☰
JVM调优三块硬骨头:记忆集、并发可达性分析与安全点
2026/10/24 1:35:42 网站建设 项目流程

1. 为什么这三个词是JVM调优的分水岭

很多人啃JVM调优,背了一堆参数,真到线上出问题还是抓瞎。我见过不少同学能把G1的MaxGCPauseMillis说得头头是道,但一问“跨代引用怎么记录”“并发标记时用户线程改了对象图怎么办”“为什么STW总停在你没预料的地方”,就答不上来了。这三个问题恰好对应标题里的三块硬骨头:记忆集、并发可达性分析手段、安全点。它们不是孤立的八股知识点,而是HotSpot把“垃圾回收”从理论变成工程的三根支柱。

先把定位说清楚。记忆集解决的是“分代回收时,老年代指向新生代的引用怎么快速找到”的问题,没有它,每次Young GC都得扫全堆,分代回收直接失去意义。并发可达性分析手段解决的是“标记阶段能不能不停用户线程”的问题,三色标记加增量更新或原始快照,让GC线程和用户线程能同时跑。安全点解决的是“什么时候停下来做那些必须暂停的事”的问题,它决定了STW的实际时长和分布。这三者串起来,就是HotSpot GC从“能回收”到“高效回收”的完整链路。

这篇文章适合谁看?如果你已经写过Java代码,知道堆分新生代老年代,但每次看GC日志都只能看个热闹,那这篇就是给你准备的。我会从设计动机讲到实现细节,再落到你能直接观察和验证的操作上。全程用大白话加类比,代码和参数都给全,看完你至少能做到两件事:一是面试被追问GC细节时能讲出所以然,二是线上遇到GC停顿异常时知道往哪个方向排查。

2. 记忆集:跨代引用的一张“通讯录”

2.1 没有记忆集会怎样:一次全堆扫描的代价

先做个思想实验。假设你用的是最经典的分代收集,新生代用复制算法,老年代用标记整理。Young GC发生时,要判断新生代里的对象是不是垃圾,得做可达性分析。根对象包括栈上的局部变量、静态变量等,这些好办。但还有一类根:老年代对象里持有对新生代对象的引用。比如一个老年代的大缓存对象,它的某个字段指向了一个刚new出来的小对象。

问题来了:你怎么知道老年代里哪些对象引用了新生代?最笨的办法是把整个老年代扫一遍,找出所有指向新生代的引用,把它们也当作GC Roots。这个方案逻辑上没问题,但代价是每次Young GC都要遍历老年代全部对象。老年代动辄几个G,Young GC本来只该花几十毫秒,结果光扫老年代就几百毫秒,分代回收的收益直接被吃光。

记忆集就是来解决这个问题的。它的本质是一张“通讯录”:老年代里哪些区域存在指向新生代的引用,我提前记下来。Young GC时只查这张通讯录,不用扫全堆。这个思路和现实中查电话簿一样,你要找某个人的号码,不会把整本电话簿从头翻到尾,而是先看目录定位到姓氏,再翻那几页。

2.2 卡表:记忆集最落地的实现形式

记忆集是抽象概念,HotSpot里最常用的实现叫卡表。把整个堆按固定大小切成一格一格,每一格叫一个卡页,通常是512字节。所有卡页对应一个字节数组,叫卡表。数组里每个元素叫卡页的“卡页标记”,或者直接叫卡表元素。

当某个卡页里的对象引用了另一个卡页里的对象,而且这两个卡页分属不同代(比如老年代卡页引用了新生代卡页),就把这个卡页在卡表里对应的字节标记为“脏”。标记动作由写屏障完成。写屏障你可以理解成JVM在“引用赋值”这个动作前后插入的一小段代码,类似AOP切面。HotSpot用的是写后屏障,也就是赋值完成后再更新卡表。

// 伪代码示意写屏障的逻辑 void oop_field_store(oop* field, oop new_value) { *field = new_value; // 真正的赋值 // 写后屏障:如果跨代引用,标记卡页为脏 if (cross_generation(field, new_value)) { card_table[card_index(field)] = dirty; } }

Young GC时,只需要扫描卡表里被标记为脏的那些卡页,把这些卡页里的对象加入GC Roots,就完成了跨代引用的收集。扫描范围从“整个老年代”缩小到“少数脏卡页”,这就是记忆集带来的数量级提升。

