GC算法:Mark-Sweep、Concurrent Copying(CC)、Generational GC

说到Android Runtime的垃圾回收,我脑子里第一个蹦出来的词就是「博弈」。GC这玩意儿,说白了就是在「停多久」和「回收多少」之间找平衡。你想想看,用户正刷着抖音,突然卡一下,那体验就全毁了。所以ART团队这些年一直在折腾GC算法,从Mark-Sweep到Concurrent Copying,再到现在的分代GC,每一步都是血泪史。

Mark-Sweep:最朴素的方案

Mark-Sweep算法,我习惯叫它「标记-清扫」。逻辑很简单:先标记所有活着的对象,然后把没标记的统统清掉。就像你收拾房间,先把要的东西贴上标签,剩下的全扔。

核心流程:

  1. 标记阶段:从GC Roots出发,遍历所有可达对象,打上标记
  2. 清扫阶段:遍历堆内存,回收所有未标记的对象

我在项目中遇到过一个问题:Mark-Sweep会产生大量内存碎片。什么意思呢?就是回收完以后,空闲内存东一块西一块,像被狗啃过的蛋糕。这时候你要分配一个大对象,明明总空闲内存够,但就是找不到连续的空间,直接OOM。

避坑指南:我曾经在一个视频编辑App里遇到频繁的GC卡顿。排查后发现,Mark-Sweep的碎片化导致每次分配大内存都要触发GC,形成恶性循环。解决方案是改用Concurrent Copying算法,问题迎刃而解。

Mark-Sweep还有一个致命伤——STW(Stop-The-World)。标记和清扫阶段都需要暂停应用线程。在Android 4.x时代,一次GC暂停几十毫秒是常事,用户能明显感觉到卡顿。

Concurrent Copying(CC):并发才是王道

Concurrent Copying算法,我管它叫「并发复制」。它的核心思想是:一边复制存活对象,一边让应用线程继续跑。这就像给飞机换引擎——你敢信?但ART做到了。

CC算法把堆分成两个区域:From空间和To空间。GC时,把From空间里存活的对象复制到To空间,然后交换角色。这样做的好处是:

  • 无碎片:复制后对象紧密排列,内存利用率高
  • 暂停短:大部分工作并发完成,只有最后阶段需要短暂暂停
  • 吞吐量高:适合大堆场景

个人经验:CC算法在Android 5.0引入时,我正好在做一个大型游戏项目。之前用Mark-Sweep,每帧都要卡几次。换成CC后,GC暂停从30ms降到了5ms以内,体验提升非常明显。但要注意,CC对CPU资源消耗更大,低端机上反而可能更慢。

CC算法的关键挑战是「并发一致性」。GC线程在复制对象时,应用线程可能正在修改这些对象。ART通过「读屏障」和「写屏障」来解决这个问题。嗯,这里要注意,读屏障的开销不能忽视,它会影响每次对象访问的性能。

// 伪代码:CC算法的核心逻辑
void concurrentCopyingGC() {
    // 1. 标记根集合
    markRoots();
    
    // 2. 并发复制存活对象
    for (Object obj : fromSpace) {
        if (obj.isAlive()) {
            Object newObj = copyToToSpace(obj);
            updateReferences(obj, newObj);
        }
    }
    
    // 3. 短暂暂停,处理遗漏
    pauseAllThreads();
    handleRemaining();
    resumeAllThreads();
    
    // 4. 交换空间
    swapSpaces();
}

Generational GC:分而治之的智慧

分代GC,说白了就是「看人下菜碟」。研究发现,大多数对象都是「朝生夕死」的——创建后很快就变成垃圾。比如你在循环里创建的临时对象,用完就扔。只有少数对象能活很久,比如Application、Activity这些。

基于这个观察,ART把堆分成几代:

特点 GC频率 回收效率
新生代(Young) 对象存活时间短 高(大部分对象都能回收)
老年代(Old) 对象存活时间长 低(只有少量对象可回收)
大对象区(Large) 存放超大对象 按需 视情况而定

分代GC的核心策略是:频繁回收新生代,偶尔回收老年代。这样大部分GC都很快,只有少数Full GC会慢一些。用户体验上,就是「偶尔卡一下,大部分时间流畅」。

关键优化点:

  • 新生代大小:太小会导致对象过早晋升到老年代,太大则GC暂停时间长。我一般建议设置为堆大小的1/3到1/2
  • 晋升阈值:对象经过几次GC后晋升到老年代?默认是2-3次,但可以根据应用特点调整
  • 并发标记:老年代GC使用并发标记,减少暂停时间

我记得在Android 8.0上,分代GC刚引入时,很多开发者发现自己的App GC次数变多了。其实不是GC变多了,而是新生代GC变频繁了,但每次暂停时间很短。整体来看,总暂停时间反而减少了30%以上。

三种算法的对比

我整理了一个对比表,方便你直观理解:

特性 Mark-Sweep Concurrent Copying Generational GC
暂停时间 长(几十ms) 短(<5ms) 短(大部分<10ms)
内存碎片 严重 较少
CPU开销 中等
适用场景 小堆、低端机 大堆、高端机 通用场景
实现复杂度 简单 复杂 中等

知识体系图

下面这张图展示了三种GC算法的核心逻辑和演进关系:

GC算法演进与核心逻辑 Mark-Sweep 标记存活对象 清扫未标记对象 ⚠ 碎片化严重 Concurrent Copying 并发复制存活对象 无碎片、暂停短 ⚡ CPU开销高 Generational GC 分代回收策略 新生代+老年代 ✓ 综合最优 核心指标对比 暂停时间 Mark-Sweep最长,CC最短 内存碎片 CC无碎片,分代较少 CPU开销 Mark-Sweep最低,CC最高

从这张图可以清楚看到,三种算法各有优劣。Mark-Sweep简单但碎片多,CC无碎片但CPU开销大,分代GC则在两者之间找到了平衡点。ART从Android 5.0开始逐步引入这些算法,到Android 8.0基本定型。

我的建议:如果你在优化App的GC性能,先搞清楚你的App属于哪种类型。游戏类App适合CC算法,因为对暂停时间敏感;工具类App用分代GC就够;低端机上还是Mark-Sweep更稳妥。没有银弹,只有最适合的方案。

最后说一句,GC算法的选择不是一成不变的。ART在运行时会根据设备性能、堆大小、应用行为动态调整。你想想看,同一个App在旗舰机和低端机上,GC策略可能完全不同。这就是ART的厉害之处——它懂得「因地制宜」。

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