JVM垃圾回收算法
JVM 中的垃圾回收算法是其自动内存管理的核心,随着 JVM 的发展,出现了多种算法,各有优缺点和适用场景。主要可以分为以下几类:
基础算法 (通常作为其他更复杂算法的基础组件)
- 标记-清除算法:
- 过程: 分为两个阶段。
- 标记: 从 GC Roots(如活动线程栈帧中的引用、静态变量、JNI 引用等)出发,遍历对象图,标记所有可达对象为“存活”。
- 清除: 遍历整个堆,回收所有未被标记为“存活”的对象所占用的内存空间。
- 优点: 实现相对简单。
- 缺点:
- 效率问题: 标记和清除两个过程的效率都不算高(需要遍历整个堆)。
- 空间问题: 回收后会产生大量不连续的内存碎片。这会导致以后需要分配较大对象时,无法找到足够的连续内存,从而触发另一次 GC。
- 过程: 分为两个阶段。
- 标记-复制算法:
- 过程: 将可用内存划分为容量相等的两块(如 From 和 To)。只使用其中一块(From)。当这块内存用尽时,就将还存活的对象复制到另一块内存(To)上,然后一次性清理掉已使用的内存块(From)的全部空间。之后交换 From 和 To 的角色。
- 优点:
- 实现简单,运行高效。
- 解决了内存碎片化问题(复制后存活对象在 To 空间是连续存放的)。
- 缺点:
- 空间浪费: 将可用内存缩小为原来的一半,代价高昂。
- 如果存活对象比例很高,复制的开销会很大。
- 应用: 主要用在新生代(Young Generation),因为新生代的对象“朝生夕死”,存活率很低,复制成本小。实际实现(如 Serial, ParNew, Parallel Scavenge)会将新生代划分为一个较大的 Eden 区和两个较小的 Survivor 区 (From, To),使用 Eden + 一个 Survivor 作为“From”,每次 Minor GC 时将 Eden 和 From Survivor 中存活的对象复制到 To Survivor,然后清空 Eden 和 From Survivor,最后交换 From 和 To 的角色。
- 标记-整理算法:
- 过程: 也分为两个阶段。
- 标记: 与“标记-清除”算法相同,标记所有可达对象。
- 整理: 让所有存活的对象都向内存空间的一端移动,然后直接清理掉边界以外的所有内存。
- 优点:
- 解决了内存碎片化问题。
- 不需要额外的复制空间。
- 缺点:
- 效率相对较低,尤其是移动存活对象并更新所有引用它们的指针需要暂停用户线程(Stop The World - STW),如果对象很多且存活率高,停顿时间会较长。
- 应用: 主要用于老年代(Old/Tenured Generation),因为老年代对象存活率高,不适合复制算法。Serial Old, Parallel Old 等收集器使用此算法。
- 过程: 也分为两个阶段。
分代收集理论
- 核心思想: 这是现代 JVM 垃圾回收器的基石。它基于一个观察事实:绝大多数对象都是“朝生夕死”的;熬过越多次 GC 的对象就越难消亡。
- 实现: 将堆划分为不同的代:
- 新生代: 存放新创建的对象。GC 发生非常频繁(Minor GC),采用复制算法(效率高,适合大量消亡的场景)。
- 老年代: 存放经历了多次 Minor GC 依然存活的对象(晋升)或大对象(可能直接分配在老年代)。GC 发生相对较少(Major GC / Full GC),采用标记-清除或标记-整理算法(适合存活率高、对象大的场景)。
- 永久代 (<= JDK7) / 元空间 (>= JDK8): 主要存放类元数据、常量池、静态变量、JIT 编译后的代码等。GC 行为与老年代类似,但触发条件不同(如类卸载、元空间不足)。元空间使用本地内存,上限更大。
- 跨代引用: 老年代对象引用新生代对象是很常见的。为了避免每次 Minor GC 都要扫描整个老年代来确认新生代对象是否可达,引入了记忆集(Remembered Set)和卡表(Card Table)技术来记录哪些老年代区域存在指向新生代的引用。
现代垃圾收集器 (实现了分代收集的具体算法)
这些收集器是上述基础算法在分代模型下的具体实现和优化,目标是减少停顿时间(STW)、提高吞吐量或降低延迟:
- Serial / Serial Old:
- 单线程收集器。进行 GC 时,必须暂停所有用户线程(STW)。
- Serial 用于新生代(复制算法),Serial Old 用于老年代(标记-整理算法)。
- 简单高效,没有线程交互开销。适用于客户端模式或资源受限的嵌入式系统。
- ParNew:
- Serial 收集器的多线程版本。用于新生代(复制算法)。
- 是 CMS 收集器在新生代的默认搭档(在 JDK 9 及以后被 G1 和后续收集器取代)。
- Parallel Scavenge / Parallel Old:
- 吞吐量优先收集器。
- Parallel Scavenge 用于新生代(复制算法),Parallel Old 用于老年代(标记-整理算法)。
- 关注点在于可控制的吞吐量(运行用户代码时间 / (运行用户代码时间 + GC 时间))。可以通过参数调节最大 GC 停顿时间或吞吐量目标。适合后台计算任务。
- CMS:
- 低延迟优先收集器。目标是减少老年代回收的停顿时间。
- 算法:基于标记-清除。
- 过程(复杂):
- 初始标记: (STW) 仅标记 GC Roots 能直接关联到的对象,速度很快。
- 并发标记: 遍历整个对象图,进行可达性分析。与用户线程并发运行。
- 重新标记: (STW) 修正并发标记期间因用户线程继续运行而导致标记产生变动的那部分对象的标记记录。停顿时间通常比初始标记稍长,但远短于并发标记。
- 并发清除: 清除未标记(死亡)的对象。与用户线程并发运行。
- 优点: 并发收集,停顿时间短。
- 缺点:
- 对 CPU 资源敏感(占用线程)。
- 无法处理“浮动垃圾”(并发清除阶段用户线程新产生的垃圾),可能导致“Concurrent Mode Failure”触发 Full GC (通常用 Serial Old)。
- 使用标记-清除,会产生内存碎片。可能导致 Full GC 提前触发(无法找到连续空间分配大对象)或 Full GC 时间变长(需要整理)。
- 相对复杂。
- 应用: 在 JDK 9 之前常用于对延迟敏感的应用。
- G1:
- 面向服务端应用,目标是在延迟可控的情况下获得尽可能高的吞吐量。JDK 9 及以后版本的默认垃圾收集器。
- 核心思想: 不再严格物理分代,而是将堆划分为多个大小相等的独立区域(Region)。Region 可以是 Eden, Survivor, Old, Humongous(存放大对象)。G1 跟踪各个 Region 的垃圾堆积“价值”(回收所需空间及回收所得空间),在后台维护一个优先列表。
- 算法: 整体基于标记-整理,局部(Region 之间)基于复制。
- 过程:
- 初始标记: (STW) 同 CMS。
- 并发标记: 同 CMS。
- 最终标记: (STW) 同 CMS 的重新标记,处理 SATB(Snapshot-At-The-Beginning)记录。
- 筛选回收: (STW - 可预测停顿模型) 根据用户设定的期望停顿时间,选择回收价值最高的一部分 Region 进行回收。将选中 Region 中存活的对象复制到空的 Region 中,然后清空整个选中的 Region。复制过程意味着整理,解决了碎片问题。
- 优点:
- 可预测的停顿时间模型: 用户可以设定期望的停顿时间目标(
-XX:MaxGCPauseMillis
),G1 会尽力达成。 - 空间整合: 整体是标记-整理算法,局部(Region 间)是复制算法,避免了 CMS 的内存碎片问题。
- 能处理非常大的堆(将大堆划分为小 Region)。
- 可预测的停顿时间模型: 用户可以设定期望的停顿时间目标(
- 缺点: 比 CMS 更复杂,内存占用(Remembered Set 等)和运行时负载(写屏障)更高。
- ZGC:
- JDK 11 中引入(生产可用),JDK 15 正式发布。目标是超低延迟(将 STW 停顿时间控制在 10ms 以内),且停顿时间不会随堆大小或存活对象集的增大而显著增加。
- 核心技术:
- 并发标记
- 并发预备重分配
- 并发重分配
- 并发引用处理
- 内存多重映射
- 染色指针: 这是 ZGC 的核心魔法!它利用指针的64位地址中未使用的高位(在 64 位系统上)来存储对象的标记信息(如 Marked0, Marked1, Remapped, Finalizable 等)。这允许 GC 线程在访问对象时,仅通过查看指针本身就能知道该对象的状态,极大加速了并发处理。染色指针还使得 ZGC 能够突破物理内存大小的限制(理论堆上限可达 16EB)。
- 优点:
- 极低的 STW 停顿时间(通常 < 1ms,最大目标 < 10ms)。
- 停顿时间与堆大小无关。
- 支持超大堆(TB 级别)。
- 缺点:
- 相对较新,仍在持续优化中。
- 在 JDK 15 之前,不支持类卸载(
-XX:+ClassUnloading
需要显式开启)。 - 对应用程序吞吐量可能有轻微影响(因为并发操作消耗 CPU)。
- Shenandoah:
- 由 Red Hat 开发,目标与 ZGC 类似(低延迟),从 OpenJDK 12 开始提供。采用与 ZGC 不同的技术路线(Brooks 指针、转发指针),但也实现了并发标记和并发对象移动(复制)。其核心思想是通过在每个对象前添加一个额外的“转发指针”(Brooks pointer)来支持并发移动对象。
总结对比表
收集器 | 分代 | 线程 | 算法(新生代) | 算法(老年代) | 目标 | 适用场景 | 关键特点/缺点 |
---|---|---|---|---|---|---|---|
Serial | Y | 单线程 | 复制 | - | 简单高效 | 客户端、嵌入式 | STW 停顿明显 |
Serial Old | Y | 单线程 | - | 标记-整理 | 配合 Serial | 客户端、老年代备用 | STW 停顿明显 |
ParNew | Y | 多线程 | 复制 | - | 低停顿 (新生代) | 配合 CMS (JDK8 及之前) | Serial 的多线程版 |
Parallel Scavenge | Y | 多线程 | 复制 | - | 高吞吐量 | 后台计算、批处理 | 可调节吞吐量/停顿目标 |
Parallel Old | Y | 多线程 | - | 标记-整理 | 高吞吐量 | 配合 Parallel Scavenge | Parallel Scavenge 的老年代版 |
CMS | Y | 并发 | - | 标记-清除 | 低延迟 | Web、B/S 服务 (JDK8 及之前) | 内存碎片、浮动垃圾、Concurrent Mode Failure |
G1 | Y | 并发 | 复制 (Region) | 标记-整理 (Region) | 延迟可控 + 高吞吐 | JDK9+ 默认, 大堆, 延迟敏感 | Region, 可预测停顿, 无碎片 (整体) |
ZGC | Y* | 并发 | 复制 (染色指针) | 复制 (染色指针) | 超低延迟 | 超大堆, 极苛刻低延迟要求 (JDK15+) | 染色指针, 停顿与堆无关 (<10ms), 大堆 (16EB) |
Shenandoah | Y* | 并发 | 复制 (Brooks指针) | 复制 (Brooks指针) | 超低延迟 | 超大堆, 极苛刻低延迟要求 (OpenJDK12+) | Brooks 指针, 并发移动, 低停顿 |
注:ZGC 和 Shenandoah 在逻辑上仍然分代(有年轻代和老年代的概念),但其物理内存布局(Region)和回收策略比传统的分代更灵活。它们都致力于几乎全并发的操作。
如何选择?
- 追求吞吐量: Parallel Scavenge + Parallel Old (或 G1 调整参数偏向吞吐)。
- 追求低延迟 (JDK8及之前): CMS (需注意碎片和浮动垃圾问题) 或 G1 (如果堆较大)。
- 追求低延迟 + 大堆 (JDK11+): G1 (默认,平衡性较好)。
- 追求极低延迟 (JDK15+ 且堆非常大 - TB级): ZGC 或 Shenandoah。
- 资源受限 (如客户端): Serial / Serial Old。
本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明来源 技术之路!
评论