看完这篇,让你不再惧怕内存优化

Posted Java小果

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了看完这篇,让你不再惧怕内存优化相关的知识,希望对你有一定的参考价值。

对于安卓应用开发来说,内存究竟会遇到什么样的问题,有什么方法可以用来测试和分析,以及有什么样的策略可以去实践优化,今天就来好好聊聊这个话题。

缘起

现代计算机是基于冯*诺依曼架构的,计算机的软件是运行在内存之中的,进程(也即运行中的程序)会耗费一定的内存,才能够正常执行。 在软件开发的中世纪,C和C++盛行的时代,是由软件开发人员(下称猿)自己管理内存,也就是说猿自己申请内存,并处理申请不到内存的情况,并在使用完成后自己负责释放内存,这无疑会加大程序开发难度,产生一些难以调试的问题,如内存越界或者内存踩踏。到了近现代,自动内存管理成为主流,研发人员不再用自己去手动管理内存了,尽管用,可劲儿造,由GC(也即是Garbage Collector内存回收器)来善后。

这极大的解放了研发人员的双手,可以让他们把更多的精力放在接收产品经理的需求上面了,三天一小需求,一周一大需求,产品迭代速度相当快,业务发展迅速,老板相当高兴啊,这干掉BAT指日可待,赶英超美就在明天,IPO触手可及。然而,现实是极其骨感的。

内存问题会引发什么问题

对于安卓 应用程序来说,内存优化很重要,因为Java VM本身就是比较耗资源的,当应用复杂到一定程度的时候,就会出现由内存使用不当造成的问题。如,测试同学反馈说应用越用越卡,经常crash,用户也反馈说应用越来越不好用了。老板把老猿叫进办公室一顿骂,然后老板让老猿尽快来解决一下问题。

老程序猿只得把需求放一边,花时间看一看这些问题,然后说凭我多年经验来看,这怕是内存出了问题。

前面提过,现代编程语言一般都有GC,帮助研发人员管理内存。但由于各种原因,还是会出现内存相关的问题。

特别是对于安卓猿来说,实现应用的编程语言是Java(准确说是JVM,Java和Kotlin 以及像Scala都是基于JVM的编程语言),天生支持GC,导致很多人对内存管理知之甚少。当应用程序复杂到一定程度,当源码庞大到一定的量级时,性能问题,特别是内存性能问题便随之而来。

具体可能是内存出现问题的场景有:

  1. OOM导致的crash。OOM,也即OutOfMemoryError,可能发生在任何地方,当Heap中可用内存不足时,便可能会遇到此类crash

  2. 应用程序越用越慢,出现黑屏或者白屏。

  3. UI操作出现卡顿,不流畅。造成UI卡,不流畅的原因很多,当排除了其他原因时,就是内存问题了

  4. 应用程序莫名闪退

内存问题的具体类型及其原因

要想做好内存优化,则必须先弄懂内存问题的根本原因,然后再对内存问题进行归类,最后是通过技术手段来解决。

内存问题的根本原因

安卓应用程序是由Java构建的,而Java是支持GC的编程语言,所以安卓猿是不需要自己手动的去做内存管理的,只管不停的创建对象即可,Java虚拟机(JVM)会帮助我们管理内存,当有不用的对象时会自动被GC。

但是Java应用程序(当然也包括安卓)还是会遇到内存问题,主要是两类,一类是内存不合理使用,如内存使用过多,频繁创建大量对象,内存碎片等等;二是内存泄露。很多人会把二者混为一谈,网络上绝大多数文章一谈性能优化,一谈内存优化,必然说到内存泄露,但其实并不严谨。内存泄露确实是最常见的内存优化内容,也确实是内存使用不合理的最常见问题,但内存问题并不局限于内存泄露。

内存使用不合理

主要分为三个方面:

  1. 浪费内存,简单来理解就是用一个人住着一千平米的大平层

  2. 大量创建小对象,产生碎片,内存碎片会造成JVM中的内存管理效率变低,当后面申请大块内存的时候效率就变差,它需要把小对象(碎片)进行转移压缩,以腾出更大的空间给大的对象使用。简单理解,这个时候JVM的效率就会变差,你的应用程序性能变差,甚至可能引起卡顿。

  3. 频繁创建对象,特别是较大的对象,造成内存抖动,也即应用程序使用的内存忽多忽少,会频繁的触发GC,从而影响JVM的运行效率。