2.3 卡页精度与伪共享:一个容易被忽略的坑

卡页大小512字节不是随便定的。卡页越小,记忆集越精确,扫描的垃圾越少,但卡表本身占的内存越大,写屏障触发也越频繁。卡页越大,卡表省内存,但一次脏标记可能覆盖很多对象,扫描时会有大量“误伤”——卡页里其实只有一个对象引用了新生代,但整个卡页512字节里的对象都得被扫。

更隐蔽的问题是伪共享。卡表是一个字节数组,多个线程可能同时更新相邻的卡表元素。如果两个卡表元素落在同一个CPU缓存行里(通常64字节),一个线程改了元素A,会导致另一个线程缓存里的元素B失效,被迫重新从内存加载。这就是伪共享,它会让写屏障的性能莫名其妙地下降。HotSpot在卡表实现里做了处理,比如用-XX:+UseCondCardMark让线程先判断卡页是否已经是脏的,是脏的就不重复写,减少缓存行争用。这个参数在高并发写入场景下值得关注。

注意:卡表标记的是“卡页脏”,不是“对象脏”。一个卡页被标记后,即使里面引用新生代的对象已经被回收,卡页标记也不会自动清除,要等下次Young GC扫描后统一清理。所以脏卡页数量会在一段时间内只增不减,这是正常现象。

2.4 不同收集器的记忆集差异

G1的记忆集比传统卡表复杂得多。G1把堆分成大小相等的Region,每个Region都有一份记忆集,记录“谁引用了我”。这叫RSet,是“Remembered Set”的缩写。RSet的实现有稀疏表、细粒度位图、粗粒度位图三种模式,根据引用密度自动切换。稀疏表适合引用很少的情况,每个引用记录一条;位图适合引用密集的情况,用位标记哪些卡页有引用。

为什么G1要这么设计?因为G1的回收单位是Region,不是整个新生代或老年代。回收某个Region前,必须知道有哪些其他Region引用了它,才能把这些引用当作根。如果还用全局卡表,就得扫描所有脏卡页,无法只针对目标Region。RSet让G1能精确地只处理与目标Region相关的跨Region引用。

ZGC和Shenandoah走的是另一条路,它们用染色指针和读屏障,记忆集的角色被弱化甚至取消。这是后话,但理解传统记忆集是理解这些新收集器的前提。

3. 并发可达性分析:让标记和用户线程一起跑

3.1 三色标记:把对象分成三种状态

可达性分析要标记出所有存活对象。如果全程STW,那简单,从GC Roots开始遍历对象图,遍历到的标记存活,没遍历到的就是垃圾。但STW意味着用户线程全停,堆越大停得越久。并发标记的目标是让GC线程和用户线程同时跑,这就需要一个能描述“标记进行到哪了”的模型。三色标记就是这个模型。

白色:还没被GC访问到的对象。黑色:自己和自己的所有引用都已经被扫描完的对象。灰色:自己被扫描到了,但引用的对象还没扫描完。标记开始时,GC Roots直接引用的对象变灰,其他全白。GC线程从灰色集合里取对象,把它变黑,把它引用的白色对象变灰。重复这个过程,直到灰色集合为空。此时黑色是存活对象,白色是垃圾。

如果用户线程不动,这个流程和STW标记没区别。但用户线程在跑,它可能修改引用关系,导致两种错误:浮动垃圾和对象消失。浮动垃圾是本来该回收的对象被误标为存活,这个可以容忍,下次GC再收。对象消失是本来存活的对象被误标为垃圾,这个绝对不能容忍,会导致程序崩溃。

3.2 对象消失的两个条件与两种破解手段

对象消失要同时满足两个条件。条件一:一个灰色对象到某个白色对象的引用被删除。条件二:同时,黑色对象新增了到那个白色对象的引用。两个条件同时发生,白色对象就既不在灰色集合里,也不在黑色对象的引用链上,被当成垃圾回收。

破解手段有两种,对应两种不同的“屏障”策略。

增量更新:破坏条件二。当黑色对象新增指向白色对象的引用时,把这个黑色对象重新变灰,让它下次被重新扫描。这样白色对象就能通过这个黑色对象被找到。CMS用的是增量更新。

