内存作为App程序运行最重要的资源之一,需要运行过程中做到合理的资源分配与回收,不合理的内存占用轻则使得用户应用程序运行卡顿、ANR、黑屏,重则导致用户应用程序发生 OOM(out of memory)崩溃。喜马直播随着近些年的业务迭代功能不断完善玩法丰富,需要在各种机器资源上保持优秀的流畅性和稳定性,内存优化是必须要重视的环节。
从目前的内存水位和APM建设入手,梳理统计规则和项目真实内存水位,在代码和业务多方面熟悉和归因,搭配工具建设和使用为抓手,最后通过内存专项治理将应用的 OOM优化到合理水位。
限于篇幅和功能性,文章总共分为理论篇 和实践篇两个部分。
写这篇文章动机,主要是工作中进行内存优化专项,便于将以往琐碎的内存知识和优化思路整合,所以有了这篇文章,感谢前人的经验。谨以此篇文章记录工作思路及基础知识,温故知新。
思维导图
内存基础知识
Android 最新系统都运行在ART虚拟机上,基于 Linux 内核实现,Linux的内存管理哲学是:Free memory is wasted memory。即内存没有得到充分利用就是在浪费内存。因此 Linux 希望尽可能多的使用内存,较少磁盘 IO 。Android 系统继承了 Linux 的优点,同样是尽最大限度使用原则。
与Linux不同的是 Android 侧重于可能多的缓存进程以提高应用启动和切换速度。即,Android系统会在内存中尽量的长时间的保持应用进程,直到系统分配内存不足时才会去根据进程优先级、内存代销等条件回收进程。这些保留在内存中的进程通常不会影响系统整体的运行速度,反而会在用户再次激活这些进程时,加快进程的启动速度。
在内存管理上,JVM拥有垃圾内存回收的机制,自身会在虚拟机层面自动分配和释放内存,因此不需要像使用C/C++一样在代码中分配和释放某一块内存。Android系统的内存管理原理基础就是JVM,通过new关键字来为对象分配内存,内存的释放由GC来回收。并且Android系统在内存管理上有一个 Generational Heap Memory模型 ,当内存达到某一个阈值时,系统会根据不同的规则自动释放可以释放的内存。即便有了内存管理机制,但是,如果不合理地使用内存,也会造成一系列的性能问题,比如 内存泄漏、内存抖动、短时间内分配大量的内存对象,接下来详细记录下Android内存管理模式及知识点。
Jvm内存分配模型
JVM 将整个内存划分为了几块:
- 方法区:存储类信息、常量、静态变量等。(所有线程共享)
- 虚拟机栈:存储局部变量表、操作数栈等。
- 本地方法栈:不同于虚拟机栈为 Java 方法服务、它是为 Native 方法服务的。
- 堆:内存最大的区域,每一个对象实际分配内存都是在堆上进行分配的,,而在虚拟机栈中分配的只是引用,这些引用会指向堆中真正存储的对象。此外,堆也是垃圾回收器(GC)所主要作用的区域,并且,内存泄漏也都是发生在这个区域。(所有线程共享)
- 程序计数器:存储当前线程执行目标方法执行到了第几行。
Android内存分配
Android Runtime有两种虚拟机,Dalvik 和 ART,堆 实际上就是一块匿名共享内存 。Android虚拟机仅仅只是把它封装成一个 mSpace ,由底层C库来管理 ,并且仍然使用libc提供的函数malloc和free来分配和释放内存。
大多数静态数据会被映射到一个共享的进程中。常见的静态数据包括Dalvik Code、app resources、so文件等等。Android通过显示分配共享内存区域(如Ashmem或者Gralloc)来实现动态RAM区域能够在不同进程之间共享的机制。例如,Window Surface在App和Screen Compositor之间使用共享的内存,Cursor Buffers在Content Provider和Clients之间共享内存。下面简单总结下我理解的堆结构:
Dalvik
Linear Alloc 、 Zygote Space 和 Alloc Space
ART
(重点汇总下)
-
Zygote Space: 包含由 Zygote 进程预加载并在所有应用程序之间共享的对象。
-
Allocation Space:用于存储应用程序在运行时创建的对象,主要的GC工作区域,为每个进程独自使用。
-
Non-Moving Space :存储不需要移动的对象,如 ART 内部数据结构,和Dalvik中的Linear Alloc类似。
-
Large Object Space :用于存储大对象(通常大于 12KB)使用单独的分配策略和GC机制。
-
Region Space :用于新生代对象的分配和管理,是离散地址的集合,使用更高效的垃圾回收机制。
-
Image Space:包含预编译的系统类和应用程序类,从预生成的映像文件中内存映射而来,在Zygote和其他应用程序进程之间共享,不参与GC。
Android系统的第一个虚拟机由Zygote进程创建并且只有一个Zygote Space。但是当Zygote进程在fork第一个应用程序进程之前,会将已经使用的那部分堆内存划分为一部分,还没有使用的堆内存划分为另一部分,也就是Allocation Space。但无论是应用程序进程,还是Zygote进程,当他们需要分配对象时,都是在各自的Allocation Space堆上进行。
单进程内存上限
Android 系统的 Java虚拟机会对单个进程使用的最大内存做限制,该属性值定义在/system/build.prop文件中,厂商一般会根据设备自身内存大小来设定这个值,不同的设备分配给APP的最大可用内存是不相同的。进程启动时,系统会先为APP分配一定的内存空间,当分配的内存快要耗尽时,系统会再次为App 分配更多的内存,但是每个APP都有内存使用上限,一旦进程分配了最大可用内存后,内存依然不足则会直接抛出OOM异常,终止程序的运行。可以通过调用 getMemoryClass() 向系统查询此数值。此方法返回一个整数,表示应用堆的可用兆字节数。
内存垃圾回收
当进程使用内存达到设定的阈值时,就会触发虚拟机的GC机制,虽然新一代的ART对于通过分代垃圾回收和高效的内存分配机制,ART 能够更加高效地使用内存,减少内存碎片和内存泄漏等方面的优化,但是GC的时候,还是会导致 STW (Stop The World),所以为了提升程序性能,有必要进行内存优化。下边列举一下Java的内存回收算法。
标记清除算法
标记清除算法是一种垃圾回收算法,用于管理动态分配的内存。它的主要思想是,遍历整个堆,标记所有被引用的对象,当某个对象不再被程序所引用时,它就可以被认为是"垃圾",并被回收以便后续的内存分配。 优缺点:自动管理动态分配的内存,但是清除后可能导致大量的内存碎片,降低堆利用率。
复制算法 :
复制算法是一种将内存分为两个区域的算法,其中一个区域用于存储活动对象,另一个区域用于存储不再使用的对象。 优缺点:运行效率高,但是浪费一半空间,代价较高。
标记整理算法 :
标记整理算法是标记清除算法和复制算法的结合,其工作原理是先标记出不再使用的对象,再整理内存使得活动对象的内存分配连续,优缺点:解决了标记清除算法导致的内存碎片问题,但是也产生了一些问题,由于进行了两次扫描,增加了时间开销。 相较其他垃圾回收算法,速度较慢,不适合新生代场景,并且标记整理算法的效率也受内存使用情况影响,效率不稳定等问题。
分代收集算法 :
分代回收算法是一种将内存分为几个代的算法,并对每个代进行不同的回收策略,这里就需要借一张图了,一图胜千言
新创建的对象 , 放在年轻代内存块中 , 开始时放在 Eden 区域 , 当 Eden 区域存满后 , 会将存活的对象转移到 From 区域 和 To 区域,对象每经过一次 GC 垃圾回收 , 其年龄就会加 1 ; 当年龄到达虚拟机设置的阈值之后 , 就会被放入老年代内存块中 ,持久代内存区域,主要存放着类加载器加载的Class和常量池等对象。
-
年轻代内存区域的垃圾回收器 : Minor GC (Serial,ParNew 和 Parallel Scavenge)
-
老年代内存区域的垃圾回收器 : Major GC (CMS ,Serial Old和 Parallel Old)
-
整个内存区域的垃圾回收器 : Full GC
持久代内存区域的内存不回收 ;年轻代内存区域与老年代内存区域的垃圾回收机制不同的。
Serial ParNew都是 使用的复制算法,主要在年轻代中收集要回收的内存,但是Serial是单线程串行,而ParNew则是多线程运行。
CMS Concurrent Mark Sweep ,并发标记清除收集器,采用标记清除算法,gc过程,用户线程仅做最短停顿,具体流程如下:
-
初始标记 : 标记与 GC Roots 有引用链的对象 ; 该操作速度快 , 该步骤需要暂停用户线程。
-
并发标记 : GC Roots 追踪,从初始标记结果集合中标记出存活对象,不能保证所有的存活对象都被标记 ; 该步骤与应用程序并发执行。
-
重新标记 : 上一步并发标记 GC 线程与用户程序并发期间的标记有部分变化 , 修正这部分标记信息之后暂停用户线程 开始标记,该暂停操作要比初始标记步骤暂停时间长。
-
并发清除 : 回收所有 GC Roots 不可达对象
CMS的优点是,并发收集,低停顿。有一个明显缺点就是因为采用了"标记-清除"算法,最后会出现大量碎片,有可能会出现在某一个时刻,当有大对象生成,不得不进行一次Full GC来解决这个问题。为了解决该问题CMS有一个参数-XX:UseCmsCompactAtFullCollection来解决因为空间不足进行Full GC。这个参数默认开启,用于在CMS收集器顶不住要进行Full GC是开启内存碎片合并整理的过程,内存整理过程是无法并发的,因此就会耗时。同时还有一个参数是-XX:CMSFullGCsBeforeCompaction,这个参数是用于执行多次不压缩的GC后,跟着来一次压缩的(默认是0,表示每次进入Full GC都进行碎片整理)。
G1收集器
Garbage-First收集器:并行和并发,分代收集。 G1和其他收集器不同的是,其他收集器收集范围都是整个新生代和老年代,而G1不再是这样。使用G1收集器的时候,Java堆分为多个大小相等的区域(Region),虽然也保留了新生代和老年代的概念,但是不再是物理隔离了,他们都是一部分Region的集合(这些Region不一定是连续的)。G1保留了Eden和Survivor的比例也是8:1:1。
ZGC
JDK11引入的ZGC收集器,在物理和逻辑上已经没有新/老年代的概念了,会分为一个个page,当进行GC操作时候会对page进行压缩,因此没有碎片问题,只能在64位linux上使用。
- 可以达到10ms以内的停顿要求
- 支持TB级别的内存
- 堆内存变大后停顿时间还是在10ms以内
Java 引用类型
既然上边列举了主要的回收算法,这里简单带过一下引用类型,也是内存优化过程中,经常使用到的知识点:
- 强引用 :强引用是 Java 中最常见的引用类型,当对象具有强引用时,它永远不会被垃圾回收。只有在程序结束或者手动将对象设置为
null
时,才会释放强引用,像常用的new
方式。 - 软引用 :当 Java 堆内存不足时,软引用可能会被回收,以腾出内存空间。如果内存充足,则软引用可以继续存在,使用SoftReference创建,gc的时候可能并不会释放软引用持有对象。
- 弱引用 :在垃圾回收器线程扫描它所管辖的内存区域的过程中,发现具有弱引用的对象,不管当前内存空间是否足够,都会回收它的内存。
- 虚引用 : 只能用于跟踪即将对被引用对象进行的收集。虚拟机必须与ReferenceQueue类联合使用。因为它能够充当通知机制。
内存优化的必要性
经过上述基础知识的分析,我们认识到内存在手机侧的宝贵和重要性,安卓系统对每个应用程序都有一定的内存限制,当应用程序的内存超过了上限,就会出现 OOM,造成App的异常退出。
因此,要改善系统的运行效率、改善用户体验、降低系统资源占用、延长电池寿命、降低系统故障的危险。Android通过内存优化,可以减少系统内存使用 提高应用后台运行存活率,让系统更加流畅,秒开率更高,减少系统GC次数,减少页面卡顿率,降低内存泄漏率,从而提高App的整体体验和功耗优化。
内存优化思路及SOP
数据收集及水位分析
关于性能或功耗方面的优化问题,都要先做到摸清大盘数据,特别是我这种新的公司,新的APM系统的,一定要摸清APM上报原理和逻辑,并且输出自己的文档,好记性不如烂笔头。
查看内存泄露、内存溢出和线程超标等方面的数据后,动手改代码前要确定好优化后的目标水位是多少,定好里程碑,及时同步领导,避免大方向错误。
借助工具排查问题
在线上大盘水位摸清之后,比如我们是基于Koom进行了魔改,就可以将上报的内存泄露问题统一汇总并解决,但是内存的水位偏高和内存抖动问题,就需要借助本地检测工具进行排查。下篇具体汇总下,通过工具查看问题。 比如图片使用方面,可以使用 Hook native bitmap (新版本都是用Native内存,暂不考虑 8.0 之前的版本情况了)检测图片在退出页面后,是否清理完成。
问题优化
针对不同类型问题需要使用不同的方式,比如内存抖动问题,存在一些大对象或者过多小对象的情况,可能存在频繁创建对象等问题。如果存在内存泄露等问题,则需要根据堆栈调用链,排查那些代码存在纰漏。针对问题优化后,做好兜底策略,并后续关注新版本是否有新的上报。
长效治理策略
问题如果解决后,可以将常见的内存泄露问题进行汇总,在排查问题过程中,使用到的工具及操作流程进行整理,汇总成内存优化SOP,可以在组内进行技术分享,提高团队的内存优化意识。
关于长期卡口,可以尝试在Git Hook的办法,针对提交的代码进行监控,如果出现内存泄露的写法阻止代码提交,并展示警告信息。