内存泄露

JVM是支持自动GC的,也就是说JVM帮助你管理内存,当有不再使用的对象时,会被JVM自动回收,此称之为GC(Garbage Collection)。但如果对象长期处于『使用』状态,并且超出了它本应该存的周期,无法被及时GC,这就会造成泄露。一般来说,这也没啥影响,但是如果泄露的对象太多,或者泄露的时间够长,就会把系统配额Java Heap空间耗尽,应用程序便会因没有内存创建对象而OOM,就会crash。即使没有crash,因为剩余空间较少,会频繁触发GC,从而导致应用程序卡顿严重。

内存泄露的根本原因是 对象的生命周期错乱 ,对象存活了超过了其本该的生命周期,或者简言之,一个本该是较短的生命周期的对象被一个更长生命周期的对象所引用着,就会导致它本该生命周期结束时无法被GC,便产生了泄露。

这是要重点关注对象的生命周期,只有管理好了对象的生命周期,才能彻底的解决内存泄露问题。

安卓应用中的生命周期

短生命周期的对象

安卓应用程序里面,有一些是短生命周期的,或者说有明显生命周期,且不是由研发人猿自己控制的,如框架层控制的那一坨东西。

  • Activity

  • Fragment

  • View

特别是Activity,它也是内存泄露的头号对象,90%的内存泄露都是Activity对象。这货完全由系统框架控制,并且有明显的生命周期,而且还有重建实例的情况(涉及状态恢复时),所以它的生命周期其实相当短暂,并且它跟进程和主线程没有任何关系,Activity退出 了(走了onDestroy)进程仍还在,主线程也仍还在。而,又因为它是应用程序的第1级入口,应用程序所有的对象,以及GUI所有的东西,全部都由Activity直接或者间接持有,换句话说,Activity泄露了,你整个应用程序的对象也基本上全泄露了。

长生命周期对象

这里所谓的长生命周期,是指它们的生命周期是与进程绑定的,除非进程退出,或者明显的执行一些退出,否则一直随进程而存在:

  • Looper,或者说消息队列,这玩意儿除非主动quit,否则一直存在。主线程的Looper与进程同在,自己创建的Looper要手动退出才算终结。

  • 被static修饰的成员变量,这东西的生命周期是跟进程一样的

  • 单例,单例必须由static来修饰,所以与进程生命周期是一样的,进程在,则单例在

  • 线程池,或者一个长时间运行的thread,除非主动去shutdown

  • RxJava的Schedulers,这玩意跟looper一样,都是长时间运行的消息队列,且与进程绑定的

  • 系统框架,手机还在开机系统框架就在运行,所以它的生命周期远远长于某一个应用程序

  • Application和ApplicationContext,这东西与进程生命周期是一样的,相当于单例了

业务逻辑中的生命周期

业务逻辑就纯属于应用程序的本身逻辑了,无法一概而论,但一般来说,主页面的生命周期肯定是长于某个子页面的。那么子页面在其退出后,理论上它的绝大多数对象应该要被回收。

如何发现内存问题

生活中不是缺少美,而是缺少发现。

对于内存优化,第一步就是要通过各种测试手段发现问题。最理想的情况是建立一种监控手段,这样最能保住革命果实,以及非常及时的发现问题。

这里指的是一般性的粗略手段来发现你的应用有内存问题了,可能需要优化了。并且这些测试方法最好能做成定期监控,这样一旦内存性能有回撤时,能尽快发现。

『队长,我们暴露了』

很多时候都是问题主动找上门来了。

前方有雷区

很不幸,你的应用程序中弹身亡(crash了),还是OOM。这是Java语言中的一个运行时的错误,可能在创建任何对象时发生,但一般来说创建比较大的对象时,这里的大是指对内存需求大,如图片,或者大块数组时,更容易发生。

当你的应用程序出现了OOM的时候,就是一个特别明显的信号,告诉你要重视内存优化了。

遇到终结者了,是lowmemorykiller

有时候,没有明显的错误,但是应用却闪退了,特别是在后台,或者跳到其他应用页面时。

这个会比较隐蔽,通常会引发其他表象的问题。最明显的问题就是,当跳转到其他页面,再返回时,发现原来的页面状态不存在了,比如你的应用要访问一个URL,跳转到了网页浏览器,但从浏览器返回时,要么你的应用不在了,要么你的应用的原先状态不在了。这其中的原因就是当你的应用不在前台了,就被系统回收了,其中一个占大头的原因就是占用内存太多,被系统的lmk(lowmemorykiller)干掉了。

