垃圾回收
开篇:谁在帮 Java 程序"打扫卫生"?
在 C/C++ 的世界里,程序员就像自己租了一间房子——用完的东西必须自己扔,忘了扔就会"垃圾堆满屋"(内存泄漏)。Java 则像住进了带物业的小区,有一支叫做 GC(Garbage Collector) 的保洁团队在后台自动帮你收拾——你只管制造垃圾(创建对象),保洁会定期来清理不再使用的对象,释放内存。
但"自动"不等于"不需要了解"。生产环境中的 GC 调优、OOM 排查、STW 卡顿分析,都要求你对垃圾回收机制有深入的理解。很多语言都有 GC 机制(Java、C#、Go、Python、JavaScript、Kotlin 等),但 Java 的 GC 体系可以说是最复杂、最成熟的。
本文将从三个核心问题出发:怎么判断垃圾?用什么算法回收?有哪些收集器可选?
一、如何判断对象是否存活
垃圾回收的第一步,是搞清楚哪些对象是"垃圾"——也就是不再被任何人使用的对象。JVM 有两种判断算法。
1.1 引用计数法:简单但有致命缺陷
思路很直觉:给每个对象维护一个整型的引用计数器,被引用时 +1,引用失效时 -1,计数器为 0 就可以回收。
优点:实现简单,判断效率高,回收没有延迟。
缺点有三:
- 需要额外的存储空间来维护计数器,增加了内存开销。
- 每次赋值都要更新计数器,增加了时间开销。
- 致命缺陷:无法处理循环引用。
// A 引用 B,B 引用 A,计数器永远不为 0
// 但它们已经没有任何外部引用了,是实打实的垃圾
Object a = new Object(); // a.count = 1
Object b = new Object(); // b.count = 1
a.field = b; // b.count = 2
b.field = a; // a.count = 2
a = null; // a.count = 1 (不为 0!)
b = null; // b.count = 1 (不为 0!)
// 两个对象永远无法回收 → 内存泄漏正因为循环引用这个致命缺陷,Java 的垃圾回收器没有使用引用计数法。
1.2 可达性分析:GC Roots
Java 采用的是可达性分析算法:从一组被称为 GC Roots 的根对象出发,按照从上到下的方式搜索被根对象集合所连接的目标对象是否可达。所有能被直接或间接访问到的对象都是"存活"的,搜索走过的路径称为引用链。如果目标对象没有与任何引用链相连,则不可达,标记为垃圾。
上图中,E 和 F 没有任何从 GC Roots 出发的引用链可以到达,所以它们是垃圾。
哪些东西可以作为 GC Roots? 记住一个原则——一组必须活跃的引用:
- 虚拟机栈中局部变量表引用的对象(方法正在执行,局部变量当然得活着)
- 本地方法栈中 JNI 引用的对象
- 方法区中
static字段引用的对象 - 方法区中常量引用的对象
- 被
synchronized锁持有的对象 - JVM 内部引用(基本类型对应的 Class 对象、常驻异常对象、系统类加载器等)
- 反映 JVM 内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等
- 分代收集时,Remembered Set 中记录的跨代引用也会被加入 GC Roots
简单判断法:一个指针如果保存了堆内存里的对象引用,但它自己不在堆中(比如在栈上、方法区中),那它就是一个 GC Root。
可达性分析需要在一致性快照中进行——分析过程中对象的引用关系不能变。这就是为什么 GC 时必须有 STW(Stop The World):暂停所有应用线程,保证标记结果的准确性。即使是号称"几乎不停顿"的 CMS,枚举根节点这一步也必须 STW。
可达性分析的不足:
- STW 时间长:需要全局分析,整个过程都是 STW 的,对大堆来说开销很大。这也是引入三色标记法的动机——把最耗时的标记阶段从 STW 中解放出来。
- 内存消耗:需要存储所有对象和引用关系信息。大型程序的对象数量巨大,可能导致内存压力。
1.3 对象的"缓刑":finalize 机制
一个对象被可达性分析判定为不可达后,并不会立刻死亡,它还有一次"缓刑"机会。判断一个对象是否可回收,至少需要经历两次标记过程:
- 第一次标记:通过可达性分析发现对象到 GC Roots 没有引用链,进行第一次标记。
- 筛选判断:如果对象没有覆盖
finalize()方法,或者finalize()已经被调用过,直接判定为不可触及——必死无疑。如果对象覆盖了finalize()且尚未执行过,它会被放入 F-Queue 队列,由一个低优先级的 Finalizer 线程执行finalize()方法。 - 第二次标记:
finalize()是对象逃脱死亡的最后机会。如果在finalize()中,对象成功与引用链上的某个对象建立了联系,它会在第二次标记时被移出"即将回收"集合——逃出生天。
但要注意:finalize() 只会被调用一次。如果对象第二次变为不可达,直接变为不可触及状态,不会再有机会了。
虚拟机中的对象有三种状态:可触及(从根节点可达)、可复活(引用被释放但可能在 finalize 中复活)、不可触及(finalize 已调用且没有复活)。只有不可触及的对象才能被回收。
现在已经不提倡使用 finalize()——它影响 GC 性能和安全性,而且 Finalizer 线程优先级低,不保证及时执行。清理资源请用 try-with-resources 或 Cleaner。
1.4 四种引用类型
Java 提供了四种引用类型,让你可以精细控制对象的生命周期。它们的主要作用是帮助 GC 决定何时回收对象:
| 类型 | 回收时机 | 典型用途 | 代码方式 |
|---|---|---|---|
| 强引用 | 只要引用在就永远不回收(哪怕 OOM) | 默认方式 | Object obj = new Object() |
| 软引用 | 内存不足时回收 | 缓存(Guava Cache) | new SoftReference<>(obj) |
| 弱引用 | 下次 GC 时必定回收 | WeakHashMap、ThreadLocal | new WeakReference<>(obj) |
| 虚引用 | 随时可能被回收 | 追踪回收状态,清理堆外资源 | new PhantomReference<>(obj, queue) |
强 > 软 > 弱 > 虚,引用的"握力"依次递减。
几个实际应用的例子:
- HashMap 使用强引用,GC 时 Map 中的对象不会被回收。
- Guava Cache 的
SoftValueReference使用软引用,内存不足时自动回收缓存对象,避免 OOM。 - WeakHashMap 的 Entry 继承了
WeakReference,当 key 不再被强引用时,Entry 会在下次 GC 时被回收。 - ThreadLocal 的 Entry 中 key(ThreadLocal 本身)是弱引用,value 是强引用。用完
ThreadLocal后必须调用remove(),否则 value 会内存泄漏。 - 虚引用通常与
ReferenceQueue配合使用,在对象被回收后通过队列通知做清理工作(如释放ByteBuffer.allocateDirect()分配的直接内存)。
// 软引用示例
SoftReference<byte[]> cache = new SoftReference<>(new byte[1024 * 1024]);
byte[] data = cache.get(); // 内存充足时返回对象,内存不足时返回 null
// 弱引用示例
WeakReference<Object> weakRef = new WeakReference<>(new Object());
System.gc();
System.out.println(weakRef.get()); // 输出 null,下次 GC 必定回收二、垃圾回收算法
知道了哪些是垃圾,接下来就是"怎么扫"的问题。每种算法都有优缺点,没有银弹。
2.1 标记-清除:最基础的算法
标记:从 GC Roots 出发,遍历对象图,标记所有存活对象。注意:标记的是存活对象,不是垃圾对象——一般在对象的 Header 中记录为可达。
清除:遍历整个堆,回收所有未被标记的对象。
所谓"清除"并不是真的把内存清零,而是把垃圾对象的地址记录到空闲列表中。下次分配新对象时,从空闲列表中找一个够大的位置覆盖。
缺点一目了然:内存碎片。回收后内存空间不连续,就像一块拼图被抽走了几块。假设碎片加起来有 1MB,但不连续,这时候来了一个刚好 1MB 的大对象——抱歉,放不下。
而且即使碎片大小和新对象大小接近,也大概率只能利用其中一部分,剩下的零头很难再被用到——这才是碎片的真正危害。
- 优点:速度快(不需要移动对象)
- 缺点:产生内存碎片;GC 时需要 STW
2.2 标记-复制:用空间换整齐
把内存分成两块,每次只用其中一块。GC 时把存活对象复制到另一块(复制过程中自动紧凑排列),然后把原来那块整个清空。两块内存交换角色,如此反复。
- 优点:没有碎片,分配内存时只需要移动指针,速度极快
- 缺点:浪费一半内存;复制对象有性能和时间开销
复制算法适合垃圾对象多、存活对象少的场景——需要复制的对象越少,效率越高。这也是新生代采用复制算法的原因:新生代中 90% 以上的对象都是朝生暮死。
2.3 标记-整理:不浪费但是慢
第一阶段和标记-清除一样,标记存活对象。第二阶段不是清除,而是把所有存活对象向内存的一端压缩,按地址顺序排列,然后直接清理掉边界以外的内存。
最终效果等同于"标记-清除 + 碎片整理"。 与标记-清除的本质区别是:标记-清除是非移动式算法,标记-整理是移动式算法。
- 优点:既不浪费空间,也没有碎片
- 缺点:移动对象耗时,移动时还要更新所有指向被移动对象的引用,而且移动过程需要 STW
适合对象存活率高的老年代。
从时间和效果两个维度对比三种算法:
- 时间(快→慢):标记-清除 < 复制 < 标记-整理
- 效果(好→差):标记-整理 > 复制 >= 标记-清除
2.4 分代收集:综合方案
没有哪种算法是万能的,所以 JVM 的解决方案是——分代收集:把堆按对象的生命周期分成不同的区域,不同区域用不同的算法。几乎所有的 GC 都采用分代收集。
| 区域 | 特点 | 适用算法 | GC 频率 |
|---|---|---|---|
| 新生代 | 生命周期短,存活率低 | 标记-复制(Eden + 2 Survivor,8:1:1) | 高 |
| 老年代 | 生命周期长,存活率高,区域较大 | 标记-整理(CMS 例外用标记-清除) | 低 |
为什么新生代用复制算法? 新生代对象存活率低(大部分是朝生暮死),GC 频繁。复制算法效率高且没有碎片。但纯粹的复制算法浪费一半内存,新生代的优化是把区域分成 Eden + 两个 Survivor(8:1:1),每次只浪费 10% 的空间。
为什么老年代用标记-整理? 老年代对象存活率高,复制算法要复制的对象太多,不划算。标记-整理虽然慢一点,但不浪费空间,没有碎片。CMS 为了降低 STW 时间,选择了标记-清除(牺牲碎片换取速度)。
新生代的对象每经过一次 Minor GC 且存活,年龄 +1。当年龄达到阈值(默认 15),就会被"晋升"到老年代。超过 -XX:PretenureSizeThreshold 的大对象会直接在老年代分配。
为什么新生代需要两个 Survivor? 如果只有一个 Eden + 一个 Survivor,Young GC 时 Eden 中存活的对象复制到 Survivor 后,下次 GC 时 Survivor 中的存活对象无处可去(不能复制到 Eden,Eden 是分配区)。有两个 Survivor 交替使用,就解决了这个问题:每次 GC 总有一个 Survivor 是空的,可以接收存活对象。这样只浪费了一个 Survivor 的空间(约 10%),比对半分(50%)高效得多。
2.5 增量收集与分区算法
除了分代,还有两种更精细的策略:
增量收集算法:每次只收集一小块区域,然后切换到应用线程,交替执行直到收集完成。缺点是线程切换频繁,总吞吐量下降。
分区算法:把整个堆划分成多个小区间(Region),每次根据目标停顿时间回收若干个区间。G1 就是分区算法的典型实现。
分代算法按对象生命周期划分,分区算法按物理空间划分。G1 两者兼具:逻辑上分代,物理上分区。
三、垃圾收集器家族
算法是理论,收集器是实现。不同收集器适合不同场景。
3.1 经典收集器对比表
| 收集器 | 作用区域 | 算法 | 线程 | 关注指标 | 适用场景 |
|---|---|---|---|---|---|
| Serial | 新生代 | 复制 | 单线程 | - | 单核/小内存 |
| Serial Old | 老年代 | 标记-整理 | 单线程 | - | 单核/小内存 |
| ParNew | 新生代 | 复制 | 多线程并行 | - | 配合 CMS |
| Parallel Scavenge | 新生代 | 复制 | 多线程并行 | 吞吐量 | 后台计算/批处理 |
| Parallel Old | 老年代 | 标记-整理 | 多线程并行 | 吞吐量 | 后台计算/批处理 |
| CMS | 老年代 | 标记-清除 | 并发 | 低延迟 | 在线交易系统 |
| G1 | 整堆 | 复制+标记-整理 | 并发 | 可控停顿 | 大内存、通用 |
| ZGC | 整堆 | 并发标记-整理 | 并发 | 亚毫秒停顿 | 超大堆、极低延迟 |
JDK 8 默认:Parallel Scavenge + Parallel Old。JDK 9+ 默认:G1。JDK 14 移除了 CMS。
三个核心性能指标:吞吐量、暂停时间、内存占用。高吞吐量和低暂停时间天然矛盾——要高吞吐量就要减少 GC 频率,但每次 GC 的暂停时间就会变长;要低暂停就要频繁做小规模 GC,但总体吞吐量会下降。现在的标准是:在最大吞吐量优先的前提下,降低停顿时间。
并发回收 vs 并行回收:并行回收(Parallel)是多个 GC 线程同时工作,但应用线程全部暂停(STW),关注吞吐量。并发回收(Concurrent)是 GC 线程和应用线程交替/同时执行,关注 STW 时长。
3.2 Serial / ParNew / Parallel:串行与并行
Serial:最古老的收集器,单线程,STW。新生代用复制算法,老年代版本(Serial Old)用标记-整理。优势是没有线程切换开销,单核场景下效率最高。缺点是对交互强的应用不友好。
-XX:+UseSerialGC # 同时启用 Serial(新生代)和 Serial Old(老年代)ParNew:就是 Serial 的多线程版本,参数和回收算法完全一样。GC 时多线程并行执行,STW 时间通常比 Serial 短。主要用来配合 CMS 使用。
-XX:+UseParNewGC # 启用 ParNew(新生代)
-XX:ParallelGCThreads=N # 设置并行线程数,默认 = CPU 核数Parallel Scavenge:同样是新生代并行收集器,但它的关注点是吞吐量(吞吐量 = 代码运行时间 / (代码运行时间 + GC时间))。适合后台运算、批处理、订单处理、科学计算等不需要太多交互的场景。
Parallel Old:Parallel Scavenge 的老年代版本,标记-整理算法。JDK 8 的默认组合就是 Parallel Scavenge + Parallel Old。
-XX:+UseParallelGC # 启用 Parallel Scavenge(新生代)
-XX:+UseParallelOldGC # 启用 Parallel Old(老年代),JDK 8 默认开启
-XX:MaxGCPauseMillis=N # 最大停顿时间目标(毫秒)
-XX:GCTimeRatio=99 # GC 时间不超过 1%(即吞吐量 99%)
-XX:+UseAdaptiveSizePolicy # 开启自适应调节策略3.3 CMS:低延迟先驱
CMS(Concurrent Mark Sweep)是第一个实现了让 GC 线程和用户线程同时工作的收集器,目标是最短的 STW 时间。它是老年代的收集器,采用标记-清除算法。
工作流程分四步:
- 初始标记(STW):只标记 GC Roots 直接关联的对象,速度很快。
- 并发标记:从初始标记的对象出发,遍历整个对象图。这是最耗时的阶段,但不需要 STW,GC 线程和用户线程并发执行。(中间还有一个预清理子阶段,也是并发的,目的是减少重新标记的工作量。)
- 重新标记(STW):修正并发标记期间因用户线程继续运行而产生的变动。CMS 采用增量更新方案解决漏标问题。
- 并发清除:清理垃圾对象,同样与用户线程并发执行。不需要移动存活对象,所以可以并发。
从四个步骤可以看出,最耗时的并发标记和并发清除都不需要 STW,只有初始标记和重新标记需要短暂 STW,所以 CMS 的整体停顿时间非常短。
为什么初始标记和重新标记需要 STW,而并发标记不需要?
初始标记阶段扫描 GC Roots 直接引用的对象。大多数 GC Roots(如栈上的局部变量)并不是堆中对象,无法通过写屏障感知它们的变化,所以必须 STW 来保证根集合的准确性。并发标记是从初始标记结果出发遍历间接可达对象——这个过程可以容忍一定的不精确(通过写屏障记录变化),所以可以和用户线程并发。重新标记是清理前的最后一次标记,必须保证准确性,所以需要 STW。
简单总结:第一次标记根不能变,中间标记可以边走边修,最后一次必须确认无误。
CMS 的三大缺点:
- CPU 敏感:并发阶段占用 GC 线程,会让应用变慢,对 CPU 资源非常敏感。
- 内存碎片:标记-清除算法的天然缺陷。碎片太多时大对象无法分配,会触发 Full GC(退化为 Serial Old),停顿反而变长。为什么不用标记-整理?因为并发清除时用户线程还在跑,如果移动对象,用户线程的引用就失效了。标记-整理更适合 STW 场景。
- 浮动垃圾:并发标记和并发清除阶段用户线程还在产生新垃圾,这些"浮动垃圾"CMS 无法在当次标记,只能留到下次 GC。如果预留空间不够,就会出现 Concurrent Mode Failure,临时启用 Serial Old 兜底——这时停顿会更长。
CMS 不能等老年代快满了才回收,必须在堆使用率达到阈值时就提前开始。JDK 9 标记废弃,JDK 14 正式移除。
如何选择:如果想要最小化内存和并行开销,选 Serial GC;如果最大化吞吐量,选 Parallel GC;如果想要最小化 GC 停顿时间,选 CMS GC(或更好的 G1)。
3.4 G1:分区收集的革命
G1(Garbage First)是 JDK 9+ 的默认收集器,也是目前最主流的选择。它把整个堆划分为大量等大小的 Region(1MB~32MB),每个 Region 可以扮演 Eden、Survivor、Old 或 Humongous(大对象)区。
E = Eden, S = Survivor, O = Old, H = Humongous。Region 不需要物理连续。所有 Region 大小相同,在 JVM 生命周期内不会改变。
G1 的五大优势:
- 并行与并发:充分利用多核 CPU,部分阶段用户线程不停顿。
- 分代收集:逻辑上仍有年轻代和老年代概念,但不再物理隔离,G1 可以独立管理整个堆。
- 空间整合:Region 间用复制算法,整体相当于标记-整理,不产生碎片,有利于程序长时间运行。
- 大对象友好:Humongous 区域存放超过 Region 大小一半的大对象,不会因找不到连续空间提前触发 GC。
- 可预测的停顿时间:这是 G1 相对 CMS 的最大优势——能让用户指定停顿时间目标。
G1 如何控制停顿时间?
通过 -XX:MaxGCPauseMillis=<N>(默认 200ms)设定目标。G1 维护一个回收收益预测模型——记录每个 Region 的回收时间和能释放的空间,每次 GC 时选择一组 Region 使预计停顿不超标。模型随运行不断调整。
就像评估工作量:一个大项目你不知道要几天,但把功能点全拆开,每个小任务的耗时就很好估算。给你固定时间,你就知道这次能做几个任务。
G1 的 Region 大小:
- 范围:1MB ~ 32MB,必须是 2 的幂次方。
- 默认让堆划分为约 2048 个 Region(如 4GB 堆 → 2MB Region,16GB 堆 → 8MB Region)。
- 也可用
-XX:G1HeapRegionSize手动指定。 - 一旦 JVM 启动,Region 大小就固定了。
G1 的回收过程(四阶段):
阶段一:Young GC。Eden 区用尽时触发,STW。扫描根(含 RSet)→ 更新 RSet → 复制存活对象到 Survivor/Old → 处理引用。复制过程保证目标 Region 中对象连续存储,没有碎片。
阶段二:并发标记。堆使用率达到阈值(默认 45%)时启动。初始标记(STW)→ 根区域扫描 → 并发标记 → 再次标记(STW,G1 用 SATB 比 CMS 更快)→ 独占清理(STW,计算回收价值排序)→ 并发清理。
阶段三:Mixed GC(混合回收)。回收所有年轻代 + 部分老年代 Region。默认分 8 次(-XX:G1MixedGCCountTarget=8),只回收垃圾占比 >= 65% 的 Region。如果可回收垃圾占堆比 < 10%(-XX:G1HeapWastePercent=10),就停止 Mixed GC。
阶段四:Full GC(兜底)。G1 初衷就是避免 Full GC。但如果 Mixed GC 速度跟不上分配速度(没有空闲 Region 用于复制,或并发标记完成前空间耗尽),退化为单线程 Full GC,性能极差。
记忆集(Remembered Set)与跨代引用:
每个 Region 维护一个 RSet,记录"谁引用了我"。写引用时通过写屏障检查是否跨 Region——如果是,通过 Card Table 记录到目标 Region 的 RSet。GC 时把 RSet 加入 GC Roots,不需要全堆扫描。
G1 vs CMS 经验法则:小堆(< 4~6GB)CMS 可能更优(G1 的 Region 管理有额外开销),大堆 G1 优势明显。平衡点大约在 6~8GB。
G1 优化建议:
- 避免用
-Xmn或-XX:NewRatio显式设置年轻代大小,会覆盖暂停时间目标。 - 停顿目标不要太苛刻,太苛刻会影响吞吐量。
G1 常用参数:
-XX:+UseG1GC # 启用 G1
-XX:MaxGCPauseMillis=200 # 目标最大停顿时间(毫秒)
-XX:G1HeapRegionSize=N # Region 大小(1MB~32MB,2的幂)
-XX:InitiatingHeapOccupancyPercent=45 # 堆占用率达到多少时触发并发标记
-XX:G1MixedGCCountTarget=8 # 一次并发标记后分几次做 Mixed GC
-XX:G1MixedGCLiveThresholdPercent=65 # 垃圾占比超过多少的 Region 才会被回收
-XX:G1HeapWastePercent=10 # 可回收垃圾占堆比低于此值则停止 Mixed GCG1 适用场景:面向服务端应用,具有大内存(6GB+)和多处理器的机器。最主要的应用是需要低 GC 延迟,同时要求可预测的暂停时间。
3.5 ZGC:亚毫秒停顿
ZGC(Z Garbage Collector)在 JDK 11 中引入,目标是无论堆多大,停顿时间都不超过亚毫秒级,且不随堆大小、存活对象数量增长。
核心特点:
- 极低延迟:大部分 GC 工作和用户线程并发执行。
- 支持超大堆:8MB ~ 16TB。
- 核心技术:染色指针(Colored Pointer) + 读屏障(Load Barrier)。
- 不分代回收(JDK 21 前):JDK 21 引入分代 ZGC,JDK 24 移除非分代 ZGC。
- 设计简单:代码库较小,易于维护和扩展。
| 特性 | CMS | G1 | ZGC |
|---|---|---|---|
| JDK 版本 | 1.5~14(已移除) | 7+(9+ 默认) | 11+(15+ 生产可用) |
| STW | 两次短暂停顿 | 可预测停顿 | 亚毫秒级 |
| 堆大小 | 一般 | 4GB+ | 8MB ~ 16TB |
| 碎片 | 有 | 无 | 无 |
| 分代 | 物理分代 | 逻辑分代 | JDK 21+ 支持分代 |
| 核心技术 | 三色标记+增量更新 | 三色标记+SATB+Region | 染色指针+读屏障 |
四、三色标记与并发问题
CMS 和 G1 都采用三色标记算法来实现并发标记,大幅降低 STW 时间。它是可达性分析的一种特殊形式,解决了传统可达性分析 STW 时间过长的问题。
4.1 三色标记算法
将对象分为三种颜色(状态):
- 白色:未被标记,GC 周期开始时所有对象都是白色。代表潜在的垃圾。
- 灰色:自身已标记,但它引用的对象还没全部标记完。
- 黑色:自身和它引用的所有对象都已标记完——确认存活。
标记过程分三个阶段:
- 初始标记(STW):遍历 GC Roots,把根对象和直接引用的对象标记为灰色。只扫描直接可达对象,所以很快。
- 并发标记(不需要 STW):从灰色对象开始,遍历整个对象图。把灰色对象引用的白色对象标灰,自身标黑。重复直到没有灰色对象。这是最耗时的阶段,但和用户线程并发执行。
- 重新标记(STW):修正并发标记阶段的变动。从灰色对象重新遍历,更新标记。
标记结束后,仍然是白色的对象就是垃圾,可以回收。
三色标记最大的意义在于:把最耗时的并发标记阶段从 STW 中解放出来,和用户线程并发执行,大大降低了停顿时间。初始标记和重新标记虽然需要 STW,但它们都很快。
4.2 并发标记的挑战:多标与漏标
并发标记时用户线程还在修改对象引用,会带来两个问题:
多标(浮动垃圾):一个对象在标记时还有引用(标黑了),但并发过程中引用被删除了,它变成了垃圾却被当成存活对象保留了。多标不致命,产生的浮动垃圾下次 GC 可以回收。
漏标(致命问题):一个存活对象没有被正确标记,被错误回收了——正在使用的对象突然消失,是灾难性的。
漏标发生需要同时满足两个充要条件:
- 至少有一个黑色对象在标记后新增了对白色对象的引用。
- 所有灰色对象在扫描完成前删除了对该白色对象的引用。
破坏任何一个条件,就能防止漏标。
4.3 增量更新 vs 原始快照(SATB)
增量更新(Incremental Update)——CMS 采用——破坏条件 1:
当黑色对象新增了对白色对象的引用时,通过写屏障记录下这个黑色对象。在重新标记阶段,以这些黑色对象为根重新扫描。白色对象被发现后标灰,不会被漏标。
类比:你已经检查过某个抽屉(标黑了),但家人又往里面放了新东西。增量更新就是给你留一张便条:"这个抽屉有变化,请重新检查。"
增量更新的本质是实时记录变化,确保每一次变化都被重新检查。缺点是需要重新扫描一些对象。
原始快照 SATB(Snapshot At The Beginning)——G1 采用——破坏条件 2:
当灰色对象要删除对白色对象的引用时,通过写屏障先把这个白色对象记录下来。在重新标记阶段以这些白色对象为根扫描。
类比:开始清理前先拍一张照片,无论后来东西怎么挪动,都按照片上的记录来判断。
SATB 可能产生少量浮动垃圾(被记录的白色对象可能确实已经没用了),但比漏标安全得多。
原始快照的本质是基于 GC 开始时的状态做决策,忽略之后的变化,保证 GC 的稳定性和一致性。
两种方案都使用了写屏障(Write Barrier)——在对象引用被修改时插入额外的记录代码。写屏障有一定的性能开销,尤其在高并发场景下,但相比全 STW 的可达性分析,这点代价非常值得。
五、SafePoint 与 STW
5.1 什么是 SafePoint?
SafePoint(安全点)是程序执行过程中的特殊位置,线程运行到这里时状态是"安全的"——所有 GC Roots 已知,堆中对象状态一致。JVM 可以在安全点暂停线程。
安全点通常出现在:方法调用、循环回边、异常抛出等位置。
哪些操作需要等到安全点?当 JVM 需要挂起线程时,会等到安全点再执行:
- 垃圾回收(STW 阶段)
- 偏向锁撤销
- 代码热替换
- 获取 Dump(
jstack、jmap) - 死锁检测
- JIT 编译优化
- 定时进入(
-XX:GuaranteedSafepointInterval)
5.2 安全区域
如果线程正在执行阻塞 I/O 或长时间计算(没有安全点检查点),JVM 引入了安全区域(Safe Region):一段不会改变对象引用关系的代码(如纯计算)被视为安全区域。线程进入安全区域时通知 JVM,GC 可以直接把它当成"已到达安全点"。退出安全区域时需检查 GC 是否正在进行。
5.3 STW 的影响与优化
STW 会暂停所有应用线程,直接影响 RT 和 QPS。优化方向:
- 选择并发回收器(CMS、G1、ZGC),让 GC 线程和用户线程并发执行。
- 调整堆大小和 GC 参数,减少 Full GC 频率。
- 避免大对象分配,减少 Humongous 回收开销。
STW 不仅存在于 GC 中。数据库维护、Kafka 重平衡、操作系统升级等场景也有类似的"全局暂停"。
STW 的时间长短取决于两个因素:堆中存活对象的数量(影响标记时间)和垃圾对象的数量(影响清理/复制时间)。不同收集器的 STW 阶段不同——Serial 整个 GC 过程都是 STW,Parallel 的 STW 时间更短(多线程并行),CMS/G1 只在初始标记和重新标记阶段 STW,ZGC 的 STW 可以控制在亚毫秒级别。
在评估 GC 对业务的影响时,主要关注两个指标:单次停顿时间(影响 P99 延迟)和停顿频率(影响吞吐量)。理想的状态是两者都尽可能小,但通常需要权衡取舍。
六、GC 的触发条件与完整流程
6.1 Young GC 触发条件
Eden 区分配满了就触发。
6.2 Full GC 触发条件
- 老年代空间不足:大对象直接进老年代但放不下;Young GC 后晋升对象放不下。
- 空间分配担保失败:Young GC 前检查老年代可用空间不足时触发。
- 永久代/元空间不足。
- 代码显式调用
System.gc()(不保证立即执行)。
6.3 一次完整的 GC 流程(JDK 8)
- 新对象在 Eden 区创建。超大对象直接进老年代。
- Eden 满了,触发 Young GC。先做空间分配担保检查——担保失败可能直接 Full GC。
- Young GC 采用标记-复制:标记存活对象 → 复制 Eden 和 From Survivor 中的存活对象到 To Survivor → 清空 Eden 和 From Survivor。
- Survivor 存不下的对象直接晋升老年代。存活对象年龄 +1,达到阈值也晋升。
- 老年代满了或担保失败,触发老年代 GC(CMS 并发标记-清除,G1 Mixed GC)。
- 老年代 GC 后还是不够——Full GC(单线程 Serial Old)。
- Full GC 后还是不够——OOM。
6.4 什么是跨代引用?
跨代引用指老年代对象引用新生代对象。Young GC 时如果不处理,会漏标新生代中被老年代引用的存活对象。
两种简单但低效的解决方案:
- Young GC 时不中断老年代扫描——成本太高。
- 把所有老年代对象也作为 GC Root——同样太慢。
高效方案:Remembered Set(记忆集)。记录老年代到新生代的引用关系,GC 时把 RSet 加入 GC Roots,避免全堆扫描。RSet 的具体实现通常是 Card Table(卡表)。
七、如何选择垃圾收集器
没有万能的收集器,选择要基于实际场景。按四步走:
- 单核/小内存(嵌入式、客户端)→ Serial GC
- 重吞吐量(批处理、定时任务、后台计算)→ Parallel GC
- 重低延迟(在线交易、Web 应用)→ G1(堆 4G+)或 ZGC/Shenandoah(堆 8G+,JDK 15+)
- 根据 JDK 版本:JDK 8 默认 Parallel,JDK 9+ 默认 G1,JDK 15+ 可用 ZGC
G1 适合 4~6GB 以上的堆。ZGC 和 Shenandoah 适合超大堆(TB 级),但稳定性仍需在具体场景验证。
八、常见面试题精选
Q1:JVM 有哪些垃圾回收算法?各有什么优缺点?
三种基础算法:标记-清除(快但有碎片)、标记-复制(无碎片但浪费空间)、标记-整理(无碎片不浪费但慢)。实际使用分代收集策略:新生代用标记-复制(存活率低,复制少),老年代用标记-整理(存活率高,不能浪费空间)。CMS 为了降低延迟选择了标记-清除。
Q2:什么是三色标记?CMS 和 G1 解决漏标的方案为什么不同?
三色标记把对象分为白(未标记)、灰(标记中)、黑(标记完成)。漏标需同时满足:黑色新增白色引用 + 灰色删除白色引用。CMS 用增量更新破坏第一个条件(记录黑色的变化),G1 用 SATB 破坏第二个条件(记录灰色删除前的快照)。SATB 更快(不需要重新扫描黑色对象),但可能产生更多浮动垃圾。
Q3:G1 如何精确控制 STW 时间?
G1 把堆划分为约 2048 个 Region,维护每个 Region 的回收时间和收益模型。根据 -XX:MaxGCPauseMillis 设定的目标,每次选择一组 Region 使预计停顿不超标。模型随运行动态调整,越来越精准。
Q4:Young GC 和 Full GC 的触发条件分别是什么?
Young GC:Eden 区满了。Full GC:老年代空间不足、空间分配担保失败、永久代/元空间不足、System.gc() 显式调用。
Q5:什么是 SafePoint?为什么 GC 需要它?
SafePoint 是代码中 JVM 可以安全暂停线程的位置。GC 需要 STW 时,所有线程必须到达安全点才能开始——保证标记过程中对象引用关系不变。长时间阻塞的线程通过"安全区域"机制处理。
Q6:Java 8 和 Java 11 的 GC 有什么区别?
默认收集器:Java 8 用 Parallel,Java 11 用 G1。Java 11 新增 ZGC(实验性)。垃圾识别:Java 8 传统可达性分析(全 STW),Java 11 的 G1 三色标记(并发,STW 更短)。内存划分:Java 8 固定分代,Java 11 的 G1 Region 自适应。
Q7:为什么 G1 从 JDK 9 成为默认?
G1 兼具并发回收(低延迟)、分代收集(效率)、空间整合(无碎片)、可预测停顿(可控性)、动态调整堆大小(灵活性)五大优势。相比 Parallel 停顿更短,相比 CMS 没有碎片和 Concurrent Mode Failure 风险。在大内存环境下(4G+)表现尤其出色。
Q8:什么是跨代引用?怎么处理的?
跨代引用指老年代对象引用新生代对象。Young GC 时如果不处理,会漏标被老年代引用的新生代存活对象。暴力方案是把老年代也全扫一遍,成本太高。高效方案是 Remembered Set + Card Table:记录跨代引用关系,GC 时把 RSet 加入 GC Roots,只扫描有跨代引用的区域而非全堆。
Q9:一次完整的 GC 流程是怎样的?
Eden 分配 -> Eden 满触发 Young GC(标记-复制)-> 存活对象进 Survivor,年龄 +1 -> 年龄达标或 Survivor 放不下则晋升老年代 -> 老年代空间不足触发 Old GC(CMS 并发标记-清除 / G1 Mixed GC)-> Old GC 不够则 Full GC(单线程 Serial Old 兜底)-> Full GC 后还不够 -> OOM。
Q10:G1 的 Region 太大或太小有什么影响?
Region 太小(如 1MB):分配效率低,大对象需要跨多个 Region 存储,分配开销大。Region 太大(如 32MB):回收粒度粗,单次 GC 停顿可能变长,难以满足低延迟目标。一般让 JVM 自动计算(堆大小 / 2048),除非有特殊需求再手动调整。
Q11:项目中如何选择垃圾回收器?
四步决策:第一步看机器(单核/小内存选 Serial);第二步看业务类型(重吞吐量选 Parallel,重延迟选 G1/ZGC);第三步看堆大小(4G 以下用 Parallel 或 CMS,4G 以上用 G1,超大堆用 ZGC);第四步看 JDK 版本(不同版本支持的收集器不同)。ZGC 和 Shenandoah 虽然停顿极短,但目前稳定性和生态成熟度仍需在具体场景验证。
小结
| 问题 | 答案 |
|---|---|
| 怎么判断垃圾? | 可达性分析(GC Roots 出发,不可达即垃圾) |
| 用什么算法回收? | 标记-清除(快但碎片)、标记-复制(无碎片但费空间)、标记-整理(无碎片但慢) |
| 实际怎么用? | 分代收集:新生代用复制,老年代用标记-整理 |
| 怎么减少停顿? | 三色标记实现并发标记,增量更新/SATB 解决漏标 |
| 收集器怎么选? | 小堆 Serial,重吞吐量 Parallel,重延迟 G1/ZGC |
垃圾回收机制是 Java "自动内存管理"的核心支柱。理解了对象存活判定、回收算法、收集器特性和三色标记原理,你就有了分析 GC 日志、调优停顿时间、排查内存泄漏的理论基础。遇到线上 GC 问题时,不再是两眼一抹黑,而是能有的放矢地定位根因。