原始快照:破坏条件一。当灰色对象删除指向白色对象的引用时,把这条被删除的引用记录下来,并发标记结束后,以这些记录为根重新扫描。相当于按“删除发生前”的对象图快照来标记。G1和Shenandoah用的是原始快照。

// 增量更新写屏障伪代码 void oop_field_store(oop* field, oop new_value) { if (is_black(current_object) && is_white(new_value)) { mark_gray(current_object); // 重新变灰 } *field = new_value; } // 原始快照写屏障伪代码 void oop_field_store(oop* field, oop new_value) { oop old_value = *field; if (is_gray(current_object) && is_white(old_value)) { record_deleted_reference(old_value); // 记录旧引用 } *field = new_value; }

两种手段各有取舍。增量更新会让黑色对象重新变灰,增加重新扫描的工作量,但浮动垃圾少。原始快照不打断标记进度,但会产生更多浮动垃圾,因为被删除引用的对象即使已经不可达,也会被标记为存活。G1选择原始快照,是因为它更看重标记的连续性,浮动垃圾可以在下次Mixed GC时处理。

3.3 并发标记的完整阶段拆解

以G1为例,并发标记周期分几个阶段。初始标记是STW的,只标记GC Roots直接引用的对象,很快。并发标记和用户线程一起跑,遍历整个对象图,耗时最长。最终标记是STW的,处理并发标记期间产生的引用变更,也就是处理原始快照记录的那些删除引用。筛选回收也是STW的,根据标记结果选择回收价值最高的Region进行回收。

这里有个关键点:并发标记阶段用户线程还在分配新对象。新分配的对象默认标记为黑色,因为它们在标记开始后才出现,不可能被之前的引用链引用到,直接当存活处理。如果新对象引用了白色对象,写屏障会处理。这个设计保证了并发标记的正确性。

提示:并发标记期间如果用户线程分配速度太快,可能导致并发标记还没结束,堆就满了。这时会触发一次Full GC,把并发标记中断。所以生产环境要关注并发标记的耗时和堆的分配速率,必要时调整InitiatingHeapOccupancyPercent提前触发并发标记。

3.4 并发标记的“漏标”排查思路

线上如果出现“对象被错误回收”导致的崩溃,排查方向通常是并发标记的屏障逻辑。但HotSpot的屏障是JVM内部实现的,普通开发者改不了。你能做的是确认是否触发了已知的边界情况。比如CMS的增量更新在并发标记期间如果发生Concurrent Mode Failure,会退化成串行Full GC,此时标记逻辑切换,理论上不会漏标。但如果JVM版本有bug,就可能出问题。

更实际的排查是看GC日志里有没有异常的concurrent mode failure或to-space exhausted。这些日志说明并发标记没能在堆满之前完成,GC退化成了STW模式。虽然不会漏标,但停顿时间会暴涨。解决办法是提前触发并发标记,或者增大堆,或者降低对象分配速率。

4. 安全点:STW到底停在哪

4.1 安全点的本质:主动挂起而非被动中断

STW不是想停就能停的。用户线程正在执行字节码,你不可能在任意一条指令处把它暂停,因为此时它的栈帧、寄存器状态可能处于不一致的状态。安全点的思路是:在代码的特定位置插入检查点,线程执行到这些位置时,检查“是否需要暂停”,如果需要就主动挂起。这些特定位置就是安全点。

哪些位置是安全点?方法调用、循环回边、异常跳转等。这些位置的共同特点是:线程的状态是明确的,栈帧信息完整,可以安全地暂停和恢复。方法调用前后是安全点,因为调用边界清晰;循环回边是安全点,因为循环体内的状态在回边处是确定的。

安全点不是“暂停指令”,而是“检查指令”。线程执行到安全点时,读一个全局标志位,如果标志位说“要STW”,线程就自己挂起,并通知VM线程“我已到达安全点”。VM线程收集到所有线程的确认后,才真正开始STW操作。

4.2 安全点与安全区域的区别

安全点解决的是“运行中的线程怎么停”的问题。但有些线程不在运行,比如处于Sleep状态、等待锁状态、或者刚启动还没初始化完。这些线程无法执行安全点检查,但它们的状态是安全的,不会修改对象图。安全区域就是为这些线程设计的。