因为系统要保证整个设备的正常运转,所以会把占用内存太多的先杀掉,以释放内存。

当你的应用频繁的遇到被lowmemory killer干掉时,也是一个明显的信号,要重视内存优化了。

读懂系统GC日志

有些时候不像前面那样严重,但是查看logcat日志时,能发现大量的GC日志,就像这样的

259857:01-08 20:00:17.836 10083 26337 26347 I test.test: NativeAlloc concurrent copying GC freed 141174(6852KB) AllocSpace objects, 29(12MB) LOS objects, 49% free, 24MB/48MB, paused 180us total 308.126ms 279178:01-08 20:00:19.618 10083 26337 26347 I test.test: Background young concurrent copying GC freed 469755(20MB) AllocSpace objects, 40(3608KB) LOS objects, 41% free, 28MB/48MB, paused 396us total 124.817ms

这是系统在进行GC,通常来说这没有什么问题。但如果在短时间内,比如某个页面,点了某个按扭后大量出现此类日志,也是一个明显的信号,告诉你要重视内存优化了。

主动出击,以攻为守

作为一个优秀的猿,不能坐着等问题上来,要能主动的去创造问题。每当完成一个需求后,或者写了一大坨代码以后,就需要主动的去查看一下内存方面是否有需要优化的地方。我们可以通过如下测试方法,来看内存是否有问题,是否需要做优化。重点就是看应用程序在一定时间内,使用的内存是否一直在增长, 有没有抖动,并且在GC后,或者退出 后是否仍不回落。

meminfo

具命令是adb shell dumpsys meminfo ,这个命令还是比较常见的,网上有很多资料可以用,可以看后面罗列的参考文章中来详细了解它的具体用法以及各个字段的意义,这里就重复了。

需要关注一下重点,就是,可以重点看Java Heap一栏的数据变化,这是Java层的占用内存情况。另外就是每次运行meminfo其实会对进程产生影响。所以,这个命令可以用来粗维度的监控,查看一些信息,做一些定性的分析。

它最大的优点是方便,且只要是进程都可以查看,不用有源码。

android Studio的Memory Profiler

在远古时代安卓SDK中会有DDMS,里面是一套调试工具,但现在都集成到Android Studio的Profiler里面了,通常会在下方的工具栏里面,如果 没有就到菜单View->Tools Window->Profiler把它调出来。然后选择要调试的进程,默认它会把CPU,Network,Memory和功耗都显示,这里可以双击Memory那一坨,就会进入专门的内存页面。

它会以时间轴的方式来图形化的展示内存使用情况,非常的直观和方便。通过这个可以直观的看到两个问题,就是嫌疑内存泄露以及内存抖动。

嫌疑内存泄露就是看到曲线一直在增长,且通过显示GC,或者退出后,或者停止某项目操作后,仍不回落的,这就非常有可能有泄露的存在,泄露是超出了它本该的生命周期,比如某一操作结束了,退出 了某一页面,甚至退出应用了,内存仍没有回落,就可能有问题。

另外就是内存抖动,就是能看到内存曲线 有毛刺,短时间内忽上忽下的,这就是内存抖动。

leakcanary

这货也是非常流行的,专门用于检测内存泄露的工具,它的功能较为强大,除了可以监控以外,还可以给出详细的trace。具体使用可以参考官方的文档,并不难。

但它最大的问题在于,必须参与项目构建。假如你想研究一下竞品的情况,就没有办法了。

如何调试内存问题

通过前面提到的手段,我们可以发现内存有一些问题了,需要进行内存方面的优化了,但这还不够,还需要一些精细化的调试方法来具体定位问题,这样才能更好的去进行优化。

那么有哪些具体的调试方法呢?

Allocation tracer

这个是前面提到的Android Studio Profier里面的工具。用Profiler可以发现问题,但还需要进一步的深入的分析问题。这就需要Allocation tracer了。

具体做法就是,当你发现某一系列操作后内存一直增长,或者看到有抖动现象时,就可以抓取这段时间的Heap dump,然后详细分析,现在Android Studio都集成好了,只需要点几下,就能抓到,并把结果列出来,可以看到具体创建哪些对象,以及它们的引用关系是怎样的。

