1. 从一次线上停顿说起:为什么需要关注记忆集与安全点
很多做Java后端的朋友,第一次真正被GC问题“教育”,往往不是在本地跑Demo的时候,而是在线上服务突然出现几百毫秒甚至几秒的停顿,监控面板上Full GC次数飙升,接口超时告警一片。你打开日志,看到的是类似[GC pause (G1 Evacuation Pause) ...]这样的记录,但具体为什么停这么久、为什么有些对象明明已经不可达却回收不掉、为什么并发标记阶段还会出现对象“消失”的诡异现象,光看日志是看不出来的。
这篇文章想聊的,就是HotSpot虚拟机垃圾回收里三个经常被一笔带过、但真正决定GC效率和正确性的底层机制:记忆集(Remembered Set)、并发可达性分析手段、以及安全点(Safepoint)。这三个东西不是孤立的,它们串起来正好回答了三个问题:跨代引用怎么高效追踪、并发标记怎么保证不漏标、以及GC什么时候能安全地开始。
如果你正在准备中高级Java面试,或者线上确实遇到过GC停顿、并发标记异常、跨代引用导致的效率问题,那这篇内容会比较对胃口。我会尽量把HotSpot里的实现逻辑讲清楚,同时补充一些实际调优和排查时用得上的经验,而不是停留在“分代收集”“可达性分析”这种教科书层面的描述。
先说结论性的认知:记忆集解决的是“不用扫描整个老年代就能找到指向新生代的引用”这个问题;并发可达性分析解决的是“用户线程还在跑,怎么保证标记不遗漏”这个问题;安全点解决的是“什么时候停下来做GC最安全、代价最小”这个问题。三者配合,才让HotSpot在吞吐和停顿之间找到一个可接受的平衡。
下面我会按这个逻辑线,一层一层拆开讲。
2. 记忆集与卡表:跨代引用追踪的工程实现
2.1 跨代引用为什么不能靠全量扫描
分代收集的基本假设是“大多数对象朝生夕灭”,所以新生代用复制算法、老年代用标记-清除或标记-整理,各自独立回收。但这里有个绕不开的问题:如果新生代对象被老年代对象引用了,那在Minor GC时,这个新生代对象就不能被回收。问题是,Minor GC只扫描新生代,它怎么知道老年代里有没有对象引用新生代?
最笨的办法是每次Minor GC都把整个老年代扫一遍,找到所有指向新生代的引用。但老年代通常比新生代大得多,全量扫描的代价会让Minor GC失去“快”的意义。HotSpot的解法是记忆集:在新生代维护一个数据结构,记录所有“老年代指向新生代”的引用位置。这样Minor GC时只需要扫描记忆集,不用碰整个老年代。
这个思路听起来简单,但工程实现上有很多取舍。记忆集的精度、存储成本、维护成本,三者是互相制约的。HotSpot里最常用的实现是卡表(Card Table),它是一种粗粒度的记忆集。
2.2 卡表的工作机制与写屏障
卡表的基本单位是“卡页(Card Page)”,通常一个卡页对应512字节的内存区域。整个老年代被划分成若干卡页,卡表就是一个字节数组,每个元素对应一个卡页。如果某个卡页里存在指向新生代的引用,这个卡页对应的卡表元素就被标记为“脏(dirty)”。
那这个“标记为脏”的动作是谁做的?答案是写屏障(Write Barrier)。当JVM执行引用字段赋值时,比如obj.field = newObj,如果obj在老年代而newObj在新生代,写屏障就会把obj所在卡页对应的卡表元素置脏。这样Minor GC时,只需要扫描卡表中被标记为脏的卡页,就能找到所有可能指向新生代的引用。
写屏障分两种:写前屏障(Pre-Write Barrier)和写后屏障(Post-Write Barrier)。HotSpot的卡表维护主要用写后屏障,因为它在赋值之后执行,逻辑更直接。G1收集器则同时使用了写前和写后屏障,因为G1的SATB(Snapshot-At-The-Beginning)需要写前屏障来记录引用变化。
这里有个实际调优时容易忽略的点:写屏障是有性能开销的。每次引用赋值都要执行额外的判断和可能的卡表更新,虽然HotSpot用了一些优化(比如先判断是否跨代、卡页是否已经脏),但在极端情况下,大量引用赋值的应用会因此产生可观的额外开销。这也是为什么有些高并发写入场景下,G1的吞吐会比Parallel GC略低的原因之一。
2.3 卡表的维护成本与伪共享问题
卡表本身是一个字节数组,多个线程可能同时更新同一个卡表元素。如果两个线程分别修改同一个卡页里的不同对象引用,它们都会尝试把同一个卡表元素置脏。这时候如果直接用普通的写操作,可能会出现伪共享(False Sharing):两个CPU核心的缓存行里都缓存了同一个卡表元素,一个核心修改后,另一个核心的缓存行失效,导致缓存颠簸。
HotSpot对卡表更新的处理是:先读卡表元素,如果已经是脏的,就不重复写;如果是干净的,才写入脏值。这个“先读后写”的逻辑减少了不必要的写操作,但并没有完全消除伪共享。在极端高并发场景下,卡表更新可能成为瓶颈之一。不过实际应用中,这种瓶颈通常不是主要矛盾,因为跨代引用赋值的频率远低于普通对象操作。
另一个值得注意的点是卡表的精度。512字节的卡页意味着,即使一个卡页里只有一个指向新生代的引用,整个卡页都会被扫描。如果老年代对象分布稀疏,卡表扫描可能会扫描很多实际上没有跨代引用的内存区域。这是粗粒度记忆集的固有代价。G1后来引入了更精细的RSet(Remembered Set),每个Region维护自己的RSet,精度更高,但存储和维护成本也更大。
2.4 记忆集在不同收集器里的差异
不同收集器对记忆集的实现差异很大,理解这些差异有助于选型和调优。
| 收集器 | 记忆集实现 | 精度 | 维护方式 | 适用场景 |
|---|---|---|---|---|
| Serial / ParNew | 卡表 | 卡页级 | 写后屏障 | 小堆、客户端 |
| Parallel Scavenge | 卡表 | 卡页级 | 写后屏障 | 吞吐优先、服务端 |
| CMS | 卡表 + 增量更新 | 卡页级 | 写后屏障 | 低停顿、老年代 |
| G1 | RSet(每Region) | 对象级/Region级 | 写前+写后屏障 | 大堆、可预测停顿 |
| ZGC | 染色指针 + 读屏障 | 对象级 | 读屏障 | 超大堆、极低停顿 |
从表里可以看出,越往后的收集器,记忆集的精度越高,但维护成本也越大。G1的RSet需要记录“谁引用了我”,而卡表只记录“哪些区域可能有跨代引用”。这个方向上的演进,本质上是在用空间和写屏障开销换GC时的扫描效率。
实际调优时,如果你用的是G1,可以通过-XX:G1ConcRefinementThreads调整并发优化线程数,这些线程负责处理RSet的更新队列。如果发现RSet更新跟不上,GC停顿会变长,这时候适当增加并发优化线程可能有帮助。但更根本的还是要看应用的对象引用模式,如果跨Region引用特别多,G1的RSet维护成本会很高,这时候可能需要考虑其他收集器。
3. 并发可达性分析:三色标记与漏标问题的解决
3.1 为什么需要并发标记
现代GC的一个核心目标是缩短停顿时间。如果标记阶段完全STW(Stop-The-World),那标记耗时直接等于停顿时间。对于大堆应用,标记可能持续几百毫秒甚至几秒,这是不可接受的。所以HotSpot的CMS和G1都采用了并发标记:标记线程和用户线程同时运行,只有少数关键阶段需要STW。
但并发标记带来一个根本性问题:用户线程在标记过程中还在修改引用关系,可能导致对象漏标。漏标意味着一个本该存活的对象被错误回收,这是致命的正确性问题。HotSpot用三色标记法来描述和解决这个问题。
3.2 三色标记法的基本规则
三色标记法把对象分成三种颜色:
- 白色:尚未被标记的对象,如果标记结束还是白色,就会被回收。
- 灰色:已经被标记,但其引用字段还没扫描完的对象。
- 黑色:已经被标记,且其所有引用字段都已经扫描完的对象。
标记过程从GC Roots开始,把直接可达的对象标灰,然后不断从灰色集合里取对象,扫描它的引用字段,把引用的对象标灰,自己变黑。这个过程持续到灰色集合为空,剩下的白色对象就是不可达的。
在STW场景下,这个逻辑没问题。但并发场景下,用户线程可能在标记过程中修改引用,导致两种破坏条件:
- 插入屏障破坏:一个黑色对象新增了一个指向白色对象的引用,而灰色对象到该白色对象的引用被删除。这样白色对象既不被黑色对象扫描到(黑色已经扫描完),也不在灰色集合里,最终被漏标。
- 删除屏障破坏:一个灰色对象删除了指向白色对象的引用,而黑色对象新增了指向该白色对象的引用。同样导致白色对象漏标。
3.3 增量更新与SATB:两种解决思路
HotSpot针对这两种破坏条件,采用了两种不同的并发标记手段:
增量更新(Incremental Update):CMS使用的方式。核心思想是记录黑色对象新增的引用,在重新标记阶段(Remark)以这些新增引用为根重新扫描。具体实现是写后屏障:当黑色对象新增指向白色对象的引用时,把这个引用记录下来。Remark阶段STW,重新扫描这些记录,保证不漏标。
增量更新的优点是重新标记阶段只需要扫描记录的新增引用,通常比较快。缺点是如果新增引用很多,Remark阶段可能变长。而且增量更新需要写后屏障,对写操作有额外开销。
SATB(Snapshot-At-The-Beginning):G1使用的方式。核心思想是在标记开始时做一个“逻辑快照”,认为快照时刻存活的对象在整个并发标记期间都存活。具体实现是写前屏障:当灰色对象删除指向白色对象的引用时,把被删除的引用记录下来。这样即使引用被删除,白色对象仍然会被当作存活对象处理。
SATB的优点是重新标记阶段只需要处理删除引用时记录的旧引用,而且这些记录是在并发标记过程中逐步产生的,Remark阶段通常比增量更新更稳定。缺点是SATB会产生“浮动垃圾”:在标记期间死亡的对象可能被当作存活对象,需要等到下一次GC才能回收。
| 对比项 | 增量更新(CMS) | SATB(G1) |
|---|---|---|
| 屏障类型 | 写后屏障 | 写前屏障 |
| 记录内容 | 新增引用 | 删除的旧引用 |
| Remark阶段 | 重新扫描新增引用 | 扫描SATB队列 |
| 浮动垃圾 | 较少 | 较多 |
| 适用场景 | 老年代标记 | Region标记 |
3.4 并发标记的实际调优经验
在实际使用CMS或G1时,并发标记阶段有几个容易出问题的地方:
并发模式失败(Concurrent Mode Failure):CMS在并发标记期间,如果老年代被填满,会触发Full GC,导致长时间停顿。解决思路是降低触发阈值(-XX:CMSInitiatingOccupancyFraction),让CMS更早开始。但阈值太低会导致GC过于频繁,需要根据应用的对象分配速率和存活率来调。
Remark阶段过长:如果增量更新记录的新增引用太多,Remark阶段会变长。这时候可以观察-XX:+PrintGCDetails里的Remark耗时,如果明显偏长,可能需要检查应用是否有大量跨代引用赋值。
SATB队列溢出:G1的SATB队列如果满了,会导致并发标记失败,退化成Full GC。可以通过-XX:G1SATBBufferSize调整队列大小,但更根本的是看应用是否有大量引用删除操作。
并发标记线程数:-XX:ConcGCThreads控制并发标记线程数。默认是ParallelGCThreads的1/4左右。如果CPU资源充足,适当增加可以加快标记速度,减少并发模式失败的概率。但线程太多会抢占用户线程的CPU,需要权衡。
4. 安全点:GC什么时候能安全地停下来
4.1 安全点的本质与主动式中断
GC要开始,必须让所有用户线程停下来。但线程不能随便停,比如一个线程正在执行obj.field = newObj这样的赋值操作,如果在这个中间停下来,引用关系可能处于不一致状态。所以HotSpot引入了安全点(Safepoint):线程只有在到达安全点时,才能被暂停。
安全点的位置通常是方法调用、循环回边、异常跳转等“边界”位置。HotSpot采用主动式中断策略:GC线程设置一个标志位,用户线程在运行过程中轮询这个标志位,发现需要GC时,自己主动跑到最近的安全点挂起。
这个轮询动作叫安全点轮询(Safepoint Poll),它被插入到编译后的代码里。轮询本身开销很小,但轮询频率会影响GC的响应速度:轮询太稀疏,线程可能跑很久才到安全点,GC等待时间长;轮询太密集,运行时开销增加。HotSpot的JIT编译器会在合适的位置插入轮询,通常是在方法返回、循环回边等位置。
4.2 安全点与停顿时间的关系
很多人以为GC停顿时间就是GC本身的工作时间,其实到达安全点的时间也是停顿的一部分。如果某个线程长时间不到达安全点,GC就得一直等,这段时间用户线程虽然还在跑,但GC已经准备STW了,整体表现就是停顿变长。
什么情况下线程会长时间不到达安全点?最常见的是大循环。如果一个循环体里没有方法调用,JIT可能不会在循环里插入安全点轮询,导致线程在这个循环里跑很久。HotSpot用**可数循环(Counted Loop)**优化来处理这个问题:对于循环次数可预测的循环,JIT会在循环回边插入安全点轮询。但如果循环次数不可预测,或者循环体太大,可能就不会插入。
实际排查时,如果发现GC停顿里“到达安全点”的时间占比很高,可以检查是否有大循环。解决办法包括:在循环里加方法调用(触发安全点轮询)、拆分大循环、或者用-XX:+UseCountedLoopSafepoints强制在可数循环里插入安全点。
4.3 安全点日志与停顿分析
HotSpot提供了安全点日志来帮助分析停顿:
-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1 -XX:+PrintGCApplicationStoppedTime-XX:+PrintGCApplicationStoppedTime会打印每次STW的总时间,包括到达安全点的时间、GC本身的时间、以及离开安全点的时间。如果发现总STW时间远大于GC日志里的GC时间,那多出来的部分很可能就是到达安全点的时间。
-XX:+PrintSafepointStatistics会打印每次安全点的详细信息,包括各阶段耗时、等待的线程数等。这个日志比较底层,但排查安全点相关问题时很有用。
4.4 安全点与偏向锁、类加载的交互
安全点不只用于GC,还用于其他需要STW的操作,比如偏向锁撤销、类加载等。偏向锁撤销需要在安全点进行,因为要修改对象头。如果应用大量使用偏向锁,且锁竞争激烈,偏向锁撤销会频繁触发安全点,导致额外停顿。
类加载也需要安全点,因为要修改类元数据。如果应用动态生成大量类(比如某些框架的字节码增强),类加载频繁触发安全点,也会影响性能。
这些交互说明,安全点是一个全局机制,GC只是它的主要使用者之一。调优时不能只看GC日志,还要关注其他STW操作的影响。
5. 三者如何配合:一次Minor GC的完整视角
5.1 从触发到结束的流程
把记忆集、并发可达性分析、安全点串起来,看一次Minor GC的完整流程:
- 触发:新生代Eden区满,触发Minor GC。
- 安全点:GC线程请求所有用户线程到达安全点。用户线程轮询安全点标志,主动挂起。
- 根扫描:从GC Roots开始扫描,包括新生代引用、老年代到新生代的引用(通过记忆集)。
- 复制存活对象:把Eden和Survivor里存活的对象复制到新的Survivor区。
- 更新引用:更新所有指向被移动对象的引用,包括记忆集里的跨代引用。
- 恢复:用户线程离开安全点,继续运行。
这个流程里,记忆集的作用是让根扫描不用扫整个老年代;安全点的作用是保证扫描和复制时引用关系一致。如果是CMS或G1的并发标记,还会涉及三色标记和写屏障。
5.2 常见误解与澄清
误解一:记忆集是万能的,有了它就不用管跨代引用了。实际上记忆集是粗粒度的,卡表可能扫描很多无效区域。而且记忆集的维护有开销,写屏障不是免费的。
误解二:并发标记不会漏标。并发标记通过增量更新或SATB来保证不漏标,但这是有代价的:增量更新需要Remark阶段重新扫描,SATB会产生浮动垃圾。没有免费的午餐。
误解三:安全点停顿就是GC停顿。安全点停顿包括到达安全点的时间,这部分可能和GC本身无关。如果到达安全点时间很长,说明线程响应安全点请求慢,需要单独排查。
5.3 实际调优中的取舍
在实际调优中,这三个机制往往需要一起考虑:
- 如果Minor GC频繁且停顿长,先看记忆集扫描是否占了大头。如果是,可能需要调整新生代大小,减少Minor GC频率。
- 如果并发标记阶段出现Concurrent Mode Failure,需要调整触发阈值或增加并发线程。
- 如果STW时间远大于GC时间,需要排查安全点到达时间,看是否有大循环或偏向锁撤销问题。
这些取舍没有标准答案,需要根据应用的对象分配模式、存活率、CPU资源来综合判断。我的经验是,先用GC日志和安全点日志把问题定位到具体阶段,再针对性调整,不要盲目调参数。
6. 几个容易踩的坑与排查思路
6.1 跨代引用导致的记忆集膨胀
如果应用有大量老年代对象引用新生代对象,卡表会频繁变脏,Minor GC时扫描的卡页会很多。这种情况下,Minor GC的耗时可能比预期长很多。排查方法是看GC日志里Minor GC的耗时,如果明显偏长,且新生代对象不多,那可能是记忆集扫描占了大头。
解决办法包括:减少不必要的跨代引用(比如用弱引用、软引用替代强引用)、调整对象晋升策略、或者换用G1(RSet精度更高,但维护成本也高)。
6.2 并发标记期间的浮动垃圾
SATB会产生浮动垃圾,这些垃圾在本次GC不会被回收,要等下一次。如果浮动垃圾太多,可能导致老年代增长过快,触发更频繁的GC。排查方法是看GC日志里老年代回收前后的变化,如果回收量远小于预期,可能有浮动垃圾。
减少浮动垃圾的思路是让并发标记尽快完成,减少标记期间的对象死亡。可以适当增加并发标记线程数,或者调整触发时机。
6.3 安全点轮询导致的延迟
如果应用有大循环,且循环里没有安全点轮询,GC请求安全点时会等待很久。排查方法是看安全点日志里“到达安全点”的时间。如果这个时间很长,检查代码里是否有大循环。
解决办法是在循环里加方法调用,或者用-XX:+UseCountedLoopSafepoints。但要注意,强制插入安全点轮询会增加运行时开销,需要权衡。
6.4 偏向锁撤销与安全点的交互
偏向锁撤销需要安全点,如果应用大量使用偏向锁且竞争激烈,会频繁触发安全点。排查方法是看安全点日志里偏向锁撤销的次数。如果次数很多,可以考虑关闭偏向锁(-XX:-UseBiasedLocking),但关闭后普通锁的性能可能下降,需要测试。
7. 写在最后:一些个人体会
这三个机制——记忆集、并发可达性分析、安全点——是HotSpot GC里比较底层的部分,平时开发可能感知不到,但一旦出问题,往往比较棘手。我的体会是,理解这些机制不是为了背八股,而是为了在排查问题时能有一个清晰的思路:GC停顿长,是记忆集扫描慢、并发标记失败、还是安全点到达慢?有了这个框架,看GC日志和安全点日志时就能有的放矢。
另外,不同收集器对这些机制的实现差异很大,调优时不能把CMS的经验直接套到G1上。比如CMS的增量更新和G1的SATB,在Remark阶段的行为完全不同,调参思路也不一样。实际工作中,我一般会先用默认参数跑一段时间,收集GC日志,分析瓶颈在哪个阶段,再针对性调整。盲目调参往往适得其反。
最后分享一个小技巧:如果怀疑安全点到达时间有问题,可以用-XX:+PrintGCApplicationStoppedTime和-XX:+PrintSafepointStatistics一起看,前者给出总STW时间,后者给出安全点各阶段耗时,两者对比就能定位问题。这个组合在排查“GC时间不长但停顿很长”的问题时特别有用。