9、ART垃圾回收基础:CMS与G1算法在ART中的实现,并发标记与清理

说到Android的内存管理,垃圾回收(GC)是个绕不开的话题。很多开发者一遇到卡顿、掉帧,第一反应就是“GC是不是在搞鬼”。嗯,这个直觉是对的。ART运行时从Android 5.0开始取代Dalvik,其中一个重要改进就是GC算法的演进。

我个人习惯把GC比作房间打扫。Dalvik时代就像用扫帚扫地,得停下来扫。ART时代呢?我们有了吸尘器,甚至有了可以边扫边干活的机器人。今天要聊的CMS和G1,就是ART里的两种“智能清洁方案”。

9.1 从Dalvik到ART:GC的进化之路

先简单回顾一下背景。Dalvik用的是Mark-Sweep(标记-清除)算法,而且大部分时候是STW(Stop-The-World)的。什么意思?就是GC一跑,所有线程都得停下来等你扫完。这在512MB内存的旧手机上还能忍,到了现在8GB、12GB的时代,简直不可想象。

ART在Android 5.0引入时,带来了两大改变:

  • AOT编译:应用安装时就编译成机器码,运行时不再需要解释执行
  • 改进的GC算法:引入了并发标记和并发清理的能力

说白了,ART的GC目标很明确:减少暂停时间,提高响应速度。你想想看,用户正在滑动列表,突然卡了200ms,这体验得多糟糕。

核心观点:ART的GC设计哲学是“尽量别让用户感觉到GC的存在”。如果非要有暂停,那就把暂停时间控制在5ms以内。

9.2 CMS算法在ART中的实现

CMS,全称Concurrent Mark Sweep,并发标记-清除。这个算法在Java世界里已经很有名了,ART把它移植过来,做了一些Android特有的优化。

9.2.1 CMS的四个阶段

CMS在ART中的实现,大致分为四个阶段:

  1. 初始标记(Initial Mark):STW,标记GC Roots直接引用的对象。这个阶段很快,因为只标记根对象。
  2. 并发标记(Concurrent Mark):从根对象开始,遍历所有可达对象。这个阶段是并发的,应用线程可以继续跑。
  3. 重新标记(Remark):STW,修正并发标记期间因应用线程修改引用而漏标的对象。
  4. 并发清除(Concurrent Sweep):清理未被标记的对象。这个阶段也是并发的。

我在项目中遇到过一个问题:某个视频编辑应用在导出视频时频繁触发CMS的重新标记阶段,导致导出时间延长了30%。后来发现是因为应用在导出过程中创建了大量临时对象,导致并发标记阶段漏标太多,重新标记时不得不扫描大量对象。

经验之谈:CMS的重新标记阶段是STW的,如果这个阶段耗时太长,说明你的应用在并发标记期间产生了太多“浮动垃圾”。减少临时对象分配,或者调整触发阈值,都能缓解这个问题。

9.2.2 CMS的优缺点

优点 缺点
并发执行,暂停时间短 会产生内存碎片
对CPU资源敏感度低 无法处理浮动垃圾
实现相对简单 并发阶段会占用CPU

说白了,CMS适合那些对响应时间要求高、但内存分配不那么频繁的场景。比如一个即时通讯应用,用户发消息时你肯定不希望卡顿。

9.3 G1算法在ART中的实现

G1(Garbage First)算法是Java 7引入的,ART在Android 9(Pie)中开始支持。G1的设计思路和CMS完全不同——它把堆内存分成多个Region(区域),每次只回收垃圾最多的Region。

9.3.1 G1的核心设计

G1把堆分成大约2048个Region,每个Region大小从1MB到32MB不等。Region的角色是动态的:

  • Eden Region:新对象分配的区域
  • Survivor Region:存活对象从Eden晋升到这里
  • Old Region:经过多次GC仍然存活的对象
  • Humongous Region:存储大对象(超过Region大小的一半)

为什么会这样设计?你想想看,CMS每次都要扫描整个堆,堆越大,扫描时间越长。G1只扫描一部分Region,效率自然高。

关键点:G1的“Garbage First”意思是优先回收垃圾最多的Region。这样每次GC都能回收最多的内存,用最少的时间。

9.3.2 G1的GC周期

G1的GC周期分为两个阶段:

  1. Young GC:只回收Eden和Survivor Region。这个阶段是STW的,但时间很短,因为只处理年轻代。
  2. Mixed GC:回收所有Region(年轻代+老年代)。这个阶段包含并发标记和并发清理。