可以参考 以下资源来详细了解如何使用此工具:

MAT

这是专门用于Java heap内存分析的工具,相当强大。但不能直接使用。

需要先想办法抓取进程的heap dump,然后转换为Java标准的格式(因为安卓的Heap与Java SE的并不一样,安卓 SDK中有转换工具),然后再用MAT打开即可,它的功能要远强大于前面的提到的Allocation tracer。所以,如果要深度的分析和优化,还是要用MAT。

关于MAT的具体使用方法,可以参考以下资源:

leakcanary

除了能监控以外,它还能分析具体的内存泄露,并给出trace,所以当发现问题后,具体定位问题的时候,也可以使用此工具,还是相当强大的。

它的使用相当简单,直接把它加入到dependencies,然后构建 就好了。

至于它的分析结果也是相当直观的,会以Notification的方式通知你,点开后有一个页面展示出引用关系链,然后判断是否是泄露,即可。

详细可以参阅它的 官方文档 就可以了。

如何优化内存

内存优化,一大半在于测试,监控和调试分析,约占70%,这部分是重头,因为只有找到具体的代码位置,才好去修复问题,并且修复后还要验证问题是否真的修复了。不能光在那里看代码,想当然的认为把几个内部类改为static,或者传递引用了ApplicationContext,就能优化了内存。

对于性能优化,当然也包括内存优化,必须用测试手段进行量化,以此来验证是否真有有改善。

本节内容,假设已通过前面提到的测试方法发现了内存问题,并通过调试手段定位到了具体位置。优化的手段也要针对 具体的问题来进行:

避免内存泄露

内存优化的大头是要避免泄露,所以重点来谈谈如何避免内存泄露。

前面提到了,内存泄露的根本原因是生命周期混乱,较长生命周期的对象,甚至是超长生命周期的对象,持有了较短生命周期的对象,这一定会导致泄露。所以,要想真的解决内存泄露问题,必须设计好对象的生命周期,这是根本解决之法。

要尽可能的,缩小对象的生命周期

对象的生周期不应该超出它本该存在的范围,并且应该尽可能的减少对象的生命周期,这个可能在设计阶段考虑到。但一般较难执行,代码复杂了,很难控得住。

对于超过Activity生命周期的对象要及时清理

前面提到过的超长生命周期的东西,如Looper,如Frameworks,如单例,如RxJava的Schedulers,如线程池,这些东西的生命周期远长于Activity,所以,一定要在对应的地方,及时清除对Activity的引用持有。

后面的参考 资料里面也有大量的实用建议可以参考,这里就不重复了。避免内存泄露应该要被总结成为编程规范,然后在团队内部推行,当然也可以设计一些源码静态检测工具,来强制执行。当然,再好的工具和规范也需要人来遵守,任何事情能够在编码阶段防止发生,成本是最小的,收益 是最大的。

WeakReference和SoftReference不是救命稻草

千万不要用WeakReference和SoftReference这东西来修复内存泄露问题,它们根本就不是用来修复内存泄露问题的。

再说一遍,内存泄露是由生命周期混乱造成的。

如果强行使用WeakReference来代替原来的强引用,就会造成想使用对象的时候它却被回收了,这时你的正常逻辑就没法走了,而且如何正确的处理这种异常case,也是很难恰当 的处理的。

WeakReference这东西最最合理,最为适合的场景就是缓存里面,也就是说它本身是用于一种可有可无的引用关系,这样一旦被GC了,也不会影响原有逻辑,因为对象本来就可能在,也可能不在缓存里面,使用者必须处理在或者不在两种case。因为缓存的清理可能不够及时(必须由编码人员手动设置条件去清理,比如在退出的时候),当JVM需要GC时,因为都是WeakReference,GC就可以快速的回收对象释放内存。

不要到处给对象引用置为null

很多有过C++经验的同学,可能会习惯在对象使用完成后,手动把对象置为null。但其实这是完全没有必要的,只会造成不必要的混乱,JVM会自己去追踪每个对象,它到底还有没有被引用持有着。我们要把精力重点放在对象生命周期的把控上面,简单的置为null,不会缩减对象的生命周期,所以它对解决和防止泄露方面没有任何帮助。

内存使用优化方式

除了避免内存泄露,其他一些方式也是有很多技术可以用于优化的。

减少内存浪费

