常见GC算法:标记-清除、标记-整理、复制算法

嗯,聊到GC优化,绕不开的就是这三种基础算法。我个人把它们比作“打扫房间”的三种方式——扫垃圾、搬家具、换个屋子住。每种方式都有自己的脾气,选错了,启动耗时立马翻跟头。

我记得刚做Android性能优化那会儿,以为GC只是个“黑盒”,后来被一次启动卡顿整得头大,才逼着自己把三种算法的底裤扒了个干净。今天咱们就挨个捋一捋。

标记-清除(Mark-Sweep)

最朴素的算法。分两步:先标记哪些对象“死透了”(不可达),然后直接清除它们占的内存。示意图如下:

初始堆: [A][B][C][D][E]  (ABCDE都存活)
标记后: [A][B][死][D][死]  (C和E被标记为垃圾)
清除后: [A][B][ ][D][ ]   (占位还在,但碎片化)

优点? 实现简单,不用动存活对象的位置。

缺点? 最大的毛病——内存碎片。清除后留下一堆小空隙,想分配一个大对象时,明明总空间够,但就是塞不进去。我在项目中遇到过,一个视频App启动时加载缓存,触发了连续GC,结果因为碎片太多,直接抛了OOM。排查了半天才发现是标记-清除留下的烂摊子。

还有一个问题:标记和清除两个阶段都需要暂停用户线程(Stop-The-World)。如果堆很大,暂停时间可能就是几十毫秒,在启动阶段用户正盯着空白屏幕等,这感觉太糟糕了。

注意:Android 4.4之前的Dalvik虚拟机用的就是标记-清除为主,启动时碎片问题非常明显。后来的ART虽然改进了,但某些场景下还是会回退到这个算法。

标记-整理(Mark-Compact)

标记-整理的思路是:既然碎片讨厌,那我就把活对象往一边搬,腾出一整块连续空间。流程是:标记 → 整理(移动对象并更新引用)→ 清除边界以外的区域。

说白了,就是“垃圾扫走后,再把家具码齐”。这解决了碎片问题,但代价也来了:

  • 移动对象开销大:每个存活对象都要拷贝,还要更新所有指向它的指针。堆越大,耗时越夸张。
  • 暂停时间更长:整理阶段必须暂停所有线程,不然对象正在移动时,另一个线程去读就乱套了。

我曾在一个图片加载库的优化中测试过,用标记-整理代替标记-清除,碎片率从15%降到接近0,但GC暂停时间从8ms飙到25ms。这就有意思了——你需要在碎片容忍度和暂停时间之间做trade-off。

实战经验:ART虚拟机在并发标记阶段使用“读屏障”技术,尽量让整理过程与用户线程并行,但最后的“压缩”阶段还是得短暂停。Android 8.0+的Concurrent Copying (CC) 收集器就是基于这种思路。

复制算法(Copying)

复制算法是最“暴力”但也是最干净的。它把堆分成两个相等区域(From和To)。平时只在From区分配对象。当From区满了,就把存活对象逐一复制到To区,然后一次清空From区,交换角色。

启动阶段用这个算法特别香,原因有三:

  • 无碎片:复制后对象按顺序排列,紧凑得很。
  • 分配极快:只需移动指针(bump-the-pointer),没有空闲链表查找。
  • 只遍历存活对象:堆里垃圾越多,效率越高——不用管死对象。

但缺点也很明显:内存利用率只有50%。你想想看,一块内存不管用没用,都得留一半空着当“避难所”。在内存宝贵的移动设备上,这个代价有点大。

我曾经在一个IoT项目里硬上了复制算法做小型运行时,结果对象存活率太高(>80%),复制开销反而比标记-清除还大。所以复制算法适合“朝生夕死”的场景——大部分对象都是临时分配的,存活率低。这正好匹配启动阶段:Activity、Service、短期回调对象很多,静态大对象少。

Java堆与Android:HotSpot虚拟机的年轻代(Young Generation)用的就是复制算法,但它是分了Eden和两个Survivor区,不是严格的1:1——这样能提高空间利用率到约90%。ART的“大堆”模式和“小堆”模式也会根据场景切换算法。

三种算法对比

特性 标记-清除 标记-整理 复制算法
内存碎片 严重 无 无
吞吐量(分配+回收) 中等(遍历所有对象) 低(移动+引用更新) 高(仅复制存活对象)
暂停时间 较短(取决于堆大小) 较长 中等(存活对象少时很快)
内存开销 低(只需标记位) 低(需要整理表) 高(需要50%额外空间)
适用场景 老年代、小堆 需要大对象连续分配时 年轻代、存活率低时

我自己在做GC抑制优化时,通常会看两点:对象存活率和可暂停的时长上限。启动阶段——尤其是冷启动——对象存活率很低(大部分是临时对象),复制算法天然占优。但如果是大图库、预加载缓存等留在堆里的“常驻对象”,标记-整理反而更稳。

三种GC算法核心流程对比 标记-清除 标记-整理 复制算法 ① 标记所有垃圾对象 ② 清除垃圾占用的内存 ③ 产生内存碎片 缺点:碎片导致 大对象分配失败 ① 标记所有垃圾对象 ② 存活对象向一端移动 ③ 得到连续空闲内存 代价:移动对象 暂停时间长 ① 划分From/To区 ② From区满时触发GC ③ 存活对象复制到To区 ④ 清空From区,交换角色 代价:浪费50%空间 适合低存活率 按“存活率”选择算法 按“碎片容忍度”选择算法 按“暂停时间预算”选择算法

上面这张图把三个流程串在了一起。你看复制算法那列,步骤多了,但胜在“干净利落”。在实际优化中,我会结合对象年龄统计来预判:如果主流存活对象的年龄都不超过2(即很快被回收),果断倾向复制算法;如果有些对象活过了3次GC,那可能需要启动标记-整理做一次“大压缩”,避免它们反复复制浪费空间。

一个小技巧:Android启动阶段,可以用adb shell dumpsys meminfo [package]查看各类对象的比例,再结合logcat中的GC_CAUSE判断使用了哪种算法。我一般会盯着ART GC日志里的Paused time看,那个数字直接反映暂停对启动的影响。