JVM 垃圾回收算法:底层实现与设计哲学
一句话总结:
JVM 的 GC 算法不是理论玩具,而是为解决“对象生命周期差异 + 内存碎片 + 分配效率 + 停顿时间”等现实问题而设计的工程方案。复制、标记-整理、标记-清除,每一种都对应明确场景,背后是内存模型、对象行为、性能权衡的真实取舍。
一、JVM 的堆内存是分代的,因为对象的“生死规律”不同
HotSpot JVM(JDK 8)把堆分为:
堆(Heap)
├── 新生代(Young Generation)
│ ├── Eden(80%)
│ ├── Survivor From(10%)
│ └── Survivor To(10%)
│
└── 老年代(Old Generation)
为什么分代?
因为对象的“生死”有统计规律:
- 90%+ 的对象,创建后很快死亡(比如循环中的临时变量、方法内局部对象);
- 不到 10% 的对象,能活过几次 GC,进入老年代;
- 极少数对象,长期存活,伴随应用生命周期。
所以,不能用同一种算法处理所有对象 —— 这是 GC 算法分化的根本原因。
二、新生代:复制算法 —— 专为“朝生夕死”设计
核心目标:
- 快:GC 频繁,必须低开销;
- 无碎片:保证后续对象分配高效;
- 简单:不搞复杂引用更新。
实现机制(Minor GC):
- 触发条件:Eden 区满;
- 标记存活:从 GC Roots 出发,扫描 Eden + From Survivor,标记所有存活对象;
- 复制到 To:把存活对象逐个复制到 To Survivor;
- 如果对象太大(超过 To 剩余空间),或 Survivor 空间不足,直接晋升到老年代;
- 清空 Eden + From:指针归零,成本几乎为 0;
- 角色交换:From ↔ To,为下一次 GC 准备。
为什么高效?
- 只处理“少数存活对象”,数据搬运量小;
- 清理是“整区清空”,不是逐个删除;
- To Survivor 中对象紧凑排列,分配时直接“指针碰撞”(Bump-the-pointer)。
为什么用两个 Survivor?
- 避免“自我复制”:如果只有一个 Survivor,复制时可能覆盖自己;
- 实现“乒乓机制”:每次 GC 只用一个 To,另一个当 From,轮流使用;
- 控制晋升节奏:对象在 Survivor 间“熬”几次 GC,年龄增长,最终进入老年代。
复制算法在新生代的成功,不是因为它“高级”,而是因为它完美匹配了“高死亡率 + 低存活量”的场景。
三、老年代:标记-整理 —— 专为“长期存活 + 高密度”设计
核心目标:
- 无碎片:老年代对象多,碎片会导致分配失败;
- 稳定:不能频繁 Full GC,必须一次整理到位;
- 可控:虽然 STW,但必须保证后续长时间稳定运行。
为什么不能用复制?
- 存活对象比例高(70%~90%),复制需要等量空闲空间;
- 比如老年代 4GB,存活 3.5GB,就需要额外 3.5GB 空间来复制 —— 内存浪费不可接受。
实现机制(以 Serial Old / Parallel Old 为例):
阶段 1:标记(Mark)
- 从 GC Roots(栈帧、静态变量、JNI 引用等)出发,遍历对象图;
- 标记所有可达对象(在对象头或位图中标记);
- 此时你知道哪些活、哪些死,但对象还在原地。
阶段 2:计算新位置(Compute New Locations)
- 从内存起始地址(0x0000...)开始,模拟“滑动窗口”;
- 遍历整个老年代,遇到存活对象,就计算它在紧凑排列后的新地址;
- 把新地址写入对象头中的“转发指针”(Forwarding Pointer)字段。
🎯 关键:不是移动对象,而是先“预约地址”。
这一步确保后续移动时,不会覆盖还没处理的对象。
阶段 3:移动对象(Compact / Slide)
- 按照“转发指针”记录的地址,逐个把存活对象复制到新位置;
- 复制完成后,原位置的数据“逻辑废弃”(不擦除,但不再使用);
- 对象头保留转发指针,用于后续引用更新。
阶段 4:更新引用(Update References)
- 遍历所有存活对象,检查它们的字段(引用类型);
- 如果字段指向的对象被移动了(有转发指针),就更新为新地址;
- 这一步确保整个对象图引用关系正确。
阶段 5:清理边界(Reset Top Pointer)
- 找到最后一个存活对象的末尾地址;
- 把堆的“已使用边界指针”(top)设置到该位置;
- 边界之后的所有内存,直接视为“空闲” —— 不清零、不扫描、不处理。
✅ “清除”不是擦数据,而是移动指针,放弃那部分内存区域。
为什么必须整理?
-
内存连续性 = 分配效率:
后续new Object()时,JVM 只需检查top + size <= end,满足就直接分配,指针前移 —— O(1) 时间,无锁,极快。 -
碎片 = 分配失败 = 提前 Full GC = OOM 风险:
即使总空闲内存足够,碎片化会导致大对象无法分配,触发昂贵的 Full GC,甚至直接 OOM。
✅ 标记-整理的代价是 STW 时间长,但换来的是长期稳定 + 高效分配 —— 对老年代来说,值得。
四、标记-清除:CMS 的妥协 —— 为了“低延迟”,暂时忍“碎片”
为什么 CMS 不用标记-整理?
- CMS(Concurrent Mark Sweep)的核心目标是:减少 STW 时间,提升响应速度;
- 标记-整理必须 STW(移动对象 + 更新引用不能并发),违背 CMS 设计初衷。
CMS 如何实现“标记-清除”?
阶段 1:初始标记(Initial Mark)【STW】
- 标记 GC Roots 直接引用的对象;
- 速度快,STW 时间极短。
阶段 2:并发标记(Concurrent Mark)
- 从初始标记对象出发,并发遍历整个对象图;
- 应用线程可同时运行(会有浮动垃圾)。
阶段 3:重新标记(Remark)【STW】
- 修正并发标记期间因对象引用变化导致的漏标;
- 比初始标记稍长,但可控。
阶段 4:并发清除(Concurrent Sweep)
- 不移动对象,直接遍历堆,把未标记对象的内存块加入空闲链表;
- 应用线程可同时运行。
✅ 清除阶段不 STW,是 CMS “低延迟”的关键。
代价:内存碎片
- 清除后,内存中留下大量“洞”;
- 大对象分配时,即使总空闲足够,也可能找不到连续空间;
- 导致:
- 晋升失败(Promotion Failed):新生代对象无法进入老年代;
- 并发模式失败(Concurrent Mode Failure):CMS 来不及清理,老年代满;
- 触发 Full GC:退化成 Serial Old(标记-整理),STW 时间长,业务卡顿。
✅ CMS 不是“不用整理”,而是“延迟整理”,把整理的成本推迟到“不得不做”的时候。
五、没有“最好”,只有“最合适”
1. 算法选择 = 场景匹配
| 场景 | 特点 | 适合算法 | 原因 |
|---|---|---|---|
| 新生代 | 高死亡率、低存活量 | 复制 | 只搬活的,成本低,天然无碎片 |
| 老年代(稳定型) | 高存活率、需长期稳定 | 标记-整理 | 无碎片,分配快,适合长期运行 |
| 老年代(低延迟型) | 不能长时间 STW | 标记-清除(CMS) | 可并发,但需容忍碎片,有兜底 |
2. 工程权衡无处不在
- 吞吐量 vs 延迟:Parallel GC 吞吐量高,但 STW 长;CMS 延迟低,但吞吐量低、有碎片;
- 内存利用率 vs 效率:复制算法浪费空间,但效率高;标记-整理不浪费,但移动成本高;
- 简单 vs 复杂:Serial 简单稳定,适合客户端;G1/ZGC 复杂,但适合大堆、低延迟。
3. 未来方向:打破“整理必须 STW”的限制
- G1:分 Region,局部复制,部分并发;
- ZGC / Shenandoah:并发移动对象,几乎无停顿;
- 核心思路:把“移动对象”和“更新引用”拆开,并发化、增量式处理。
✅ JVM GC 的演进,就是不断在“停顿时间、吞吐量、内存效率、实现复杂度”之间找新平衡。
六、常见问题
Q1:对象移动时,其他对象还在引用它,怎么办?
→ 用“转发指针”(Forwarding Pointer):
- 移动前,在对象头记录新地址;
- 更新引用阶段,遍历所有对象,发现引用了“旧地址”,就根据转发指针改成“新地址”。
Q2:为什么不直接在原地“压缩”,非要复制?
→ 因为对象大小不一,原地压缩需要“逐个挪动”,可能覆盖未处理对象,逻辑极其复杂,且并发困难。
→ “先算位置,再整体复制”是最简单、最安全、最易并发化的方案。
Q3:Full GC 一定会发生吗?
→ 不一定。如果老年代空间充足、碎片可控、晋升顺畅,可以长期不触发 Full GC。
→ CMS 的目标就是尽量避免 Full GC,但“兜底机制”必须存在。
Q4:为什么 G1 不用标记-整理?
→ G1 用的是“局部复制”:
- 把堆分成 Region;
- 每次 GC 只选部分 Region(“回收价值高”的);
- 在这些 Region 内部用复制算法;
- 避免全堆整理,减少 STW。