如果你最近在啃 JVM,多半会和这几个词撞个满怀:OopMap、安全点、记忆集、卡表。网上一篇文章只讲其中一个术语的情况很多,但这四样东西其实是一整套配合关系,拆开看容易越看越乱。我最早琢磨 HotSpot 源码和 GC 日志时,最大的困惑不是“词不认识”,而是想不明白:JVM 要找到从栈上出发的引用,为什么不直接把线程栈翻一遍?为什么还要搞出“安全点”这种额外机制?老年代对象引用新生代对象,跨代引用又是怎么被发现并作为 GC Roots 的?
这篇文章我准备把这几个概念放在一条主线上讲清楚:GC 到底需要什么信息、信息从哪里来、什么时机拿最安全、跨代引用怎么记录。你可以把它当成一份底层的系统笔记来看,也可以直接跳到某一段解决眼下的问题。我会尽量用实际场景说话,不堆概念。
1. 先想明白:GC Roots 扫描为什么不能“裸着来”
1.1 一个方法里到底哪些数据是引用
很多人刚开始理解可达性分析时,脑子里有个很朴素的画面:从栈上的局部变量出发,一层层找对象。但真到 JVM 内部,机器码里根本没有“局部变量”这个概念,只有寄存器和内存栈槽。同样一个 64 位槽位,可能存放着一个对象的地址,也可能存着一个 int,还可能只是某个中间计算结果。
比如说你写了一个方法,里面有一个 User 对象引用和一个 long 数字。JIT 编译后,这两个值可能都出现在寄存器里,也可能被压到栈帧的不同偏移位置。GC 要去扫描栈上的引用,首先得知道:这个位置上的二进制数据到底是不是一个 oop(ordinary object pointer,普通对象指针)。
如果你让 GC 什么都不管,把所有看起来像地址的值都当成引用去访问对象,后果很严重。一是会把根本不是对象的“假地址”当成对象,轻则访问到错误数据,重则直接崩溃;二是即使侥幸不死,这种“保守式扫描”会保留大量本可以回收的对象,堆里全是垃圾。所以 JVM 需要一份“地图”,明确告诉 GC 在某个执行位置,哪些寄存器、哪些栈槽才是真正的对象引用。这份地图,就是 OopMap 要解决的问题。
1.2 四个概念如何分工配合
如果你把一次完整的局部 GC 拆开看,会发现 JVM 主要干两件事:先找根,再沿着根往下扫。找根这件事依赖 OopMap 和安全点。OopMap 负责回答“哪些位置是引用”,安全点负责回答“在哪个位置可以停下来收集这些信息”。
而堆里对象之间的引用关系还需要跨区考虑。新生代对象可能被老年代对象引用,Minor GC 时不能把整个老年代扫一遍,这时候就需要记忆集来记录“哪些老年代区域存在指向新生代的引用”,卡表则是实现记忆集最常见的一种具体数据结构。
所以这四个概念不是并列的四门功课,而是两条线:OopMap 和安全点解决“根从哪来”,记忆集和卡表解决“跨代引用从哪来”。把这个关系搞清楚,后面看任何收集器的细节,思路都会顺很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. OopMap:HotSpot 如何提前“剧透”对象引用位置
2.1 从字节码到机器码,为什么只有编译器能画出这张图
Java 源码编译成字节码后,每个方法的结构还是很完整的。局部变量表里哪个槽对应什么类型的变量,操作数栈里哪些位置是引用,这些从字节码内容都能看出来。解释器执行时,虚拟机天然清楚这些信息,所以解释执行时做 GC Roots 枚举并不难。
问题发生在 JIT 即时编译之后。C2、C1 这类优化编译器会把字节码转换成高度优化的机器码,局部变量、操作数栈、临时量被分配到寄存器或者栈槽里。一个原本存在于 Java 层面的引用,经过逃逸分析后可能根本不再存在;也可能一个原本的 int 被复用成了引用;甚至两个变量共用一个栈槽,只有其中一个还在活跃范围。
这些细节只有编译器自己清楚。所以 HotSpot 的做法是:在 JIT 编译过程里同步生成每个安全点对应的 OopMap。这份地图精确记录当前执行到这条指令时,哪些寄存器和栈偏移位置存放着 oop。GC 一旦在安全点暂停了线程,直接查对应指令地址的 OopMap 就行,不需要再去理解当前方法内部逻辑。
你可以把 OopMap 理解成电影拍摄现场的分镜表。摄像师(GC)不需要知道演员下一秒会走到哪,只需要在导演(安全点)喊“卡”的那一刻,拿出当前分镜,就知道所有人应该站在什么位置。
2.2 JIT 编译时的 OopMap 生成过程
HotSpot 的 OopMap 不是编译完成后一次性生成的,而是在编译过程中同步收集。编译器在生成机器指令的同时,会维护一个“当前哪些寄存器是活跃引用”的集合。每当遇到一个可能触发 GC 的指令位置,也就是后面要讲的安全点位置,它就把当前集合快照下来,登记到该指令地址对应的 OopMap 上。
这样 OopMap 里通常记录的内容很具体:
| OopMap 中的信息 | 含义 | GC 时的作用 |
|---|---|---|
| 寄存器编号 | 某个 CPU 寄存器中保存了 oop | 直接读取该寄存器作为根对象 |
| 栈偏移量 | 当前栈帧中某一偏移槽位保存了 oop | 读取该位置作为根对象 |
| 派生的 oop | 一个 oop 加上偏移后得到的地址 | 用于处理数组元素、字段访问等场景 |
这里有一个容易忽略的点:OopMap 并不是每个方法只保存一份。一个方法经过编译后可能有很多安全点,它们对应的引用活跃情况不一样。如果你把每个安全点都完整存一份 OopMap,空间开销会非常可观。HotSpot 在具体实现中会做压缩和共享,多个安全点之间可以共用相同的映射结构,这也是为什么它能在“精确记录”和“空间开销”之间做到平衡。
2.3 为什么不能每条指令都记录
看到这里你可能会想:既然 OopMap 这么好用,那干脆每条机器指令都配一张地图,GC 随时都可以停,不是更简单吗?
理论上可以,实际上没人这么做。原因很现实:记录 OopMap 是有额外成本的。每条指令都生成一份快照级别的地图,编译时间会明显上升,编译后的元数据体积也会膨胀,最终影响 JIT 缓存和类卸载效率。更糟的是,GC 不是每秒发生一次,如果为了“随时可暂停”而让所有代码都背着沉重的元数据,那对所有线程的正常运行都是一笔巨大的隐性开销。
所以 JVM 不会追求“任意指令都能停”。它会在执行流的少数关键位置设置安全点,只在这些位置上准备 OopMap。这就是我们现在必须引入安全点的根本原因:地图再好,也得挑合适的时机打开。
3. 安全点:让所有线程停在一个“能交代”的位置
3.1 安全点选点:不是随便挑一条指令
安全点的选择原则,现在很多资料里都会提到两个字:长期。所谓长期,是指指令的执行时间可能会很长,GC 如果不在这种位置设置暂停点,线程可能长时间不响应。
具体到 HotSpot 里,最常见的安全点位置包括:方法调用指令、循环回边指令、异常跳转指令等。方法调用前停一下,是因为调用点本身就意味着当前栈帧进入了相对明确的边界;循环回边处停一下,是因为循环可能执行成千上万次,GC 等不起;异常跳转处类似,异常路径往往不常用但可能长时间不返回。
基于这个原则,你也能理解另一个现象:为什么你在代码里写一个死循环,往往会让 GC 的停顿时间升高,而写一个普通循环却不明显。如果 JIT 编译后的循环里没有方法调用,但频繁回跳,那它自己就是一个“长期执行点”。HotSpot 为了保证线程能进入安全点,通常会保证每个需要轮转的循环回边具备安全点能力,具体机制不同版本还有差异,但思路都是:绝不能让线程无限运行而不经过安全点。
3.2 线程怎么知道该停:主动式中断与抢占式中断
知道安全点设在哪还不够,线程还得“乖乖停”。老一代虚拟机和一部分早期实现采用抢占式中断:先让所有线程都中断,线程跑到安全点再自己停下来。这种方式问题很明显,线程不是随时能处理中断,而且频繁打断所有线程的成本高、不精准。
现代 HotSpot 几乎都使用主动式中断。在需要 STW 时,VM 线程设置一个全局的安全点请求标志。每个 Java 线程在执行到安全点的时候,都会主动检查这个标志。如果发现 VM 线程正在等待安全点,当前线程就把自己挂起,进入安全点状态。
翻译成人话:GC 不是靠“强制 STOP”把所有线程扳停的,而是在门口竖了一块“前方暂停”的牌子,每个线程路过安全点的时候看一眼牌子,然后主动停下来。这样每个线程都停在信息完整的位置,OopMap 才能派上用场。
3.3 安全区域:线程进不了安全点怎么办
主动式中断有个最基本的假设:线程会持续执行 Java 代码,并且能得到 CPU 调度。但如果线程正处在 Thread.sleep、Object.wait、LockSupport.park 这类状态呢?它没有在跑指令,自然不存在“跑到安全点”这个过程。如果 GC 一直等它,停顿就无限拉长了。
这时候就轮到安全区域出场。安全区域是指一段代码中,引用关系不会发生变化的区域。线程进入这类区域前,会先标记自己进入了安全区域。GC 要 STW 时,看到这个线程已经处于安全区域,就直接认为它已经安全了,不必等它去执行安全点检查。
在线程要离开安全区域时,它会先检查全局安全点请求。如果 VM 线程正在等待安全点,那它就必须等安全点操作结束后再离开。本质上,安全区域是把“主动检查”从“执行到某条指令”扩展到了“从睡眠/等待中醒来”这一刻。这也是很多 GC 停顿分析里,你会发现大量线程处在 Blocked 或 Sleeping 状态也不会影响进入安全点的原因。
3.4 安全点日志怎么打开与怎么看
排查 STW 问题时,不能光盯着 GC 日志里的 pause 时间,还要看安全点本身耗时。HotSpot 提供了一组诊断参数,可以打印安全点统计:
bash复制-XX:+PrintSafepointStatistics
-XX:+PrintSafepointStatisticsCount=1
开启后,JVM 会在每次安全点操作结束时输出一行统计,里面包括 VM operation 类型、当前线程数、初始运行线程数、等待线程数,以及各个阶段耗时。常见字段含义大致如下:
| 字段 | 含义 |
|---|---|
| vmop | 触发安全点的 VM 操作,比如 GC、偏向锁撤销、JVMTI 等 |
| total | 安全点总耗时 |
| spin | 等待线程主动到达安全点的时间 |
| block | 线程被阻塞后等待的时间 |
| sync | 同步阶段耗时,通常也是安全点开销的一部分 |
| cleanup | 清理阶段耗时 |
我之前排查过一次诡异的高暂停,GC 日志里 Young GC 本身只有 30 多毫秒,但应用感受到的停顿却有 300 多毫秒。打开安全点日志后才发现,触发源根本不是常规 GC,而是某个频繁的偏向锁撤销和 RevokeBias 操作,导致安全点被反复触发。所以各位在分析 STW 问题时,别只盯着 G1EvacuationPause 或者 ParNew,先看一眼安全点日志,往往能省很多时间。
4. 记忆集:解决“跨区域引用”的台账
4.1 为什么 Minor GC 不能顺着老年代把所有对象扫一遍
分代收集带来的一个经典问题是:新生代里有一个对象要被回收,但老年代里正好有一个对象引用着它。如果只扫描新生代的存活对象,很容易把这个本该存活的对象当成垃圾回收掉。
那能不能每次 Minor GC 都扫描整个老年代来找这种跨代引用呢?老年代动辄几个 GB,甚至几十 GB,为了回收一小块新生代去扫描整个老年代,代价完全不成比例。所以收集器需要一种机制,提前记录好“哪些老年代区域可能存在指向新生代的引用”,等到 Minor GC 时,只要把这些区域里的对象当成额外的根去遍历一遍,就能精准找到所有老年代到新生代的引用。
这个机制就是记忆集。用大白话说,记忆集是一本台账,专门登记“外部区域对当前收集区域”的引用可能出现在哪些地方。它面向的问题是跨代引用,而不是某个具体对象内部的结构。
4.2 记忆集的三层精度:字长、对象、卡
记忆集本身是一个抽象概念,它可以有不同的实现精度。数据结构里记录的粒度越小,GC 时需要扫描的范围就越小,但维护成本越高;反过来,记录粒度越大,GC 扫描范围越大,但写屏障的维护成本越低。
| 精度 | 描述 | 优点 | 缺点 |
|---|---|---|---|
| 字长精度 | 精确到某个机器字的位置 | 扫描精度最高,几乎不用额外过滤 | 记录数量多,空间和维护开销大 |
| 对象精度 | 精确到某个对象 | 比字长精度更省记录项 | 仍然需要确定对象内部引用字段的位置 |
| 卡精度 | 精确到某一块内存区域 | 实现简单,写入成本低 | GC 时需要扫描整块区域做二次过滤 |
对象精度最容易理解,就是“老年代里某个对象引用了新生代”。卡精度则粗暴一些,把老年代内存按固定大小分成很多“卡”,只要某张卡里存在一个跨代引用,就把这张卡标记为脏。GC 时只需要扫描所有脏卡区域里的对象,再找出真正指向新生代的引用。
这里顺便说一句,很多人会把“记忆集”和“卡表”当成同一个东西,这其实不太严谨。记忆集是抽象数据结构,负责解决“跨区域引用怎么记录”的问题;卡表只是实现记忆集的最常见手段。你可以说“HotSpot 用卡表实现了卡精度的记忆集”,但不能说“记忆集就是卡表”,毕竟 G1 里还有基于卡表之上实现的全新 RSet(Remembered Set),处理粒度比传统卡表更复杂。
4.3 跨代引用是什么时候被记下来的
记忆集最大的难点不是“查”,而是“记”。JVM 不可能没事就去扫描整个老年代,看哪些对象持有了新生代引用。它必须在一个确定的时间点,以最低成本把这个信息记录到台账里。
这个时间点就是引用字段被赋值的时候。当 Java 代码执行类似 oldObj.field = youngObj 这样的操作时,HotSpot 会在赋值动作完成后插入一段额外逻辑,把 oldObj 所在的内存区域在记忆集中标记为脏。这段额外逻辑就是写屏障,它不是 Java 层面的概念,而是类似 AOP 一样在字节码或机器码层面给“引用类型字段赋值”动作加上的后置处理。
你把记忆集想象成小区门口的登记表。快递员(引用写入)每次往小区里送包裹(写入跨代引用),保安(写屏障)都会记一笔“某栋楼可能有新包裹”。等到物业搞检查(Minor GC)时,不需要敲开全小区每一户的门,只需要按登记表上门即可。
G1 和 ZGC 使用的写屏障逻辑会比传统 CMS 更复杂,G1 的 SATB(Snapshot-At-The-Beginning)甚至会在引用变更前也插入屏障,但这些高级收集器仍然没有脱离“在引用赋值点做额外记录”这一基本框架。
5. 卡表与写屏障:一张字节数组如何把脏对象标出来
5.1 卡表的基本结构
传统 HotSpot 收集器里,卡表最简单的实现就是一张字节数组。老年代被划分成许多大小相同的卡页,每张卡页默认 512 字节。这张字节数组下标与卡页一一对应,数组元素的值代表卡页当前状态。
对于 JVM 来说,如果老年代某个对象引用了新生代对象,那这个老年代对象所在的卡页就应该被标记成“脏”,通常就是把对应卡表项的值改成 0。之所以用 0 表示脏,是为了后续计算方便。因为一个卡页地址右移 9 位,就能得到它对应的卡表下标:
bash复制# 卡页大小 512 = 2^9,所以地址右移 9 位得到卡表下标
CARD_TABLE[addr >> 9] = 0;
这里有个细节容易让新手兴奋过头:卡页是 512 字节,不代表里面只有一个对象。一个对象可能跨越多个卡页,一张卡页里也可能装下好几个对象和一堆空闲内存。卡表只能告诉你“这张卡范围里有跨代引用”,但具体是哪个对象产生的,需要 GC 真正扫描这张卡时才能确定。这种粗粒度处理带来的好处就是写入方非常快,只做一次数组写入。
5.2 写屏障:引用赋值后那道隐藏指令
卡表自身不会自动变脏,关键动作发生在写屏障里。HotSpot 在 JIT 编译时,会对“对象字段写入一个引用值”的代码插入写屏障代码。简单点说,每次 obj.field = ref 这样的字节码或编译后代码执行完,都会附带执行一段类似 dirty_card(obj) 的本地逻辑。
在低版本 HotSpot 中,这段写屏障代码的执行是相对直接的:找到引用字段所属对象的内存地址,计算出它所在的卡表下标,把对应元素置为 0。整个过程只需要几条本地指令,理论上开销很小,但经不住高频写入放大。
现代收集器会对写屏障做不少优化。比如用 -XX:+UseCondCardMark 参数可以开启条件卡标记,先判断当前卡是否已经是脏卡,不是才写入,减少无意义的卡表写操作。这类优化在写密集且同一批卡页反复被写入的场景下,能明显改善性能。
顺带提一句,写屏障不只用于传统跨代引用的卡表记录。CMS 的增量更新、G1 的 SATB、ZGC 的读屏障,都是在引用操作的切入点做了额外动作,只是目的不同。理解写屏障这一层抽象,对后面理解任何一款收集器的并发标记阶段都有帮助。
5.3 卡表更新可能带来的伪共享问题
卡表实现看起来简单,但工程师们在多线程场景下踩过一个大坑:伪共享。多核 CPU 的缓存行通常 64 字节,而卡表数组元素只占 1 字节。如果两个不同线程分别更新数组中相邻的卡表项,这两个线程跑在不同的 CPU 核上,但它们对应的卡表项落在同一条缓存行里,某一方修改会导致整条缓存行在另一核上失效,于是两个核开始频繁同步缓存数据。
这就像两个人明明写的是两张纸,但两张纸被放在了同一个文件夹里,其中一个人每次动笔,另一个人手里的文件夹都会被没收重发,效率自然上不去。
HotSpot 处理伪共享的思路包括:让不同线程操作的卡表项尽量分散到不同缓存行,或者用条件标记减少写入频率。但这不是一个可以一劳永逸解决的问题,实际调优时还是要结合线程数量和写入频率观察。你会发现很多 JVM 性能问题,最后都绕不开 CPU 缓存这个物理事实。
5.4 卡表扫描的时机与成本
卡表不会一直是脏的。某些卡虽然在运行期被标记成脏,但经过GC扫描后,确实没有发现指向新生代的引用,这时候就需要把卡表项重新清理成干净状态。这个过程通常发生在 GC 处理记忆集的时候,扫描脏卡对应的内存区域,同时把卡表项重置。
如果脏卡数量特别多,GC 在扫描记忆集阶段的开销就会很大。有一种极端情况是,应用程序频繁创建大量短生命周期的老年代对象,并且同时引用新生代对象,导致脏卡数量飙升。这时候你会在 GC 日志里看到根扫描阶段耗时变长。
所以调优时不能只看堆大小,还要关注引用写入模式。如果业务中大量存在“老对象持有新对象引用”的代码路径,比如一个长期缓存的对象频繁更新到新创建的 DTO,那卡表相关的开销就会成为隐藏热点。
6. 串一次完整 Young GC:四个组件如何协同
6.1 一次 Young GC 的执行流程推演
现在我们把前面几个概念串成一个完整流程。假设当前触发了一次新生代回收:
第一步,VM 线程发起安全点请求,等待所有 Java 线程到达安全点。JIT 编译后的代码在安全点处检查到请求,线程挂起。已经处于安全区域或者阻塞状态的线程,由安全区域机制兜底。
第二步,GC 开始枚举根。这一步会读取线程栈当前指令地址对应的 OopMap,把其中记录的寄存器、栈槽中的 oop 作为根对象,放入扫描队列。由于每个线程都停在自己的安全点上,所以这块能拿到非常准确的根集合。
第三步,GC 处理记忆集,也就是扫描卡表里的脏卡。对传统分代收集器来说,这些脏卡可能记录了老年代对象指向新生代对象的引用。GC 把脏卡范围内的老年代对象也作为一个特殊根集合进行遍历,避免漏掉来自老年代的引用。
第四步,从根集合出发做可达性分析。这里不仅会从 Java 线程栈找对象,还会处理各种 JNI 引用、当前锁对象、静态变量等,但最核心的起点,仍然是 OopMap 提供的栈上引用和卡表提供的跨代引用。
第五步,回收完毕后,GC 退出安全点,各线程恢复执行。整个 STW 的耗时就是安全点等待时间加上根扫描、标记、清理等各阶段耗时的总和。
可以看到,OopMap 和安全点保证了根枚举的准确性和效率,记忆集和卡表则保证了跨代场景下不必牺牲全堆扫描的代价。少了任何一环,GC 的停顿模型都会变得不可接受。
6.2 从 GC 日志反推卡表和写屏障的表现
只看流程还是太抽象,我建议你把 GC 日志开详细一点,自己观察一次 Young GC 的暂停分布:
bash复制-Xlog:gc*:file=gc.log
如果条件允许,加上安全点统计。看到类似下面的信息时,不要直接掠过:
code复制GC pause (G1 Evacuation Pause) (young), 45.23ms
[Root Scanning] 18.10ms
[Other] 27.13ms
当 Root Scanning 耗时的占比明显偏高时,除了常规的线程根,还要怀疑脏卡数量是否过多。你可以结合业务代码,看看是否存在大量针对老年代对象的引用赋值。如果这种赋值发生频率极高,卡表写屏障本身也会成为 CPU 热点,这类问题在 Java Flight Recorder 的采样里往往表现为 JIT 编译后的写屏障方法频繁出现。
有一点要千万注意:不要只因为 Root Scanning 耗时就断定卡表有问题。老年代区域很多时,扫描整个记忆集本身的耗时也会上升,并不只是脏卡数量一件事。要综合看堆大小、存活对象数量、线程数量和引用写入频率,才能定位到真正瓶颈。
6.3 调优时哪些参数值得关注
跟卡表和写屏障最直接相关的参数是 -XX:+UseCondCardMark。在高并发写引用频繁的场景下,开启它可能减少一些无关的卡表写入,但它要求你先判断当前卡是否为脏,也会引入分支判断,所以并不是所有场景都有正收益。我倾向于先在压测环境对比开启前后的吞吐量,再决定是否上生产。
另一个经常被提到的参数是 -XX:+ReduceInitialCardMarks。它主要影响对象初始化阶段的引用赋值。对象刚创建时,字段默认值通常为 null,如果 JVM 能判断出这次写入不会产生跨代引用,就可以省掉对应的卡表标记。在一些对象创建极多的场景下,这个优化能减少不少写屏障开销。
至于 OopMap 和安全点相关的参数,普通应用不建议随意调整。有些优化手段会尝试调整安全点位置,但容易踩到难以排查的问题。安全点机制是 HotSpot 的基石,除非你有非常明确的问题且已经通过安全点日志定位到异常,否则不要为了“优化”而动它。
7. 实操中踩过的坑和排查经验
7.1 长轮询循环导致安全点停顿被放大
有段时间我负责一个推送网关,业务线程里有大量 while (!stop) { // 检查队列 } 这类长轮询。线上时不时出现几十毫秒的小停顿,但 GC 日志显示 Young GC 只有 10 毫秒左右。起初很困惑,后来打开安全点日志才发现,很多线程在 GC 发安全点请求后,需要多花一段时间才能“路过”安全点。原因是这些循环经过 JIT 优化后,安全点的分布不够密集,导致线程不能及时响应。
这类问题没有银弹级参数。最有效的做法是在循环体里增加合理的睡眠或让出 CPU,比如 LockSupport.parkNanos(1),既能降低 CPU 空转,又能让线程更快经过安全点。至少在排查这类问题时,你对安全点机制的判断会直接决定方向对错。
7.2 写屏障在引用密集型服务里成为隐藏热点
另一个案例是一个广告召回的候选池服务,大量 QPS 下要不停更新内存里的候选对象引用。压测时发现 CPU 使用率很高,火焰图里反复出现卡表写入相关的本地方法。一开始我以为是业务逻辑太复杂,后来单独统计引用类型字段赋值路径,才发现写屏障被高频触发。
尝试开启 -XX:+UseCondCardMark 之后,CPU 使用率有了一定下降,因为很多写入都落在同一批卡页上,条件判断过滤掉了大量重复置脏动作。这个案例也说明了一个道理:卡表不是“为了讲解而存在的概念”,它在真实系统里是能直接影响性能的代码路径。
7.3 排查线程状态时别忘安全区域
还有一次,安全点日志显示某个线程等待时间特别长,看起来像是它一直不响应安全点请求。我最初怀疑是不是 JNI 代码或者锁竞争导致线程卡住,排查了很久。最后发现,这个线程其实一直处于 Object.wait,按道理应该进入安全区域,但线程在进入安全区域前就已经被 JVM 判定为可以安全等待。
真正的问题是等待线程太多,被唤醒后又集中离开安全区域,造成了阶段性的线程调度压力。这类问题如果不是对着安全区域机制分析,很容易被误判成“GC 等待线程”故障。所以遇到安全点耗时长时,我现在的第一反应都是:先看线程状态分布,再看是哪些 VM operation 触发安全点,最后才看 OopMap 和 RSet 的处理开销。
从现实经验来看,这四个概念一旦打通,JVM 报出来的很多诡异现象都能找到解释。我个人最推荐的学习方式,不只是背概念,而是打开安全点日志和 GC 日志,把一次 Young GC 的每个阶段和这些机制一一对上。等你亲眼看到一次停顿里的 Root Scanning、脏卡处理和线程同步耗时,你才算真正把它们吃透。