内存浪费,就是使用了没必要的内存,虽然可能不会引发问题,但是还是会增加风险,比如同样都是后台进程,你的应用占用内存稍大了一些,被杀的风险就高了一些。

减少内存浪费,核心的方法就是按需申请,特别像图片这种内存占用大户,一定要按需要来加载,何为需要就是目标View的大小,具体可以看官方教程 Loading Large Bitmaps Efficiently 。以及尽可能的 要复用bitmap

再如资源图片,设置合理的分辨率,没有必要啥都上高清,且要为低精度设备提供单独的一套资源。

以及像不是要求那么清晰的场景就用RGB_565,而非RGBA_8888等等,这些都是在编码的时候就可以提高内存使用的方法。

使用缓存

缓存是计算机史上最伟大的发明,甚至是人类史上最伟大的发明,它无处不在从硬件到软件都会使用缓存,并且它在各种东西的设计之中都是很重要的一部分。

前面提到的内存抖动问题,就需要用缓存来解决,以避免频繁创建对象。特别是涉及图片的场景,比如流行的图片加载开源库里面都有专门的缓存的机制,有些是二级,有些是三级。当需要设计缓存时,可以重点参考图片加载库中的缓存设计。

另外,SDK中也有标准的缓存组件可以用, LruCache ,这是针对内存层面的缓存,可以看 这篇文章 来详细了解使用方法。

合理复用对象

这里的意思是使用像 享元这样的设计模式 ,来合理的复用对象。

需要注意的是享元(Flyweight Pattern)的适用场景,它适用于创建对象的成本较高,比如创建对象需要的一些资源较昂贵,不同的对象仅是有不同的属性,或者说对象本身在使用的时候的表现是不同的。

一个典型的例子就是绘图的形状,比如一个页面有大量的不同的形状需要绘制,有方的,有圆的,有白色的,有彩色的,有实边的有虚线的。常规的思路是一个基类叫Shape,里面有各种属性,还有一个draw方法,子类可以定义不同的属性,各自实现draw方法。然后根据需求创建一大坨具体的对象,遍历调用draw方法。这是面向对象编程(OOP)中的非常标准的多态(Polymophsim)。事实上,你只需要创建一个对象就够了,它会根据不同的属性画出不同的效果。这就是设计模式中的享元模式,具体可以参考 这篇文章 来详细了解。

认识几种不同的内存类型

通过各种工具查看的内存时,如通过meminfo以及像Memory profiler,但可以发现有不同种类,需要重点关注以几种:

Java Heap

也即通常意义上的heap内存(堆内存),名字可能会是Java,Java Heap,或者Java allocate,但都是一样就是指纯Java代码中通过new创建对象时使用的内存。

Native Heap

因为Java是支持JNI与C/C++接通,也即native方法,那么通过native方法创建的对象是计算在Native之中的,它与Java层是分开的,当然通过native方法(malloc或者new)创建的对象,要记得去释放,否则是一定会泄露的。

因为Android的大部分是由C/C++实现的,Java层仅是封装,Frameworks层大部分功能都由JNI转到native层去实现的,因此native这部分的内存也是很多的,并且由于Frameworks本身会大量调用JNI native层,所以即使你的应用程序根本没有用到JNI,但是还是会看到Native内存使用。

Graphics

主要是涉及OpenGL ES的相关内存占用,如GL Surfaces,如Texture或者如Framebuffer等,它们所占用的内存。

这里需要特别注意的是,即使你的应用没有用到OpenGL相关的东西,但仍可能会有此部分内存占用,这是由于硬件加速本身也是通过OpenGL ES实现的。

ion内存

这个是为了效率,直接从kernel层开出shared buffer,以加速内存使用效率,这个是偏底层的,普通app是用不到的。

可以参考一下这个 The Android ION memory allocator

共享内存

可以理解为Linux中的匿名共享内存,可以用来实现IPC通信,但它并不会被Profiler计算在Java或者Native里面。非死不可出品的Fresco当初牛逼的地方就在于把Bitmap放在匿名共享内存里面,从而不占用应用自己的Heap空间。

可以参考这两个文章:

学无止境

深入学习GC相关知识,如JVM的GC如何演进。

也可以学习一下其他编程语言的GC机制。

不要过早优化,更不能过度优化

性能优化这个事情是要在架构设计和产品设计阶段就需要考虑的事情,比如是否要加入缓存。

