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中的实现,大致分为四个阶段:
- 初始标记(Initial Mark):STW,标记GC Roots直接引用的对象。这个阶段很快,因为只标记根对象。
- 并发标记(Concurrent Mark):从根对象开始,遍历所有可达对象。这个阶段是并发的,应用线程可以继续跑。
- 重新标记(Remark):STW,修正并发标记期间因应用线程修改引用而漏标的对象。
- 并发清除(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周期分为两个阶段:
- Young GC:只回收Eden和Survivor Region。这个阶段是STW的,但时间很短,因为只处理年轻代。
- 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 meminfo或systrace查看GC频率和暂停时间。如果GC暂停超过10ms,就需要优化了。 - 减少对象分配:GC再快,也不如不GC。用对象池、避免在循环中创建对象,这些老生常谈的建议依然有效。
- 调整堆大小:通过
AndroidManifest.xml的largeHeap属性可以申请更大的堆。但注意,堆越大,GC时间越长。 - 避免内存抖动:短时间内大量分配和释放对象,会导致GC频繁触发。用
Memory Profiler可以直观地看到内存抖动。
总结:ART的CMS和G1算法,本质上是“用空间换时间”和“用时间换空间”的权衡。CMS追求低延迟,G1追求高吞吐。理解它们的实现原理,能帮你写出更流畅的Android应用。
嗯,这一章的内容就到这里。GC是个大话题,但理解了CMS和G1的核心思想,你就能在遇到性能问题时,快速定位是不是GC在“捣乱”。下一章我们会深入ART的内存分配机制,看看对象是怎么在堆上“安家落户”的。
公众号:蓝海资料掘金营,微信deep3321