安全区域是一段代码区间,在这个区间内,引用关系不会发生变化。线程进入安全区域时标记自己“在安全区域内”,此时GC可以不管它。线程离开安全区域前,要检查GC是否已经完成,如果没完成就等待。安全区域可以看作“扩展版的安全点”,覆盖了那些无法执行检查的线程状态。

4.3 安全点停顿时间的组成与优化

一次STW的停顿时间由三部分组成:到达安全点的时间、GC操作本身的时间、恢复线程的时间。很多人只关注第二部分,但第一部分经常是停顿时间的大头。

到达安全点的时间取决于“最慢的那个线程多久能跑到安全点”。如果一个线程正在执行一个超长的循环,循环体内没有方法调用,也没有回边安全点,那它可能很久都到不了安全点。JVM有个-XX:+UseCountedLoopSafepoints参数,可以给可数循环也插入安全点,避免这种“安全点饥饿”。

# 查看安全点相关日志 -XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1 -XX:+UnlockDiagnosticVMOptions -XX:+PrintSafepointStatisticsTimeout=1000

这些参数会输出每次安全点停顿的详细统计,包括各阶段耗时、到达安全点的线程数、VM操作类型等。线上排查STW异常时,这是第一手资料。

注意:PrintSafepointStatistics在高版本JDK中可能需要配合-Xlog:safepoint使用。不同JDK版本日志参数有差异,建议先查对应版本的文档。

4.4 安全点日志的实战解读

安全点日志里几个关键字段。vmop表示这次安全点要做什么操作,比如GenCollectForAllocation是Young GC,G1IncCollectionPause是G1的Young GC,RevokeBias是撤销偏向锁。total是总停顿时间,initial是到达安全点的时间,sync是各线程同步的时间,cleanup是清理时间,vmop是VM操作本身的时间。

如果initial很大,说明有线程迟迟到不了安全点,要检查是否有大循环或长时间不调用方法的代码。如果vmop很大,说明GC操作本身慢,要检查堆大小、对象存活率、GC算法选择。如果sync很大,说明线程间同步开销大,可能是线程数太多或者锁竞争严重。

我遇到过一个案例,某服务Young GC的vmop只有20毫秒,但initial有200毫秒,总停顿220毫秒。排查发现是一个定时任务在跑一个巨大的for循环,循环体内没有方法调用,JIT编译后回边安全点被优化掉了。加上-XX:+UseCountedLoopSafepoints后,initial降到10毫秒以内。

5. 三者的联动:一次Young GC的完整旅程

5.1 从触发到结束的时序拆解

把记忆集、并发标记、安全点串起来看一次Young GC。假设G1触发Young GC。首先,VM线程发起安全点请求,设置全局标志位。各用户线程执行到安全点时检查标志位,主动挂起。VM线程等待所有线程到达安全点,这个等待时间就是initial。

到达安全点后,开始Young GC。G1的Young GC是STW的,不需要并发标记。它扫描Young Region和Survivor Region,同时扫描这些Region的RSet,找出老年代指向它们的引用。RSet就是记忆集在G1里的实现。扫描到的跨代引用加入GC Roots,然后做可达性分析,把存活对象复制到新的Survivor Region或晋升到Old Region。

复制完成后,清理原来的Young Region,更新RSet,然后恢复用户线程。整个过程里,记忆集决定了扫描范围,安全点决定了停顿的起点和终点。并发标记不参与Young GC,但并发标记周期里的最终标记和筛选回收也是STW的,同样依赖安全点。

5.2 参数配置与观察方法

想观察这三者的实际行为,需要打开对应的日志。GC日志用-Xlog:gc*,安全点日志用-Xlog:safepoint,RSet相关的统计在G1的日志里会有体现。

# 综合日志配置示例 -Xlog:gc*,gc+heap=info,gc+ergo=info,safepoint=info:file=gc.log:time,uptime,level,tags

GC日志里关注Pause Young的耗时,以及Evacuation阶段的耗时。如果Evacuation耗时长,可能是RSet扫描慢,说明跨代引用多,或者RSet本身太大。安全点日志里关注initial和vmop的占比。如果initial占比高,优化安全点;如果vmop占比高,优化GC本身。

5.3 常见误区与纠正

第一个误区:以为记忆集是“记录所有跨代引用”。实际上卡表记录的是“脏卡页”,一个脏卡页里可能有多个跨代引用,也可能只有一个。扫描脏卡页时,卡页里所有对象都会被检查,不管它们是否真的引用了新生代。这是精度和性能的权衡。

