JVM 垃圾回收算法:底层实现与设计哲学

60 阅读7分钟

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):

  1. 触发条件:Eden 区满;
  2. 标记存活:从 GC Roots 出发,扫描 Eden + From Survivor,标记所有存活对象;
  3. 复制到 To:把存活对象逐个复制到 To Survivor;
    • 如果对象太大(超过 To 剩余空间),或 Survivor 空间不足,直接晋升到老年代
  4. 清空 Eden + From:指针归零,成本几乎为 0;
  5. 角色交换: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。