GC算法详解:标记清除/复制/整理
约 2303 字大约 8 分钟
javagcalgorithms
2025-03-10
概述
垃圾回收(Garbage Collection, GC)是JVM自动内存管理的核心。GC算法需要解决两个基本问题:哪些对象是垃圾(可达性分析)和如何回收垃圾(回收算法)。本文详解三种基础GC算法及分代收集理论。
对象存活判断
引用计数法(Reference Counting)
每个对象维护一个引用计数器,被引用时加1,引用失效时减1,计数为0则可回收。
致命缺陷:无法处理循环引用。
// 循环引用示例
class Node {
Node next;
}
Node a = new Node();
Node b = new Node();
a.next = b; // b引用计数=1
b.next = a; // a引用计数=1
a = null; // a引用计数=1(b.next仍引用)
b = null; // b引用计数=1(a.next仍引用)
// 两个对象都无法回收,但已无法从外部访问可达性分析(Reachability Analysis)
从 GC Roots 出发,沿引用链遍历,不可达的对象即为垃圾。这是Java(以及大多数现代GC)使用的算法。
红色的F、G虽然互相引用,但从GC Roots不可达,会被判定为垃圾。
GC Roots包括
| GC Root | 说明 |
|---|---|
| 虚拟机栈引用 | 方法参数、局部变量 |
| 方法区静态变量 | static Object obj |
| 方法区常量 | static final String CONST |
| JNI引用 | Native方法持有的引用 |
| synchronized锁对象 | 被 synchronized 持有的对象 |
| JVM内部引用 | Class对象、异常对象、类加载器 |
标记-清除算法(Mark-Sweep)
最基础的GC算法,分两个阶段:
- 标记阶段:从GC Roots遍历,标记所有可达对象
- 清除阶段:遍历堆,回收未被标记的对象
优点:
- 实现简单
- 不需要移动对象
缺点:
- 内存碎片:清除后产生大量不连续的空闲空间,大对象可能无法分配
- 效率不稳定:标记和清除两个阶段的时间与堆中对象总数成正比
标记-复制算法(Mark-Copy)
将内存分为两个大小相等的半区(From / To),每次只使用其中一个。GC时将存活对象复制到另一半区,然后清空当前半区。
优点:
- 无内存碎片,分配只需移动指针(Bump Pointer)
- 只处理存活对象,存活率低时效率极高
缺点:
- 内存利用率仅50%
- 存活率高时复制开销大
Appel式回收(改进版)
HotSpot新生代采用的优化方案:将新生代分为1个 Eden 和2个 Survivor(默认比例8:1:1)。
每次只有 Eden + 1个Survivor 被使用,内存利用率提升到 90%。当 Survivor 不够时,老年代进行分配担保(Handle Promotion)。
标记-整理算法(Mark-Compact)
在标记阶段后,不直接清除,而是将所有存活对象向内存一端移动,然后清理边界外的空间。
优点:
- 无内存碎片
- 内存利用率高(不需要两个半区)
缺点:
- 移动对象需要更新所有引用,开销大
- 移动过程必须暂停用户线程(STW)
三种算法对比
| 特性 | 标记-清除 | 标记-复制 | 标记-整理 |
|---|---|---|---|
| 速度 | 中等 | 最快(存活少时) | 最慢 |
| 空间利用率 | 高 | 低(50%~90%) | 高 |
| 内存碎片 | 有 | 无 | 无 |
| 移动对象 | 否 | 是 | 是 |
| STW时间 | 短 | 短 | 长 |
| 适用场景 | 老年代(CMS) | 新生代 | 老年代(Serial Old, G1) |
分代收集理论
两个假说
- 弱分代假说:绝大多数对象朝生夕灭(存活时间短)
- 强分代假说:熬过越多次GC的对象,越不容易死亡
由此推导出分代策略:新生代用复制算法(存活率低),老年代用标记-清除/整理(存活率高)。
对象晋升规则
| 规则 | 说明 |
|---|---|
| 年龄阈值 | 对象每熬过一次Minor GC年龄+1,达到阈值(默认15)晋升老年代 |
| 动态年龄判定 | Survivor中相同年龄对象大小超过Survivor空间50%,年龄>=该值的直接晋升 |
| 大对象直接进入 | 超过 -XX:PretenureSizeThreshold 的对象直接分配到老年代 |
| 空间分配担保 | Minor GC后Survivor放不下,通过担保机制直接进入老年代 |
跨代引用与记忆集
问题:Minor GC 只收集新生代,但老年代中可能有对象引用新生代对象。如果扫描整个老年代,Minor GC就失去了意义。
解决方案:记忆集(Remembered Set)。
卡表(Card Table) 是记忆集的一种实现。将老年代划分为若干个Card(通常512字节),如果某个Card中的对象引用了新生代对象,则标记该Card为dirty。Minor GC时只需扫描dirty card,而非整个老年代。
// 卡表的写屏障伪代码
// 在引用赋值时触发
void oop_field_store(oop* field, oop new_value) {
*field = new_value;
// 写后屏障:标记card为dirty
card_table[((uintptr_t)field) >> 9] = DIRTY_CARD;
}安全点与安全区域
安全点(Safepoint)
GC需要暂停所有用户线程(STW),但不能在任意位置暂停 — 必须在安全点。安全点是指"程序执行时所有引用关系确定、GC可以安全进行"的位置。
常见的安全点位置:
- 方法调用
- 循环末尾(回边)
- 异常抛出
如何让线程到达安全点?
- 抢先式中断(几乎不使用):直接中断所有线程,不在安全点的恢复执行
- 主动式中断(主流):设置一个标志,线程在安全点轮询该标志,发现需要GC则主动中断
安全区域(Safe Region)
线程处于 Sleep/Blocked 状态时无法主动走到安全点。安全区域是指一段代码中引用关系不会发生变化的区域,线程进入安全区域时标记自己,GC可以不等待这些线程。
常见FAQ
Q: Full GC 和 Major GC 的区别?
A: Major GC 通常指老年代GC。Full GC 指整个堆(新生代+老年代+元空间)的GC。不同GC收集器的术语可能有差异,G1中使用Mixed GC而非Major GC。
Q: 为什么新生代用复制算法?
A: IBM研究表明,新生代中98%的对象熬不过第一次GC。存活率极低意味着需要复制的对象很少,复制算法效率最高。
Q: 对象一定在堆上分配吗?
A: 不一定。JIT的逃逸分析可能使对象在栈上分配(栈上分配)或拆解为标量(标量替换),这些优化可以减少GC压力。
总结
三种基础GC算法各有优劣:标记-清除简单但有碎片,标记-复制无碎片但浪费空间,标记-整理无碎片但速度慢。分代收集理论基于对象生命周期特征,为新生代和老年代分别选择最合适的算法,是现代JVM垃圾回收器设计的基础。理解这些算法,才能进一步理解G1、ZGC等先进收集器的设计思路。
贡献者
更新日志
9f6c2-feat: organize wiki content and refresh site setup于