第二个误区:以为并发标记完全不STW。并发标记的初始标记和最终标记都是STW的,只是时间很短。真正并发的是标记遍历阶段。所以“并发标记”不等于“全程无停顿”。

第三个误区:以为安全点越密越好。安全点太密会增加运行时开销,每次检查都要读标志位,JIT编译时也要考虑安全点插入。安全点的密度是性能和停顿时间的平衡,不是越密越好。

6. 实操排查:从日志到代码的定位路径

6.1 停顿时间异常的排查清单

线上遇到GC停顿异常,按这个顺序排查。第一步,看GC日志确认停顿类型,是Young GC、Mixed GC还是Full GC。第二步,看安全点日志确认initial和vmop的占比。第三步,如果initial大,检查代码里有没有大循环、长时间不调用方法的计算逻辑。第四步,如果vmop大,检查堆大小、存活对象大小、RSet大小。第五步,如果RSet大,检查是否有大量跨代引用,比如老年代缓存持有大量新生代对象。

现象可能原因排查手段解决方向
initial占比高线程到达安全点慢安全点日志、代码审查加UseCountedLoopSafepoints、拆分大循环
vmop占比高GC操作本身慢GC日志、堆dump调堆大小、换GC算法、减少对象分配
RSet扫描慢跨代引用多G1日志、RSet统计减少老年代持有新生代引用
并发标记超时分配速率快GC日志、分配速率监控提前触发并发标记、增大堆

6.2 一个模拟排查案例

假设某服务GC日志显示Young GC平均停顿80毫秒,但偶尔飙到500毫秒。安全点日志显示飙高时initial有400毫秒。代码审查发现一个批量处理任务,在一个大循环里做纯计算,循环体内没有方法调用。JIT把回边安全点优化掉了,导致线程长时间到不了安全点。

解决办法有两个。一是加-XX:+UseCountedLoopSafepoints,强制可数循环也插入安全点。二是重构代码,把大循环拆成多个小方法调用,方法调用本身就是安全点。实测加参数后,initial从400毫秒降到20毫秒以内,总停顿回到100毫秒以下。

6.3 工具与参数速查

# 安全点统计 -XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1 # 可数循环安全点 -XX:+UseCountedLoopSafepoints # G1 RSet相关 -XX:+UnlockDiagnosticVMOptions -XX:+G1PrintRegionLivenessInfo # GC日志详细 -Xlog:gc*,gc+phases=debug,gc+ergo=debug,safepoint=debug

这些参数不是让你全开,而是根据排查方向选择性开启。全开日志量巨大,生产环境慎用。建议先在预发环境复现问题,再决定开哪些日志。

7. 我踩过的坑与几条实在建议

第一个坑:以为调大堆就能解决所有GC问题。堆大了,单次GC的扫描范围变大,停顿可能反而更长。记忆集和RSet的规模也随堆增长,扫描开销增加。堆大小要和停顿目标匹配,不是越大越好。

第二个坑:忽略安全点日志。很多人只看GC日志,不看安全点日志。但GC日志里的停顿时间包含了到达安全点的时间,如果不拆开看,你根本不知道时间花在哪。安全点日志是GC排查的X光片。

第三个坑:在低版本JDK上用过时的参数。比如-XX:+PrintGCDetails在JDK 9以后被-Xlog:gc*取代,-XX:+PrintSafepointStatistics在不同版本行为也有差异。排查前先确认JDK版本,查对应版本的参数文档。

几条实在建议。第一,生产环境至少开-Xlog:gc*:file=gc.log:time,uptime:filecount=5,filesize=50M,保留最近几次GC日志,出问题时有据可查。第二,安全点日志按需开启,不要长期开着,日志量太大。第三,理解记忆集和RSet的原理,比背参数有用,因为参数是死的,场景是活的。第四,遇到停顿异常先分清楚是initial大还是vmop大,方向对了再深入。

最后分享一个小技巧。如果你怀疑某个方法导致安全点饥饿,可以用-XX:+UnlockDiagnosticVMOptions -XX:+PrintSafepointStatistics配合jstack在停顿发生时抓线程栈,看哪个线程还没到安全点。多抓几次,如果总是同一个方法,那就是它的问题。这个方法比看日志更直接,适合快速定位。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询