全面理解 JVM 垃圾回收(GC)机制:原理、流程与实践

发布于:2025-06-18 ⋅ 阅读:(18) ⋅ 点赞:(0)

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 区满)

  1. Minor GC 开始

  2. STW,复制 Eden 区存活对象 ➜ Survivor 区

  3. Survivor 满或年龄够 ➜ 晋升老年代

  4. 释放 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  # 每轮最大处理老年代比例

深入理解 JVM 的垃圾收集器:CMS、G1、ZGC | 二哥的Java进阶之路


网站公告

今日签到

点亮在社区的每一天
去签到