跳到主要内容
约 9 分钟3 张卡

分代回收

分代回收根据对象的存活时间,把回收工作分配到年轻代和老年代。本文从分代假设入手,说明怎样记录跨代引用,再解释并发标记和安全点。最后结合分配失败与日志,说明这些机制怎样影响暂停和空间余量。

1. 面试核心​

先记住这几条,再读正文
  • 分代依据。 分代收集依据多数对象很快失效的经验,频繁回收年轻代,减少对长期存活对象的扫描。
  • 跨代引用。 写屏障配合卡表或 RSet 记录跨代引用,让年轻代回收能找到老对象持有的年轻对象。卡 · 分代假设
  • SATB。 G1 的 SATB 前置写屏障记录被覆盖的旧引用,让并发标记继续追踪开始时可达的对象。卡 · SATB 与增量更新
  • 安全点与握手。 安全点让 JVM 能准确解释线程状态与引用位置,线程握手则可只协调指定线程。卡 · 安全点

2. 分代假设​

分代要解决的是反复扫描长期存活对象的成本。短命临时对象多的服务中,多数新对象很快失效,只有少量对象长期存活。因此,频繁处理年轻对象、较少扫描老对象,可以提高回收效率。

典型分代堆把新对象放入年轻代,对象存活若干轮后再按策略进入老年代,这称为晋升。大对象等特殊分配路径则由收集器决定。分代是收集器的设计选择,Java 语言和 JVM 规范都未要求堆必须分代。

不同收集器的代、区域划分和晋升策略有差异,Minor、Major、Full 的含义也需结合实现解释。诊断时应写出收集器和具体事件名,再确认回收范围及是否暂停应用线程。

3. 跨代引用与写屏障​

只回收年轻代时,还要知道哪些老对象正在使用年轻对象。例如,下面的赋值让老对象持有了一个刚创建的对象:

oldOrder.latestEvent = new Event();

如果只扫描年轻代和线程根,又没有记录这条老年代引用,收集器就会漏掉存活的 Event。遍历整个老年代可以找到它,但会失去局部回收的效率。

GC 写屏障是 JVM 在引用更新前后插入的一小段辅助逻辑。它可以把发生变化的堆地址记录到卡表中,让回收时只扫描相关区域。卡表的一个条目对应一段堆地址,被标记为脏的卡表示这段地址需要检查。扫描卡片对应区域后,收集器才能找到具体引用。

Remembered Set(RSet,记忆集)用于组织局部回收所需的外部引用信息。卡表与 RSet 怎样配合,取决于具体收集器。这些结构用屏障执行、额外内存和摘要扫描的成本,换取较小的回收范围。

4. 并发标记与对象图变化​

并发标记让收集器和应用同时运行,但应用仍会删除、覆盖或新增引用。收集器看到的对象图因而一直在变化,必须记录关键变化才能避免漏掉存活对象。可以用三色模型描述遍历进度:

  • 白色表示尚未发现的对象。
  • 灰色表示已发现、尚未扫描完引用的对象。
  • 黑色表示引用已扫描完的对象。

假设黑色对象新增了一条指向白色对象的引用。黑色对象已经扫描过,收集器原本不会再次检查它。如果尚未扫描的旧路径又被删除,收集器就会漏掉这个仍然存活的白色对象。

4.1 SATB 与增量更新​

SATB 与增量更新用不同方式处理上述引用变化。G1 使用 SATB,意思是“开始时快照”:保留标记开始时可达对象的存活信息。覆盖引用前,前置写屏障先记录旧引用,标记线程随后继续追踪它。这样即使原来的边被删除,收集器仍有线索找到目标对象。

增量更新关注的是新增引用。应用让已扫描对象指向未扫描对象时,收集器会记录变化或安排重新扫描。这两类算法都在维护“存活对象不能漏标”的正确性条件,只是记录变化的方向不同。

SATB 按开始时的可达性保留对象,所以对象即使在标记期间变得不可达,本轮仍会保留它。这类后来失去用途的对象称为浮动垃圾,要留到后续回收重新判断。与此同时,应用还在创建新对象,因此并发周期需要为新增分配和浮动垃圾预留空间。

4.2 引用屏障的分工​

同一次引用更新可以涉及不同职责。G1 的 SATB 前置屏障记录旧引用,用于并发标记。后置屏障处理更新后的跨 Region 引用信息,用于维护局部回收需要的摘要。也就是说,RSet 解决的是从哪里扫描外部引用,SATB 解决的是标记过程中怎样避免漏标。

其他收集器会根据算法使用读屏障、写屏障或二者的组合。读屏障是在读取引用时执行的辅助逻辑,因此会把部分成本放到读取路径。理解屏障时,应先确认它维护哪项正确性条件。

GC 屏障维护收集器需要的引用信息。Java 内存模型(JMM)中的内存屏障则约束跨线程的内存访问顺序。两者都能涉及额外机器指令,但服务的正确性目标不同。

5. 安全点​

GC 的部分阶段需要稳定的线程状态,才能准确找到栈里的对象引用。安全点就是 JVM 能解释线程执行状态与引用位置、可以协调操作的状态。编译后的代码会在指定位置轮询是否收到安全点请求,再配合 JVM 暂停执行。

Java 线程阻塞或执行 native 本地代码时,可以处于可安全扫描的状态。线程返回 Java 代码前,还要检查 JVM 是否正在协调全局操作。因此,本地方法执行时间长,本身不足以说明它会拖延安全点。

5.1 全局安全点与线程握手​

操作影响的线程范围不同,协调方式也可以不同。全局安全点需要协调所有相关 Java 线程。Java 17、21 支持的 thread-local handshake 称为线程握手,可以只协调指定线程。因此,部分线程操作无需等待所有线程共同暂停。

