JVM 垃圾回收算法与收集器全版本演进解析
在 Java 开发中,我们很少手动释放内存,这得益于 JVM 强大的垃圾回收(GC)机制。然而,随着堆内存从 MB 级别增长到 GB 甚至 TB 级别,如何处理“Stop The World (STW)”停顿成了性能优化的核心。
今天,我们就从底层的回收算法讲起,一步步看懂 CMS、G1 以及 ZGC 这些顶级收集器是如何运作的,以及它们在不同 JDK 版本中的进化。
1. 这篇文章要解决什么问题?
早期的 C/C++ 开发中,程序员需要手动 free/delete 内存,这极其容易导致内存泄漏或“悬挂指针”。
Java 引入 GC 是为了:
- 自动化内存管理:减轻开发负担,避免低级内存错误。
- 解决内存碎片:随着程序运行,碎片会导致无法分配连续大对象的尴尬局面。
- 性能博弈:在“最大吞吐量”与“最小停顿时间”之间寻找最佳平衡点。
2. 核心原理:如何精准回收?
对象存活判定:根可达性分析
JVM 并不使用计数法(无法解决循环引用),而是使用 根可达性分析 (Reachability Analysis)。
通过一组称为 GC Roots 的根对象(如栈帧中的本地变量表、静态变量、JNI 指针等)出发,像找树根一样寻找所有关联的对象。凡是找不到的对象,统统视为垃圾。
三大基础算法:
graph TD
A["GC 基础算法"] --> B["标记-复制 (Copying)"]
A --> C["标记-清除 (Mark-Sweep)"]
A --> D["标记-整理 (Mark-Compact)"]
B --> B1["优点: 没碎片, 速度快"]
B --> B2["缺点: 浪费一半内存"]
C --> C1["优点: 利用率高"]
C --> C2["缺点: 产生大量内存碎片"]
D --> D1["优点: 无碎片, 无浪费"]
D --> D2["缺点: 移动对象代价高(耗时)"]
分代收集理论
JVM 把内存分为:
- 新生代 (Young Generation):对象寿命短,用“标记-复制”算法。
- 老年代 (Old Generation):对象存活久,用“标记-整理/清除”。
3. 流程/机制描述:收集器的演进
CMS 收集器 (JDK 5 引入):并发的起点
CMS 是第一款追求低停顿的收集器,它尝试让 GC 线程与用户线程“并发”执行。
- 核心逻辑:初始标记 (STW) -> 并发标记 -> 重新标记 (STW) -> 并发清除。
- 痛点:无法清理“浮动垃圾”,且会产生内存碎片,最终可能崩坏成 Full GC。
G1 收集器 (JDK 7 支持,JDK 9 默认):Region 化革命
G1 彻底打破了物理隔阂。它把全堆分成上千个大小相等的 Region。
grid-layout
["Eden(E)", "Old(O)", "Survivor(S)", "Eden(E)"]
["Old(O)", "Humongous(H)", "Eden(E)", "Old(O)"]
["Survivor(S)", "Old(O)", "Eden(E)", "Humongous(H)"]
[!NOTE] G1 内部不再有物理隔离的新生代/老年代,每个 Region 都可以动态扮演 E/S/O/H 角色。
ZGC 收集器 (JDK 11 引入,JDK 15 生产可用):亚毫秒时代
ZGC 是为了应对 TB 级大内存而生的。
- 黑科技:染色指针 (Colored Pointers)。它直接把对象指针的 64 位中的几位拿来标记对象状态(比如是否被移动)。
- 流程:所有最繁重的任务(如对象搬迁)都是在后台并发完成的。
- 结果:无论你是 4G 还是 16TB 内存,停顿时间都控制在 1ms 以内。
4. 关键代码/示例:参数配置实战
针对不同规模的系统,推荐的 GC 参数设置:
# JDK 8 推荐:使用 G1 收集器,并限制最大停顿时间
java -Xms4g -Xmx4g \
-XX:+UseG1GC \
-XX:MaxGCPauseMillis=200 \
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps \
-jar app.jar
# JDK 11+ 推荐:开启 ZGC (如果内存 > 8G)
java -Xms32g -Xmx32g \
-XX:+UnlockExperimentalVMOptions -XX:+UseZGC \
-Xlog:gc* \
-jar heavy-app.jar
如何观察 GC 日志?
关注关键词:Young GC, Full GC, STW, Pause。如果出现频繁的 Full GC,说明内存配置不足或存在内存泄漏。
5. 常见误区
误区 1:Minor GC 一定比 Full GC 快
纠正:大部分情况下是。但如果在 Minor GC 发生时,大量对象要晋升老年代,且老年代空间不足,会伴随频繁的担保失败尝试,此时的速度不一定会快,甚至会直接产生 Full GC。
误区 2:G1 能完全避免 STW
纠正:不能。G1 虽然大部分时间是并发的,但在初始标记和数据迁移阶段依然会有短暂的 STW。只不过它控制得非常精准。
6. 实际工作中怎么用?
- 根据 JDK 版本选型:
- JDK 8 以下:默认通常是 Parallel 或 CMS。
- JDK 9 - JDK 16:强烈建议默认使用 G1。
- JDK 17+:如果内存够大且对响应时间极度敏感,尝试 ZGC。
- 避免大对象直接入老年代:
- 比如 2MB 的数组,如果超过了
-XX:PretenureSizeThreshold,它会跳过新生代直接进老年代,这会加速老年代满的速度,诱发 Full GC。
- 比如 2MB 的数组,如果超过了
- 监控重于调优:
- 在生产环境开启
-XX:HeapDumpOnOutOfMemoryError,这是发生故障时的救命稻草。
- 在生产环境开启
总结
垃圾回收不是简单的“扫大街”,它是一场空间与时间的巅峰博弈。理解了从分代收集到 Region 切分,再到染色指针的进化,你才能在面临系统性能瓶颈时,精准定位是算法选型错了,还是内存分配不合理。