JVM 垃圾回收:对象如何被判定和回收
目录
- [1. GC Roots:对象生死从哪里开始判断](#1. GC Roots:对象生死从哪里开始判断)
- [2. 分代模型:为什么要区分新老对象](#2. 分代模型:为什么要区分新老对象)
- [3. 三种基础回收算法](#3. 三种基础回收算法)
- [4. 收集器的演进](#4. 收集器的演进)
- [5. ZGC 与 Shenandoah](#5. ZGC 与 Shenandoah)
- [6. GC 日志怎么看](#6. GC 日志怎么看)
- [7. 调优思路](#7. 调优思路)
- [8. 小结](#8. 小结)
本文主要讨论 HotSpot JVM。分代布局和对象晋升部分以 Serial、Parallel 等传统分代收集器为基础,G1、ZGC 和 Shenandoah 的具体实现会单独说明。
你写下一行 new Object(),一个新对象通常会被分配到堆内存中。当它不再能从任何 GC Roots 访问到时,就成了可以被回收的对象。JVM 通常不会在对象刚刚失去可达性时立刻清理它,而是在出现分配压力、堆占用达到阈值或满足收集器触发条件时统一回收。
这个过程平时很难直接感知,却会影响应用的响应时间和吞吐量。在 GC 的 Stop-The-World 阶段,应用线程会被暂停,尚未处理的用户请求只能等待。搞清楚垃圾回收的原理,才能在出问题的时候知道往哪看。
1. GC Roots:对象生死从哪里开始判断
JVM 主要通过可达性分析判断对象是否还存活:从一组称为 GC Roots 的起点出发,沿着引用关系向下搜索,能够到达的对象仍然存活,无法到达的对象则具备了被回收的条件。
GC Roots
│
├── 当前线程栈帧中的引用,如局部变量和方法参数
├── 已加载类的静态字段所引用的对象
├── 运行时常量池等 JVM 内部结构引用的对象
├── JNI Handle 引用的对象
└── 活跃线程、监视器锁等 JVM 内部持有的对象
和引用计数法相比,可达性分析能处理循环引用。即使两个对象互相引用,只要从 GC Roots 出发无法到达它们,可达性分析仍然会把它们判定为可回收对象;简单的引用计数法却可能因为计数不为零而无法识别这种循环。
你可以把 GC Roots 想象成一束探照灯,从这些起点向外照,光能照到的对象仍然存活,照不到的对象则可以被回收。
2. 分代模型:为什么要区分新老对象
Serial、Parallel、G1 等分代收集器会把堆逻辑上划分为年轻代 (Young Generation)和老年代(Old Generation),用不同策略处理不同生命周期的对象。在 Serial、Parallel 等传统分代收集器中,年轻代通常由一个 Eden 区和两个 Survivor 区组成;G1 也保留了这些逻辑角色,但底层由多个不连续的 Region 构成。
Serial/Parallel 等传统分代收集器的简化布局:

为什么要这么分?因为大部分对象都是"朝生夕死"的。方法里创建的许多临时对象只服务于当前计算,只要没有被返回、保存到字段或传递到外部,方法执行结束后通常就不再可达。大量实际应用都表现出类似特征:多数对象存活时间很短,只有少量对象会长期留在堆中,这就是弱分代假说。
基于这个假说,分代收集器会针对年轻代和老年代采用不同的回收策略。年轻代通常回收得更频繁,而且存活对象较少,因此适合通过复制或转移存活对象来快速腾出空间。老年代中的存活对象更多,传统收集器通常采用标记-整理或标记-清除,G1 等现代收集器则有自己的 Region 回收机制。分代的好处是让收集器能针对不同区域选择最合适的算法,而不是一刀切。
在 Serial、Parallel 等传统分代收集器中,对象的典型晋升过程如下:

大对象的分配方式取决于具体收集器。部分传统收集器支持通过 -XX:PretenureSizeThreshold 让超过阈值的对象直接进入老年代;G1 则会把超过半个 Region 大小的对象视为 Humongous Object,使用连续的 Humongous Region 保存。如果 Survivor 区无法容纳本次 GC 中的存活对象,部分对象也可能提前晋升到老年代。
3. 三种基础回收算法
3.1 标记-清除
先从 GC Roots 出发标记所有存活对象,再清除没有被标记的对象。

它的问题是清除后会留下不连续的空闲区域,也就是内存碎片。即使空闲空间总量足够,后续分配大对象时也可能找不到足够大的连续区域,从而提前触发下一次 GC。
标记-复制
经典的半区复制算法会把可用内存分成两块,每次只使用其中一块。GC 时把存活对象复制到另一块,然后把原来的整块清空。
传统分代收集器中的 Eden 和两个 Survivor 区采用了类似思路,但并不是两个等大的半区。对象主要分配在 Eden,两个 Survivor 区中一个作为 From 区保存上次 GC 的存活对象,另一个 To 区保持空闲。Young GC 时,Eden 和 From Survivor 中仍然存活的对象会被复制到 To Survivor;达到晋升条件的对象则进入老年代。复制完成后,Eden 和原来的 From 区可以整体清空。下一次 GC 时,两个 Survivor 区交换 From 和 To 的角色。
这种方式能快速获得连续的空闲空间,代价是需要预留一块区域作为复制目标,用额外空间换取回收效率。而且如果存活对象很多,复制的开销会很大。因此,复制算法更适合存活率较低的年轻代;如果直接用于存活对象很多的区域,复制成本会明显上升。
标记-整理
先标记存活对象,再把它们向内存的一端移动,最后回收边界之外的空间。

整理后可以得到连续的空闲空间,也不需要长期保留等大的复制区域,但移动对象和更新引用会带来额外开销。在 Serial Old、Parallel Old 等传统收集器中,标记-整理常用于存活率较高的老年代。
4. 收集器的演进
这三种算法是理论基础,实际的收集器是围绕它们做的工程实现。从早期 JDK 到今天,HotSpot 先后提供了多种收集器,它们分别在吞吐量、停顿时间和内存开销之间做出不同取舍。
Serial / Serial Old
Serial 是 HotSpot 中较早出现的收集器,垃圾回收工作主要由单个 GC 线程完成。GC 时必须暂停所有业务线程(STW),然后用一个线程去回收。
年轻代使用复制算法,老年代的 Serial Old 使用标记-整理算法。它实现简单、额外开销较小,但随着堆和存活对象规模增加,单线程回收的停顿通常会越来越长。Serial 更适合小堆、CPU 核心较少或资源受限的应用,在大型服务端应用中通常不是首选。
Parallel / Parallel Old
在 JDK 8 的 Server-Class Machine 配置中,Parallel GC 通常是默认收集器;从 JDK 9 开始,服务器配置的默认收集器改为 G1。它与 Serial 的主要区别是使用多个 GC 线程并行完成回收工作。多个 GC 线程同时工作,回收速度更快,但 STW 还是要暂停业务线程。
Parallel GC 主要追求吞吐量,也就是尽量降低程序总运行时间中 GC 所占的比例。适合后台任务、批处理这种对响应时间不敏感但对吞吐量要求高的场景。
CMS(Concurrent Mark Sweep)
CMS 是 HotSpot 中具有代表性的低停顿并发收集器。如果省略并发预清理和重置等细节,CMS 的核心回收流程可以简化为四个阶段,其中初始标记和重新标记需要 STW:
初始标记(STW) → 处理 GC Roots 能直接到达的对象,范围较小
并发标记 → 从 GC Roots 出发遍历整个引用链,和业务线程一起跑
重新标记(STW) → 处理并发标记期间发生的引用变化,修正标记结果
并发清除 → 清除没有被标记的对象,和业务线程一起跑
CMS 缩短了部分 GC 停顿,但也带来了几个新的问题:
1. CPU 敏感。 并发阶段要和业务线程抢 CPU。当可用 CPU 资源有限或业务线程本身已经接近满负载时,并发 GC 线程会进一步挤占业务执行时间。
2. 浮动垃圾。 在并发标记和清除期间,业务线程仍在运行,一些已经完成标记的对象可能随后变得不可达,只能留到下一次 GC 再处理,这部分对象被称为浮动垃圾。如果 CMS 来不及完成并发回收,老年代就已经没有足够空间继续分配,可能发生 Concurrent Mode Failure,JVM 会退化为一次耗时更长的 STW Full GC。
3. 内存碎片。 CMS 用的是标记-清除算法,清除之后内存不连续。碎片多了,大对象找不到连续空间,也会触发 Full GC。
CMS 在 JDK 9 被标记为废弃,JDK 14 正式移除。对于原本使用 CMS 的低停顿场景,G1 成了主要迁移方向。
G1(Garbage First)
从 JDK 9 开始,G1 成为 Server-Class Machine 配置下的默认收集器,并在后续 JDK 中持续演进。G1 把堆划分为许多大小相等的 Region。每个 Region 在不同阶段可以承担 Eden、Survivor、Old 或 Humongous 等角色,这些 Region 在物理地址上不要求连续。

Garbage First 表示 G1 会优先考虑回收收益较高的 Region,也就是能够在预计停顿时间内释放较多空间的区域。G1 会根据 Region 中的存活对象数量、预计复制成本和跨 Region 引用等信息评估回收收益,再选择本次 GC 的 Collection Set。它的目标是在给定的停顿时间预算内,尽可能回收更多空间。
G1 同时包含 STW 的对象转移和并发标记。一个完整周期可以简化为:

和 CMS 相比,G1 主要有以下变化:
| 维度 | CMS | G1 |
|---|---|---|
| 算法 | 老年代以标记-清除为主,容易产生碎片 | 通过 Region 转移存活对象,在回收过程中完成局部整理,但仍可能出现 Humongous Region 碎片 |
| 停顿预测 | 不支持 | 支持停顿预测,-XX:MaxGCPauseMillis 设置的是软目标,不保证每次停顿都不超过该值 |
| 内存布局 | 年轻代和老年代通常是连续的逻辑区域 | Region 化,灵活分配 |
| 大对象处理 | 通常需要在老年代中寻找连续空间 | 大于等于单个 Region 一半的对象会被视为 Humongous Object,使用 Humongous Region 保存 |
G1 适合希望兼顾吞吐量和可预测停顿的服务端应用,尤其适用于中大型堆,但是否优于 Parallel、ZGC 等收集器仍需要结合实际负载测试。
5. ZGC 与 Shenandoah
G1 已经能把老年代回收拆分到多次停顿中,但随着存活对象、跨 Region 引用和分配速率增加,维持较短的停顿目标仍会变得困难。对于延迟敏感的业务(比如交易系统、实时推荐),几十毫秒的停顿可能不可接受。
ZGC 在 JDK 11 中以实验特性引入,Shenandoah 在 JDK 12 中进入 OpenJDK。它们都以低停顿为主要目标,并尽量降低停顿时间与堆容量之间的关联。
两者都把标记、对象转移和引用修正中的大量工作放到并发阶段,STW 阶段主要完成全局状态切换和少量必须暂停应用线程的协调工作。ZGC 通过染色指针记录对象引用状态,并使用访问屏障完成引用检查和重映射;分代 ZGC 还引入了写屏障。Shenandoah 则通过转发信息和访问屏障,使对象转移能够与业务线程并发执行。
ZGC 和 Shenandoah 都在 JDK 15 转为正式特性。此后,ZGC 在 JDK 21 引入分代模式,并从 JDK 23 起默认使用分代实现;分代 Shenandoah 也在 JDK 25 成为正式特性。对于大堆和严格延迟目标,ZGC 与 Shenandoah 已经是可以实际评估的方案,但它们会使用更多并发 CPU 和内存资源,是否合适仍要通过真实负载验证。
6. GC 日志怎么看
出了问题先看日志。开启 GC 日志只需在启动参数里加一行:
bash
# JDK 8
-XX:+PrintGCDetails -XX:+PrintGCDateStamps -Xloggc:/path/to/gc.log
# JDK 9+
-Xlog:gc*:file=/path/to/gc.log:time,uptime,level,tags
不同 JDK 和收集器的日志格式并不相同,下面两个示例来自 JDK 8 的 Parallel GC。
JDK 8 使用 Parallel GC 时,一次 Young GC 的日志大致如下:
[GC (Allocation Failure) [PSYoungGen: 65536K->1024K(76288K)] 65536K->1025K(251392K), 0.0012345 secs]
拆开来看:
| 字段 | 含义 |
|---|---|
Allocation Failure |
触发原因:年轻代无法满足新的对象分配请求,通常是 Eden 空间不足 |
PSYoungGen: 65536K->1024K(76288K) |
年轻代从约 64 MiB 降到约 1 MiB,当前容量约 74.5 MiB |
65536K->1025K(251392K) |
整个堆从约 64 MiB 降到约 1 MiB,当前容量约 245.5 MiB |
0.0012345 secs |
这次 GC 耗时约 1.2ms |
同样在 JDK 8 Parallel GC 下,一次由 Metaspace 阈值触发的 Full GC 日志如下:
[Full GC (Metadata GC Threshold) [PSYoungGen: 1024K->0K(76288K)] [ParOldGen: 128000K->127500K(175104K)] 129024K->127500K(251392K), [Metaspace: 98765K->98765K(1056768K)], 0.0567890 secs]
Metadata GC Threshold 表示类元数据占用触发了回收检查,并不等同于 Java 堆已经耗尽。JVM 会尝试卸载不再使用的类,并根据回收结果调整 Metaspace 的触发阈值。
分析 GC 日志时重点关注四项信息:触发原因 、回收前后的内存变化 、单次停顿时间 和发生频率。单次回收效果正常,不代表频繁 GC 对业务没有影响。
7. 调优思路
GC 调优的过程其实是回答以下三个问题:
问题一:停顿时间能不能接受?
如果接口要求 P99 小于 200ms,GC 停顿只能占用其中一部分延迟预算,还需要为业务执行、网络和下游调用预留时间。使用 G1 时,可以根据延迟预算设置 -XX:MaxGCPauseMillis,但它只是收集器努力满足的软目标,不是停顿上限。要求更低停顿时,可以评估 ZGC 或 Shenandoah。
如果应用更关注总体吞吐量,例如离线计算或批处理任务,可以优先测试 Parallel GC。它主要在 STW 阶段并行回收,通常能减少并发 GC 对业务线程的持续干扰。
问题二:Full GC 是不是太频繁?
Full GC 通常会处理更大的堆范围,停顿时间往往明显长于普通 Young GC,因此需要重点关注。常见原因:
| 现象 | 可能原因 | 排查方向 |
|---|---|---|
| 老年代很快就满了 | 对象晋升太快,或者有内存泄漏 | 检查对象分配速率、晋升速率、老年代存活对象和大对象分配;必要时结合 Heap Dump 排查内存泄漏或异常持有 |
| Metaspace 不够 | 动态生成类太多(CGLIB、反射) | 检查类加载与卸载数量、动态代理生成和 ClassLoader 泄漏;确认确实受到上限限制后,再调整 MaxMetaspaceSize |
| System.gc() 被调用 | 某些框架或库主动触发了 GC | 先定位调用来源并确认是否必要;确认可以忽略后,再评估 -XX:+DisableExplicitGC 或收集器支持的并发显式 GC 方式 |
| G1 出现 Evacuation Failure | 可用 Region 不足、Humongous Object 过多 | 查看 Humongous Region 占用、并发标记是否启动过晚以及堆是否缺少预留空间 |
问题三:堆大小设得对不对?
堆太小,GC 频繁,业务线程经常被暂停。增大堆通常能降低 GC 频率,但也会增加内存占用,并可能拉长部分需要处理更多存活对象的 GC 阶段。如果进程工作集接近或超过物理内存,操作系统换页还会造成更明显的延迟。
堆大小至少要容纳稳定状态下的存活对象,并为对象分配、晋升、并发标记周期和收集器预留空间。具体需要多少余量,应通过压测和 GC 日志确定。可以通过 GC 日志、JFR、jcmd <pid> GC.heap_info 和 Heap Dump 观察堆占用、分配速率与存活对象,再通过压测寻找合适的容量。
常见的调优参数:
bash
# JDK 17+ 使用 G1 的起点配置
-Xms4g
-Xmx4g
# 固定堆容量可以减少扩缩容带来的抖动,
# 但会更早提交大量内存,并不适合所有容器环境
-XX:+UseG1GC
-XX:MaxGCPauseMillis=200
# G1 的软停顿目标,不保证每次 GC 都低于 200ms
-Xlog:gc*:file=gc.log:time,uptime,level,tags
# JDK 9+ 统一日志参数
对 G1 来说,通常先保留默认的年轻代和晋升策略,只设置堆容量、停顿目标和 GC 日志。观察到明确问题后,再调整更细的参数。如果需要手动控制年轻代比例,部分传统分代收集器支持 -XX:NewRatio=2,但 G1 通常应保留年轻代自适应调整;-XX:MaxTenuringThreshold=15 设置的是最大晋升年龄,JVM 仍可能根据 Survivor 空间动态提前晋升。
更可靠的调优顺序是:先明确延迟、吞吐量和内存目标,再通过 GC 日志与监控确认瓶颈,然后调整堆容量或收集器,最后只修改少量有明确依据的参数,并通过压测验证结果。不要上来就调一堆参数,先搞清楚瓶颈在哪。
8. 小结
JVM 垃圾回收始终在三个目标之间取舍:停顿时间、吞吐量和内存占用。Serial 用较低的额外开销换取较长停顿,Parallel 追求吞吐量,G1 尝试平衡吞吐量与可预测停顿,ZGC 和 Shenandoah 则把更多工作移到并发阶段,以获得更低的停顿。
现代收集器已经能并发完成越来越多的标记、根处理和对象转移工作,但仍需要短暂暂停应用线程完成部分全局协调。分代 ZGC 的设计目标是把停顿控制在 1ms 以内,实际结果仍需要结合硬件、JDK 版本和业务负载验证。对业务系统来说,真正的目标不是追求理论上的零停顿,而是在可接受的 CPU 和内存成本下,让 GC 行为稳定地满足应用的延迟与吞吐量要求。