应把等待进入安全状态与停顿内工作分开观察:

安全点总时间 = 到达安全状态的时间 + 停顿内工作时间

某个 GC 阶段很短时,线程到达安全状态的等待仍会拉长总时间。引用处理、类卸载等其他停顿内工作也需要时间,所以应查看完整事件。

具体日志解读见GC 停顿分析。

6. Stop-The-World 阶段​

Stop-The-World(STW)指暂停应用线程,让 JVM 执行回收或其他全局工作。Serial 与 Parallel 的主要回收工作都在停顿内完成。G1、ZGC 和 Shenandoah 将部分标记、整理或重定位工作与应用并发执行,但仍保留协调状态的 STW 阶段。

并发工作使用的 CPU 原本也可以用于应用计算,而且回收完成前仍需容纳新分配。因此,比较收集器时应一起观察以下五组指标:

  • 暂停次数和暂停时长分位数。
  • GC 与屏障消耗的 CPU。
  • 应用在相同负载下的吞吐。
  • 并发周期需要预留的堆空间。
  • 分配停滞与退化回收的次数和耗时。

暂停变短只是收益的一部分,还要确认吞吐和资源成本是否符合目标。

7. 晋升与分配失败​

年轻对象存活后,需要有空间接收复制或晋升的对象。目标空间不足时,收集器就会进入失败处理。日志中会出现 evacuation failure、promotion failure 或分配停滞等信号,名称与恢复方式依实现而定。前两者分别表示疏散失败和晋升失败。

空间被耗尽时,重点检查以下四类原因:

  • 回收后的存活对象总量接近堆上限,留给新分配的空间太少。
  • 分配或晋升速率突然增加,目标空间迅速消耗。
  • 大对象增加连续空间需求,或跨代引用增多使扫描工作变重。后者会增加回收成本,而非直接占满目标空间。
  • 并发周期启动太晚或执行太久,尚未完成回收,空闲空间就已耗尽。

G1 的疏散与大对象处理见G1。

调优前先确认存活量、分配速率和周期耗时。降低暂停目标会减少每次允许安排的工作,并增加回收频率,反而会加重并发周期压力。空间不足仍要从对象保留量和可用余量入手。

8. 回收日志​

日志能把前面的机制对应到实际耗时和空间变化。Java 17、21 可以通过统一日志同时记录 GC 与安全点:

java -Xlog:gc*,safepoint:file=gc.log:time,uptime,level,tags -jar app.jar

先用常规日志建立时间线,再关联以下六组信息:

  • GC 的触发原因、事件类型与回收范围。
  • 各阶段持续时间,确定主要成本发生在哪里。
  • 回收前后的占用与当前容量,以及相同回收范围下的存活量变化。
  • 并发周期是否在可用空间耗尽前完成。
  • 到达安全点的时间与停顿内执行工作的时间。
  • 同期分配率、应用吞吐与请求延迟。

只有常规日志无法解释问题时,再短期开启对应的详细标签。这样能获得所需阶段信息,也能控制调试日志的写入量。

9. 常见问题​

9.1 GC 写屏障和 volatile 的内存屏障是一回事吗?​

两者用途不同。GC 写屏障记录引用变化,让局部扫描和并发标记保持正确。volatile 涉及的内存屏障约束跨线程读写顺序与可见性。即使实现中都出现额外指令,也要按各自目的区分。

9.2 安全点只由 GC 触发吗?​

9.3 年轻代越大,性能越好吗?​

10 · 面试题卡片​

10.1 为什么分代收集需要写屏障和 Remembered Set​

出现公司:阿里巴巴

考察重点:区分局部回收的外部引用与并发标记的变化记录。

相关内容:分代假设至并发标记与对象图变化。

参考回答

写屏障与 RSet 让年轻代回收能找到老对象的引用,同时省去遍历整个老年代的成本。

老对象指向年轻对象时,屏障记录引用变化,卡表或 RSet 保存相应摘要。回收时扫描这些外部区域,就能把仍被老对象使用的年轻对象保留下来。

这种方式会增加屏障执行和摘要内存成本。并发标记还有处理对象图变化的要求,G1 的 SATB 前置屏障为此记录旧引用。其他收集器按各自算法使用增量更新、读屏障或写屏障,职责需要分别说明。

10.2 什么是安全点,GC 为什么需要它​

出现公司:阿里云

考察重点:区分安全点到达、停顿内工作与线程握手。

相关内容:安全点和Stop-The-World 阶段。

参考回答

安全点是 JVM 能准确解释线程状态与引用位置、可以协调操作的状态。

GC 的部分阶段需要稳定根集合或更新全局结构,因此要协调线程进入安全状态。等待线程到达的时间也会增加应用感知的停顿。

并发收集器把大量工作移到并发阶段,但仍保留用于协调的 STW 阶段。只影响部分线程的操作可以使用线程握手。诊断时既要区分全局协调和线程局部操作,也要分开看到达时间与停顿内工作时间。

10.3 G1 的 SATB 为何记录旧引用,还会留下浮动垃圾?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释旧引用怎样维持开始时的可达性,以及浮动垃圾为什么需要额外空间。

相关内容:SATB 与增量更新。

参考回答

SATB 记录旧引用,是为了在引用被覆盖后仍能追踪标记开始时可达的对象。

前置屏障把旧引用保存下来,标记线程随后继续处理,避免删除引用导致漏标。

对象如果在标记期间变得不可达,本轮仍会因开始时的快照而保留。这就是浮动垃圾,要等后续回收重新判断。并发周期还会产生新对象,所以需要为这两部分预留空间。

11. 参考资料​