但如果前期想太多,会造成严重的扭曲,会让你陷入无限的复杂问题里面,难以自拔(本是问题1,但是变成了问题A,问题B,直到问题z,最初的问题1却被忽略了),反倒不是好事情。

最为想理的情况就是小步迭代,先提出能满足需求的最小版本,然后逐步迭代。比如说做一个新的feature的时候,先用最简单的架构和设计来实现,然后考虑补充细节,处理异常case,再考虑可能的扩展,然后考虑性能优化。

剩下的是态度

不是说一线开发的态度,而是老板们的态度。

性能问题是直接影响体验,所以只有重视体验的老板才会重视性能问题。而且这也不是研发猿的问题,需要测试,产品都要能重视性能问题,才能最终把性能做好。产品同学不能只顾着提需求,也要平衡性能,并且给研发同学一定的时间去注重性能问题,而测试同学更加重要,需要不断精进你的测试方法,帮助研发同学更好的解决问题,并且要有监控手段,比如说A版本做了性能优化专项,那么为了保留革命果实,需要有一种监控手段,以防性能出现重大回撤。

很多事情不能怪研发,就像有一位技术相当不错的同事说过的话,当时大家聊起性能优化的事情,他说:『道理大家都懂,但当左边是产品经理在那里崔需求,右边是设计师在那说按扭还差几个象素,测试同学在那崔你赶紧发版本啊,我还等着测完回家呢!当你处在这种条件下,谁TMD的还管性能啊,先实现了再说吧,甚至代码格式都懒得改了。』

所以,这是整个工程体系的事情,只有整个研发体系都注重性能,性能才会好,体验才会好,而这就需要一个老板的支持了,否则,性能不可能好,产品汪们只顾着提需求,设计师只顾着画面精美,研发同学光实现需求都做不完,哪有精力去搞性能啊!测试同学也不能只用粗浅的测试方法,只说性能不好,具体哪不好,不应该都让研发自己去调试,去发现问题。另外,也需要做好性能监控机制,以保住革命果实。要不然,A版本辛辛苦苦搞了一轮性能优化,也有大幅改善,然后到了B版本,或者几个月后,再来一轮。

这就是很骨感的现实。所以,在现实生活中只有大厂头部应用 才真的重视性能和体验,并且才能把性能和体验做好。

希望能对你有所帮助!

需要领取免费资料的小伙伴们,添加小助手vx:SOSOXWV  即可免费领取资料哦!

看完这篇还不清楚Netty的内存管理,那我就哭了!


说明

在学习Netty的时候,ByteBuf随处可见,但是如何高效分配ByteBuf还是很复杂的,Netty的池化内存分配这块还是比较难的,很多人学习过,看过但是还是云里雾里的,本篇文章就是主要来讲解:Netty分配池化的堆外内存的细节,期待可以让你明白!!!

由于为了更好的表达,文章中的图我最少画了6小时,画的不熟悉,并且也强调一些细节上。

由于该源码中涉及到大量的二进制操作,建议看看我之前写的2篇二进制文章:,。

ByteBuf重要性

ByteBuf在Netty中一直存在,读写必备!ByteBuf是Netty的数据容器,高效分配ByteBuf至关重要!

看完这篇还不清楚Netty的内存管理,那我就哭了!Netty从socket读取数据。


看完这篇还不清楚Netty的内存管理,那我就哭了!Netty准备把数据写到socket中去。


看完这篇还不清楚Netty的内存管理,那我就哭了!通过这里我们就可以看到,再把数据写socket的之前会判断是否是堆外内存,如果不是会构造一个directbuffer对象的,细节代码如下:

if (msg instanceof ByteBuf) {
            ByteBuf buf = (ByteBuf) msg;
            if (buf.isDirect()) {
                return msg;
            }

            return newDirectBuffer(buf);
        }


看完这篇还不清楚Netty的内存管理,那我就哭了!所以本篇文章就是主要来讲解:Netty分配池化的堆外内存的细节,其实分配堆内存的细节很多也是类似的。

备注: 为什么不是堆外内存还要转堆外内存,为什么加这个判断,我之前也不理解,忽然有天和涤生大佬讨论,讨论讨论就清晰了,后续有空写篇。

总览

看完这篇还不清楚Netty的内存管理,那我就哭了!

本次主要讨论的是关于池化内存的分配,PooledByteBufAllocator就是netty分配池化内存的操作入口。

其提供对外常用操作api:

看完这篇还不清楚Netty的内存管理,那我就哭了!

