JVM 的 GC(Garbage Collection)机制是 Java 程序性能的关键支柱。本文将从堆内存布局、回收原理、GC 算法、流程细节、并发收集器机制等维度,系统讲清楚 GC 的底层运作原理和优化思路。
一、JVM 堆内存结构
Java 堆是 GC 管理的主要区域,按生命周期划分为以下几个部分:
1.1 年轻代(Young Generation)
Eden 区:新对象最初分配在此
Survivor 区:分为 S0 / S1 两个区,用于对象在晋升前的多次存活
1.2 老年代(Old Generation)
存放从年轻代晋升上来的长寿对象
内存大,GC 频率低,但成本高
1.3 元空间(MetaSpace)
存放类元数据(Class 对象、方法表等)
从 JDK 8 起替代了 PermGen 永久代
二、对象如何判断是否可以被回收?
2.1 可达性分析(Reachability Analysis)
GC 会从一组 “GC Roots” 对象开始向下追踪引用链,凡是无法从 GC Roots 可达的对象,被认为是“垃圾”。
GC Roots 主要包括:
当前线程的局部变量(栈帧)
类的静态字段引用
JNI 本地方法引用
2.2 引用计数法(已废弃)
虽然概念简单(对象被引用次数为 0 即可回收),但无法解决循环引用问题,因此主流 JVM 都使用可达性分析。
三、GC 类型与时机
3.1 Minor GC(小回收)
回收年轻代
使用 复制算法
快速、频繁、STW
3.2 Major GC / Old GC / Mixed GC(大回收)
回收老年代
可能是并发或 STW,取决于收集器
3.3 Full GC(整堆回收)
回收整个堆(年轻代 + 老年代 + 元空间)
STW,性能开销大
触发原因:
老年代内存不足
调用
System.gc()
CMS GC 失败
四、常见 GC 算法
4.1 复制算法(Copying)
年轻代使用(Eden ➜ Survivor)
每次回收只处理 Eden + 一个 Survivor,存活对象复制到另一个 Survivor 或晋升老年代
简单高效,适合“朝生夕灭”的对象
4.2 标记-清除(Mark-Sweep)
老年代基础算法
缺点:产生碎片
4.3 标记-整理(Mark-Compact)
在清除后整理内存,消除碎片
用于老年代(如 Serial Old、G1 Old)
五、Stop-The-World (STW)
GC 期间 JVM 会暂停所有应用线程,这称为 Stop-The-World
即使是并发 GC(如 CMS、G1)也不能完全避免 STW,尤其在:
初始标记(Initial Mark)
重新标记(Remark)
Full GC
六、GC 的完整流程细节(以 G1 为例)
6.1 触发 GC(如 Eden 区满)
Minor GC 开始
STW,复制 Eden 区存活对象 ➜ Survivor 区
Survivor 满或年龄够 ➜ 晋升老年代
释放 Eden 区对象
6.2 并发标记流程(预防 Full GC)
当老年代占用达到一定阈值时,G1 启动 Concurrent Marking 过程:
阶段 | 描述 | 是否 STW |
---|---|---|
Initial Mark | 标记 GC Roots 引用对象 | ✅ 是 |
Concurrent Mark | 遍历对象图,标记可达对象 | ❌ 否 |
Remark | 捕获并发阶段变更的引用 | ✅ 是 |
Cleanup | 清理不可达的 Region | ❌ 否 |
七、三色标记法(Tri-color Marking)
为支持并发标记,GC 使用三色标记算法确保安全性:
白色:未被访问(假定为垃圾)
灰色:被访问但未扫描其引用
黑色:自身与其引用都已处理
写屏障(SATB、增量更新)
用于记录并发阶段引用变更,防止“误删活对象”
八、对象晋升与回收细节
对象在 Survivor 区每存活一次,年龄 +1
达到
MaxTenuringThreshold
或 Survivor 区满时晋升老年代G1 Mixed GC 会“部分回收”老年代,非 Full GC
九、避免 Full GC 的实践建议
设置合适的堆大小:避免频繁 Minor 或 Full GC
使用
-XX:+PrintGCDetails
观察 GC 频率和时间针对 G1,关注老年代使用率和 Mixed GC 是否充分触发
防止大对象直接进入老年代(如
-XX:+UseLargePages
、避免一次性创建大数组)
🔚 总结
JVM GC 机制既有实时性需求,也有吞吐压力。掌握对象生命周期、GC 类型和回收策略,是 Java 性能调优的核心技能。随着 G1、ZGC、Shenandoah 等收集器的引入,并发、可预测、低延迟的垃圾回收将成为主流。
🚫 使用 G1 GC 时的反面案例与问题分析
例 1:大量短命小对象 + 高分配速率,结果频繁 Minor GC
背景:
某服务使用 G1 GC,业务为高并发 API 网关
每秒创建数万个小对象(如 JSON 解析、临时集合等)
错误表现:
Eden 区很快满,导致 频繁 Minor GC
老年代增长不快,但 GC 次数持续拉高
响应时延抖动明显
原因分析:
G1 在默认配置下 Eden 占比较小,无法适应高速对象分配,导致频繁 GC(即便每次 GC 都很快)
优化建议:
-XX:G1NewSizePercent=30 # 提高年轻代初始比例
-XX:G1MaxNewSizePercent=60 # 增大年轻代最大值
-XX:MaxGCPauseMillis=100 # 适当放宽暂停时间目标
例 2:大对象直接分配到老年代,导致提前 Full GC
背景:
某报表系统一次性构造数十 MB 的
List<Map<String, Object>>
JVM 使用 G1,老年代频繁增长
错误表现:
年轻代未满,但大对象直接进老年代
老年代迅速填满,触发 Full GC
G1 试图执行 Mixed GC,但回收效果不佳
原因分析:
G1 对大于 region size 的对象(默认 1~32MB),会直接分配到老年代
如果这些对象生命周期短,会造成老年代“脏积压”
优化建议:
-XX:G1HeapRegionSize=8m # 适当增大 Region Size
-XX:G1ReservePercent=20 # 增加保留内存,防止老年代爆掉
-XX:+UseStringDeduplication # 优化大量重复字符串的内存使用
例 3:设置了极端的 MaxGCPauseMillis
,导致 GC 频繁打断业务
背景:
运维强行要求 GC 停顿 < 20ms,于是配置:
-XX:MaxGCPauseMillis=20
错误表现:
G1 尝试做“小步快跑”,每次只回收很少区域
GC 调度频繁,导致 GC 开销过大
实际吞吐率反而降低,甚至 Full GC 也变得更频繁
原因分析:
G1 的暂停预测模型被“过度限制”
GC 频繁被触发,但每次清理量不够,长远看反而回收不及时
优化建议:
放宽
MaxGCPauseMillis
至 100ms 以内,兼顾吞吐和暂停观察日志中的 “pause target met” 状态,动态调整
例 4:Mixed GC 没有完成,导致 Full GC 重复触发
背景:
应用负载波动大,Mixed GC 经常被中断
老年代利用率飙升,最终触发多次 Full GC
错误表现:
G1 执行并发标记后启动 Mixed GC
但在处理 Eden 区时就被打断,老年代未清理
多轮循环 GC 后,内存碎片堆积 ➜ Full GC
原因分析:
Mixed GC 本质上是 “老年代的增量回收”
如果每轮 GC 清理量太少,无法及时清理老年代,会累积到需要 Full GC
优化建议:
-XX:G1HeapWastePercent=5 # 允许适度“浪费”,让更多 Region 被选中
-XX:G1MixedGCLiveThresholdPercent=85 # 调整可回收区域的判定阈值
-XX:G1OldCSetRegionThresholdPercent=20 # 每轮最大处理老年代比例