我曾经调试过一个游戏应用,它在加载新关卡时会出现明显的卡顿。用systrace一看,发现是G1的Mixed GC在作祟。加载关卡时分配了大量对象,触发了Mixed GC,而Mixed GC的并发标记阶段占用了太多CPU,导致渲染线程拿不到时间片。

避坑指南:我曾经在优化一个图片加载库时,发现G1的Humongous Region分配会导致短暂的STW暂停。原因是分配大对象时,G1需要找到连续的Region来存放。如果你的应用频繁分配大对象(比如Bitmap),可以考虑复用对象池,避免触发Humongous分配。

9.4 并发标记与清理的实现细节

不管是CMS还是G1,并发标记和并发清理都是核心能力。ART是怎么做到“一边跑一边扫”的呢?

9.4.1 三色标记法

ART的并发标记基于三色标记法:

  • 白色:未被标记的对象,GC后会被回收
  • 灰色:已被标记,但其引用的对象还未被标记
  • 黑色:已被标记,且其引用的对象也已被标记

并发标记的问题在于:应用线程可能在标记过程中修改引用。比如一个黑色对象本来引用白色对象,应用线程把这个引用改成了另一个白色对象。如果不处理,这个白色对象就会被误回收。

ART的解决方案是写屏障(Write Barrier)。当应用线程修改引用时,写屏障会记录这个修改,让GC在重新标记阶段处理。

深入理解:写屏障是有性能开销的。每次引用赋值都会触发写屏障,虽然开销很小,但频繁赋值时累积起来也不容忽视。我建议在性能敏感的热点代码中,尽量减少不必要的引用赋值。

9.4.2 并发清理的挑战

并发清理听起来简单——标记完了,把没标记的对象清理掉就行。但实际实现时有个问题:清理过程中,应用线程可能正在访问这些对象。

ART的做法是:先清理,再压缩。清理阶段只回收对象占用的内存,不移动对象。压缩阶段(可选)才会移动对象来减少碎片。这样应用线程在清理阶段访问对象时,对象还在原来的位置,不会出问题。

9.5 CMS vs G1:如何选择?

ART在Android 9之后默认使用G1,但CMS仍然可用。选择哪个,取决于你的应用场景:

场景 推荐算法 原因
低延迟、小堆(<4GB) CMS 暂停时间更可预测
大堆(>4GB)、高吞吐 G1 Region设计减少扫描范围
内存碎片严重 G1 压缩阶段可以整理碎片
频繁分配大对象 CMS G1的Humongous分配有额外开销

我个人习惯是:如果是新项目,直接用G1。ART的G1实现已经相当成熟,而且后续的优化都会集中在G1上。CMS虽然稳定,但已经是“老技术”了。

9.6 实战建议

说了这么多理论,最后给几个实战建议:

  • 监控GC日志:用adb shell dumpsys meminfosystrace查看GC频率和暂停时间。如果GC暂停超过10ms,就需要优化了。
  • 减少对象分配:GC再快,也不如不GC。用对象池、避免在循环中创建对象,这些老生常谈的建议依然有效。
  • 调整堆大小:通过AndroidManifest.xmllargeHeap属性可以申请更大的堆。但注意,堆越大,GC时间越长。
  • 避免内存抖动:短时间内大量分配和释放对象,会导致GC频繁触发。用Memory Profiler可以直观地看到内存抖动。

总结:ART的CMS和G1算法,本质上是“用空间换时间”和“用时间换空间”的权衡。CMS追求低延迟,G1追求高吞吐。理解它们的实现原理,能帮你写出更流畅的Android应用。

ART垃圾回收算法对比:CMS vs G1 CMS 算法 阶段: 1. 初始标记 (STW) 2. 并发标记 3. 重新标记 (STW) 4. 并发清除 特点: ✓ 暂停时间短 ✗ 内存碎片 ✗ 浮动垃圾 适用:小堆、低延迟 G1 算法 阶段: 1. Young GC (STW) 2. 并发标记 3. Mixed GC 4. 压缩整理 特点: ✓ Region设计 ✓ 可预测暂停 ✓ 减少碎片 适用:大堆、高吞吐 ART 从 Android 9 开始默认使用 G1 算法

嗯,这一章的内容就到这里。GC是个大话题,但理解了CMS和G1的核心思想,你就能在遇到性能问题时,快速定位是不是GC在“捣乱”。下一章我们会深入ART的内存分配机制,看看对象是怎么在堆上“安家落户”的。


公众号:蓝海资料掘金营,微信deep3321