Netty在发送数据的时候会判断是否是堆外内存,如果不是会进行封装的:

看完这篇还不清楚Netty的内存管理,那我就哭了!

所有这里我们以分配池化的堆外内存为例,进行本文说明。池化的堆内存分配其实流程都差不多的。

下面我们来看看分配示例demo:

public static void main(String[] args{
    ByteBufAllocator alloc = PooledByteBufAllocator.DEFAULT;

    //tiny规格内存分配 会变成大于等于16的整数倍的数:这里254 会规格化为256
    ByteBuf byteBuf = alloc.directBuffer(254);

    //读写bytebuf
    byteBuf.writeInt(126);
    System.out.println(byteBuf.readInt());

    //很重要,内存释放
    byteBuf.release();
}

后续我们都会根据这段简单的demo进行分析。

操作入口类

PooledByteBufAllocator的初始化:

看完这篇还不清楚Netty的内存管理,那我就哭了!

进去之后可以看到核心类的一初始化操作:

看完这篇还不清楚Netty的内存管理,那我就哭了!

看完这篇还不清楚Netty的内存管理,那我就哭了!

看完这篇还不清楚Netty的内存管理,那我就哭了!

分配理论是jemalloc,可以理解为java版本的jemalloc实现。

PoolThreadCache

看完这篇还不清楚Netty的内存管理,那我就哭了!

通过上图可以清晰的了解到PoolThreadCache的主要数据结构。

开始的时候,这些Cache里面都是没有值的,只有在调用free释放的时候(在后续释放内存中会讲解),才会把之前分配的内存大小放到该cache的queue里面,其实每次分配的时候都是先看看是否缓存里面有,如果有直接返回,没有则进行正常的分配流程(内存分配会讲解)。

我们来看看PoolArena  directArena内容:

看完这篇还不清楚Netty的内存管理,那我就哭了!

下面我们来看看PoolArena结构。

PoolArena

看完这篇还不清楚Netty的内存管理,那我就哭了!

通过下图可以清晰的了解到PoolArena的主要数据结构。

看完这篇还不清楚Netty的内存管理,那我就哭了!

在PoolArena里面涉及到PoolChunkList和PoolSubpage对应的结构有PoolChunk和PoolSubpage,我们来详细的看看这2块内容。

PoolChunk

第一次的时候,PoolChunkList、PoolSubpage都是默认值,需要新增一个Chunk,默认一个Chunk是16M。内部会结构是完全二叉树一共有4096个节点,有2048个叶子节点(每个叶子节点大小为一个page,就是8k),非叶子节点的内存大小等于左子树内存大小加上右子树内存大小。

完全二叉树结构如下:

看完这篇还不清楚Netty的内存管理,那我就哭了!

这颗完全二叉树在java中是使用数组来进行表示的。

唯一需要注意的是,下标是从1开始而不是0.

看完这篇还不清楚Netty的内存管理,那我就哭了!

depthMap的值初始化后不再改变,memoryMap的值则随着节点分配而改变。

看完这篇还不清楚Netty的内存管理,那我就哭了!

这个值太多就不都截图了,就是把上面那颗完全二叉树用数组表示了而已,只是值存的不是节点的下标而是存的树的深度而已。

depthMap数组值为0表示可以分配16M空间,如果为1 表示可以分配8M,,如果为2表示嗯可以分配4M,如果为3表示可以分配2M ……………………如果为11表示可以分配8k空间。

如果该节点已经分配完成,就设置为12即可。

怎么确定需要分配的大小在深度是多少?

如果需要分配的内存规格化之后,是小于8k,那么在8k上面分配即可(即深度为11)。

如果为8k或者大于8k那么通过下面代码就可以定位到深度了:

int d = maxOrder - (log2(normCapacity) - pageShifts);

知道深度之后,怎么进行定位到那个节点呢???

看完这篇还不清楚Netty的内存管理,那我就哭了!

找到该节点之后,先把该节点显示占用,在更新起父节点父节点的父………………如下:

看完这篇还不清楚Netty的内存管理,那我就哭了!

SubpagePool

看完这篇还不清楚Netty的内存管理,那我就哭了!

上面的图就是关于SubpagePool的内存结构了。我们在分配page的时候,根据memoryMap对于的值就知道是否被分配了,那么如果是subpagePool呢?

subpagePool分为2类:tinySubpagePools和smallSubpagePools,大小对于也对于上面的图里面了,每类都是固定大小的,如果分配256b的大小,那么一个page就是8k,8*1024/256 = 32块。那么怎么怎么表示每个还被分配了呢?

private final long[] bitmap;

由于一个long占用的字节数为64,我们这里仅仅是需要表示32个,所以使用一个long即可了,二进制每位 1表示已经使用了,0表示还未使用。

看完这篇还不清楚Netty的内存管理,那我就哭了!

由于subpage不仅仅需要定位到完全二叉树在那个节点,还需要知道在long的第几个 并且是第几位,所以要复杂一些:

看完这篇还不清楚Netty的内存管理,那我就哭了!

通过一个long的前32位来表示subpage的第几个long的第几位上面,通过后32来表示在完全二叉树的那个节点上面,完美。

分配核心

分配入口:ByteBuf byteBuf = alloc.directBuffer(256);

进行跟进代码:

看完这篇还不清楚Netty的内存管理,那我就哭了!

我们来看:PooledByteBuf  buf = newByteBuf(maxCapacity);

构建PooledByteBuf对象。最后返回PooledByteBuf对象。

我们来看下类继承结构:

看完这篇还不清楚Netty的内存管理,那我就哭了!

所有ByteBuf byteBuf = alloc.directBuffer(256);这句话是没有什么问题的,不会报错。

我们来看看newByteBuf(maxCapacity)的细节实现:

看完这篇还不清楚Netty的内存管理,那我就哭了!

这里借助了Netty增加实现的Recycler对象池技术。Recycler设计也非常精巧,后续可以专门写篇Recycler文章,今天不是重点,我们只要知道由于分配PolledByteBuf对象的代价有点大,如果需要频繁使用到PolledByteBuf对象,并且对性能有所要求,那么池化技术是一个不错的选择(比如我们以前使用的线程池、数据库连接池等都是类似道理),池化技术在一定程度上面减少了频繁创建对象带来的性能开销。其实这个类似的思想非常常见(比如我们查询数据库成本高,缓存到redis,思路也是一样的),在本篇后续中还可以体会到(PoolThreadCache)。

通过PooledByteBuf  buf = newByteBuf(maxCapacity);仅仅是获取到了一个初始对象而已。

分配的核心在:allocate(cache, buf, reqCapacity);

看完这篇还不清楚Netty的内存管理,那我就哭了!

  • 先尝试在1步骤 进行分配,根据不同的类型定位到不同的Caches,如果有进行分配直接返回。

  • 如果1步骤 分配不了,进行2步骤上面分配。

2步骤分配细节:看看需要分配的是什么类型 page还是subpage,如果是subpage在根据看看是tinySubpagePools还是smallSubpagePools,找到对应的槽位,看看链表里是否有可用的PoolSubpage,如果有就进行分配修改标记退出,如果没有就现需要在先分配一个page了,根据chunklist的这些看看是否有合适的,如果有合适的,那么在这些已经有的chunk上面进行分配一个page (分配page也是这个情况了)

之后在根据分配到的page,进行该请求大小的分配 (由于一个page可以存储很多同大小的数量)需要用long的位标记,表示该位置分配了,并且修改完全二叉树的父等值,分配结束。如果没有chunk那么需要新分配一块chunk之后重复上面步骤即可。

释放核心

释放入口 :byteBuf.release();

进行跟进代码:

看完这篇还不清楚Netty的内存管理,那我就哭了!

看完这篇还不清楚Netty的内存管理,那我就哭了!

看完这篇还不清楚Netty的内存管理,那我就哭了!

通过这段代码我们就这段放入到相应的queue了:

看完这篇还不清楚Netty的内存管理,那我就哭了!

缓存到了对应的Cache的queue里面了。

END

如果读完觉得有收获的话,欢迎点【好看】,关注【匠心零度】,查阅更多精彩历史!!!

让我“好看” 

以上是关于看完这篇,让你不再惧怕内存优化的主要内容,如果未能解决你的问题,请参考以下文章

看完这篇Jetpack compose开发指南,让你感觉如获至宝~

看完这篇HTTP,跟面试官扯皮就没问题了

看完这篇你还能不懂C语言/C++内存管理?

看完这篇你还能不懂C语言/C++内存管理?

看完这篇还不清楚Netty的内存管理,那我就哭了!

看完这篇, FlinkSQL 统统能整明白了