1. 一次GC背后,藏着四把钥匙
做Java开发的朋友,早晚都会遇到一次印象深刻的GC日志分析。我印象最深的一次,是线上服务高峰期突然出现长达几秒的停顿,翻日志发现Full GC频繁触发。当时围着JVM参数调了很久,-Xmx、-Xmn调了一轮又一轮,问题却依然存在。后来才发现,真正的问题根源并不是堆大小配错了,而是我对JVM垃圾回收的几个底层机制缺乏足够深的理解。
HotSpot在实现垃圾回收时,绕不开四个核心概念:OopMap、安全点(Safepoint)、记忆集(Remembered Set)和卡表(Card Table)。这四个词看起来各管一摊,实际上环环相扣。如果把一次垃圾回收比作一次城市大扫除,那么OopMap就是用来确定哪些地方可能藏着垃圾的“藏宝图”,安全点是环卫工人约定好集合再开始干活的“集合点”,记忆集是记录哪些街区之间可能存在垃圾传递的“台账”,卡表则是这份台账最常用的一种记录方式。
这篇文章不打算只做概念罗列,我会从HotSpot实际工作的角度,把这四个机制一条线串起来讲。你不需要提前看过JVM源码,只需要对堆、栈、GC Roots这些基础概念有个大概印象就能跟上。读完以后,你会理解为什么JVM停顿时长和这些机制息息相关,也能在排查GC问题时多一些判断依据。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么JVM需要OopMap
2.1 没有OopMap的年代:从根枚举说起
无论使用哪种垃圾收集器,GC的第一步几乎都是枚举GC Roots。所谓GC Roots,简单说就是一堆“起点引用”,从这些起点出发,沿着对象间的引用链一路走,能走到的对象就是活的,走不到的就是可以被回收的。
在早期的虚拟机实现里,枚举GC Roots是一件非常“笨重”的事情。虚拟机必须从线程栈的每一个栈帧开始,逐个扫描局部变量表和操作数栈,判断每个槽位里存的是不是对象引用。这种做法的开销大得惊人,而且完全不知道哪里是引用、哪里只是普通的整数或者地址。
你可以想象一个场景:你要在一间堆满杂物的房间里找几件贵重物品,但没有清单,只能把每个抽屉、每个箱子都打开翻一遍。这段时间不仅长,而且翻的过程中你还不能干别的,整个程序都得停下来等着。
2.2 OopMap就是一张精确的“引用地图”
HotSpot的解决办法是,在JIT编译过程中,编译器会顺便记录下代码执行到某个偏移位置时,栈上和寄存器里哪些地方存放的是对象引用。这条记录就是OopMap(Oop是Ordinary Object Pointer的缩写,即普通对象指针)。
有了OopMap之后,GC在枚举根的时候就不再需要盲猜了。它只需要从线程当前的执行位置出发,找到对应的OopMap记录,按照记录里标注的位置,把对象引用取出来,逐个加入GC Roots集合就行。
这里有一个关键点需要说明:OopMap只在特定的位置才有记录,不是每一行字节码都对应一份OopMap。因为如果每执行一条指令都去维护一份“当前位置哪些槽位是引用”的映射,记录本身的存储开销和执行时的维护开销会变得不可接受。HotSpot的实际做法是,只在特定的安全点位置生成并维护OopMap。
2.3 一个简单的代码示例
为了帮助理解,我用一个伪代码来模拟OopMap的样子。假设有一段Java代码:
java复制public void foo() {
User user = new User();
int age = 18;
String name = user.getName();
System.gc(); // 这里可能触发GC
System.out.println(name);
}
当这段代码被JIT编译后,机器指令里会插入一些“OopMap记录点”。在调用System.gc()之前的那条指令位置,OopMap大概会记录:
| 位置 | 寄存器/栈槽 | 内容 |
|---|---|---|
| 指令偏移量 0x2A | RSI寄存器 | 指向User对象的引用 |
| 指令偏移量 0x2A | 栈偏移 +8 | 指向String对象的引用 |
| 指令偏移量 0x2A | 栈偏移 +16 | int,非引用 |
GC发生时,GC线程只要拿到线程当前执行到的指令地址,找到对应偏移量的OopMap,就能精确知道哪些槽位要当作根来处理。
注意:OopMap的价值不仅在栈上。寄存器里的引用同样会被记录。这也是为什么JIT编译产物必须和GC机制深度配合,二者不能完全脱离各自独立发展。
3. 安全点:线程必须在约定的地方停下来
3.1 问题来了:线程不能随时响应GC
有了OopMap这张“地图”以后,GC线程确实能快速找到根引用。但还有一个问题没有解决:GC线程不能想停哪个线程就停哪个线程。如果在一个线程正在修改对象引用关系的中途强行暂停它,内存里的对象图可能处于中间状态,GC扫描到一半时引用关系可能已经发生了变化,导致漏标或者错标。
HotSpot采用了一种协作式的暂停机制:不是GC线程强行把业务线程掐停,而是业务线程自己运行到某个特定的位置后,主动检查一个全局标志位,发现需要GC就自己停下来。这个“特定的位置”就是安全点。
3.2 安全点应该选在哪里
安全点的选择不能太密也不能太疏。太密,则线程频繁检查标志位,运行效率下降;太疏,则线程要跑很久才能到达安全点,GC等待时间变长。
HotSpot选择安全点的基本原则是“让程序长时间执行的区域之外”。具体来说,以下位置会被选为安全点:
- 方法调用指令之后
- 循环跳转回去的位置(backward branch)
- 异常跳转的目标位置
- 可能引发GC的方法调用处
为什么循环回跳的位置必须是安全点?因为如果一个非常耗时的循环体内部没有安全点,那么这个线程可能要循环几百万次才有机会响应GC,相当于这个线程永远无法被暂停。JIT编译器在生成代码时,会在循环回边(每次循环结束跳回开头的位置)插入安全点检查逻辑。
3.3 线程是怎么“知道”该停下来的
现代HotSpot实现中,安全点检查通常采用一种叫做“主动中断”的方式,而不是传统的“被动中断”。具体流程是:
- GC线程设置一个全局的内存页为不可读状态(或者设置一个全局标志位)。
- 业务线程执行到安全点位置时,会执行一条测试指令去读那个被保护的内存页。
- 如果内存页不可读,会触发一个异常或者自陷,业务线程随即进入安全点挂起状态。
这个机制的妙处在于,业务线程在非安全点位置运行时完全不需要做任何额外判断,只有在安全点位置才会去触碰那个被标记的页面。因此安全点检查的开销被压缩到极低。
3.4 一个容易踩坑的点:安全区域
安全点机制有一个薄弱环节:如果一个线程处于Sleep状态或者Blocked状态,它根本没有在执行代码,不可能跑到安全点去主动挂起。那GC是不是要一直等它醒过来?
JVM引入了“安全区域”(Safe Region)的概念来解决这个问题。安全区域是指一段代码片段中,引用关系不会发生变化,因此在这段区域的任何地方开始GC都是安全的。当线程进入安全区域时,它会标记自己进入了安全区域;当JVM要发起GC时,发现线程在安全区域里,就不必等待它到达安全点,可以直接认为它处于可暂停状态。线程要离开安全区域时,JVM会检查GC是否已经完成,如果还没完成,就必须等待,直到收到GC结束的信号才能离开。
实操心得:在排查GC停顿问题时,可以开启安全点日志(
-Xlog:safepoint,JDK 9+语法),观察线程到达安全点的时间分布。如果发现某个线程用了很长时间才到达安全点,多半是它在执行一个没有安全点检查的超长循环,此时需要检查代码中是否有类似while(true)这样的密集计算逻辑。
4. 记忆集与卡表:追踪跨代引用的关键手段
4.1 分代收集带来的新麻烦
HotSpot的堆被划分为新生代和老年代。新生代里大部分对象朝生夕灭,所以Minor GC可以非常频繁地执行,每次回收掉大量短命对象。但Minor GC只回收新生代,有一个前提问题需要解决:老年代对象可能引用了新生代对象,如果不处理这些“来自老年代的引用”,单纯扫描新生代时就会漏掉一些实际还活着的对象。
最简单的做法是,Minor GC时把整个老年代也扫描一遍,找出所有指向新生代的引用。这样做的确能保证正确性,但代价是Minor GC的耗时随老年代大小线性增长。老年代动辄好几个GB,每次做Minor GC都要全量扫描老年代,停顿时间会高到无法接受。
于是就有了记忆集(Remembered Set)的概念。
4.2 记忆集:只记录“可能有跨代引用”的区域
记忆集是一种抽象数据结构,用来记录“从非收集区域指向收集区域的指针”所在的位置。以新生代回收为例,记忆集记录的是“老年代中哪些地方可能有指向新生代对象的引用”。当Minor GC扫描根时,除了常规的GC Roots之外,还会把记忆集记录的那些老年代位置加进来扫描,这样既不用全量扫描老年代,又能保证不漏掉跨代引用。
记忆集的实现精度可以有很多种:
| 精度 | 含义 | 开销 |
|---|---|---|
| 字长精度 | 记录每个机器字长位置 | 最精确,记录开销最大 |
| 对象精度 | 记录每个对象起始位置 | 较精确 |
| 卡精度 | 记录每个“卡页”位置 | 粗粒度,实现最简单 |
HotSpot实际采用的就是“卡精度”方案,也就是我们常说的卡表(Card Table)。
4.3 卡表到底是什么
卡表的实现思路非常朴素,甚至可以说有点“暴力”。它将整个堆划分成一个个固定大小的内存块,每个块叫做一张卡(Card),卡的大小通常是512字节。然后维护一个字节数组,数组的每个元素对应一张卡。如果某张卡对应的内存区域里有对象引用了其他分代的对象,就把这个数组元素的值标记为dirty(脏)。
Minor GC时,GC只需要扫描卡表里被标记为脏的那些卡,把卡里的对象作为额外的根来处理,就可以找到老年代指向新生代的引用。未标记为脏的卡,说明里面没有跨代引用,可以跳过。
4.4 写屏障:谁负责给卡表打标记
卡表需要时刻保持最新状态,不可能等GC的时候再全堆扫描一遍来重新生成卡表。这就需要JVM在所有可能产生跨代引用的写入操作之后,额外执行一小段逻辑,把对应的卡标记为脏。这段逻辑叫做写屏障(Write Barrier)。
写屏障有两种主要形式:
- 写前屏障:在写入操作之前执行。
- 写后屏障:在写入操作之后执行。
对于卡表维护来说,需要使用的是写后屏障。每执行一次引用类型字段的赋值操作,JIT编译后的代码都会在后面追加一小段“标记卡表”的逻辑。HotSpot会尝试对这段逻辑做优化,比如先在卡表里读取目标位置的值,如果已经是脏的,就跳过写操作,从而减少不必要的内存写。
从Java代码层面看,你只是写了一个普通的字段赋值:
java复制oldGenObj.child = newGenObj;
但在JIT编译后的机器码层面,这个赋值操作后面还跟着卡表标记的额外步骤。这就是Java程序员感知不到、但性能上确实存在的一笔开销。
实操心得:CMS和G1都用卡表,但实现细节不同。G1更进一步,把堆划分成Region后,使用双向卡表(双向的Card Table)来记录跨Region引用,同时还结合了并发标记阶段的写前屏障来维持SATB(Snapshot-At-The-Beginning)算法的正确性。如果你在分析GC日志时看到
remark阶段耗时较久,有时候是和SATB缓冲区处理有关,而这背后依然离不开写屏障和卡表机制的支撑。
5. 四者协同:一次安全点暂停的完整旅程
5.1 从触发GC到业务线程挂起
为了把四个概念串成一条线,我们模拟一次新生代GC(Minor GC)的完整过程。
业务线程正在执行JIT编译后的代码。某个时刻,新生代空间快要满了,JVM决定发起一次Minor GC。GC线程首先做的事是设置一个全局安全点标志。业务线程继续运行,执行到下一个循环回跳位置时,碰到了安全点检查指令,发现标志位被置位,于是主动挂起。此时所有业务线程都停在各自的安全点上,线程栈上的引用位置都可以通过OopMap精确找到。
这一步是整个GC过程中最直接的停顿来源。该等多久,取决于最慢的那个线程什么时候到达安全点。
5.2 根枚举与跨代引用的扫描
所有线程都到达安全点之后,GC线程开始枚举GC Roots。它从每个线程当前的安全点位置出发,查看对应的OopMap,把栈和寄存器里的引用取出来。这一步因为有了OopMap,速度非常快,不需要逐帧扫描并猜测哪些是引用。
接下来,GC线程开始从GC Roots往下遍历对象图。但在遍历之前,它必须处理一个关键问题:新生代里的对象可能被老年代对象引用着。如果忽略这些引用,部分新生代对象会被误判为不可达。
这时轮到卡表登场。GC线程遍历卡表数组,找出所有被标记为脏的卡,把卡范围内的对象引用当作额外的根。这些对象引用指向新生代的位置,在可达性分析时会被当作出发点。
5.3 标记、清理与写屏障的持续作用
在标记过程中,GC线程会顺着引用链把存活对象标记出来。标记完成后,存活对象会被复制到存活区(Survivor)或者晋升到老年代。
与此同时,业务线程恢复执行后,它们继续写对象字段。每写一次跨代引用,写后屏障都会把对应卡的标记置脏。也就是说,卡表的维护是持续不间断的,它和GC是否正在执行没有直接关系,这是一个后台默默运行的成本。
一次Minor GC结束后,卡表并不会被清空为全干净的状态。那些已经处理过的脏卡会被重置,但后续新的写入又会重新把卡标记为脏。整个机制就是这样一个不断标记、不断消费的过程。
5.4 一张图理解四个机制的关系
这里我用文字描述一下四者的关系:
text复制触发GC
↓
设置全局安全点标志
↓
业务线程运行到安全点 → 主动挂起
↓
GC线程读取线程栈对应的 OopMap → 枚举GC Roots
↓
结合 卡表 中脏卡记录 → 补充跨代引用到根集合
↓
可达性分析 → 标记存活对象 → 回收
OopMap解决的是“怎么快速知道哪些栈上位置有引用”,安全点解决的是“线程在什么位置愿意停下来”,记忆集解决的是“哪些非收集区域可能指向收集区域”,卡表则是记忆集的一种落地实现。四个机制不是孤立存在,而是从不同角度配合支撑起一次GC。
6. 需要深入理解的几个隐藏细节
6.1 OopMap是“谁”生成的
OopMap的生成重担主要在JIT编译器(C1和C2)身上。解释执行阶段,由于没有编译产物,OopMap的生成方式又不同。解释器需要在每个方法调用和循环回跳位置记录栈帧中的引用位置,因此解释器维护OopMap的机制是精心设计的,确保不用为每一行字节码都生成映射。
如果你用-XX:+PrintAssembly查看过JIT编译产物,会发现在一些关键指令位置后面跟着oop map的注释,后面标明了寄存器和栈偏移。这就是OopMap在机器码层面的呈现形式。值得注意的是,GC发生时线程不一定恰好停在某个安全点上——准确说,是停在了安全点上,但如果代码路径被JIT优化得非常深,OopMap的记录位置也会随之变少,因为C2编译器会尽量消除不必要的安全点检查。
6.2 为什么要避免使用-XX:-UseBiasedLocking之类参数来影响安全点
某些JVM参数会影响安全点数量,从而影响GC停顿表现。举个例子,偏向锁的撤销逻辑需要到达安全点才能执行。如果关闭偏向锁(JDK 15以后默认禁用),则省去了撤销偏向锁时到达安全点的需求。这个问题在低版本JDK上尤为明显,业务线程持有偏向锁时间过长时,GC需要等待偏向锁撤销完成,导致安全点等待时间异常。
类似地,如果开启了-XX:+PrintGCDetails等诊断日志,某些版本会在安全点处执行额外的日志输出,也会增加停顿时长。排查GC问题时,建议先关闭一切非必要的诊断参数,观察纯业务场景下的停顿基线。
6.3 卡表的字节粒度与性能权衡
HotSpot的卡表是一个字节数组,每张卡对应512字节的堆空间。为什么不用一位(bit)来标记一张卡?理论上用位图能做到更小的内存占用,但字节数组在读写上更高效,card[index] = dirty这种操作比位操作要快。而且卡表本身在JVM里已经足够小:一个2GB的堆,卡表只需要约4MB的字节数组,内存开销完全可以接受。
卡表粒度过粗也有问题。如果一张卡里有多个对象,其中只有一个对象有跨代引用,那么整张卡都会被标记为脏。GC扫描时,不得不扫描卡内所有对象,这带来了一定程度的精度损失。但相对于全量扫描老年代,这点损失实在微不足道。
6.4 安全点日志怎么读
如果你正在排查一次可疑的长时间停顿,建议优先看安全点日志。JDK 8u272以后可以通过-Xlog:safepoint=info输出,旧版本使用-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1。
日志里通常有两类关键信息:
vmop列:表明触发安全点的操作类型,比如GCTaskThread、RevokeBias等。到达安全点的总耗时:如果这个值很大,说明某个线程迟迟没有跑到安全点。
举例来说,日志中出现:
text复制vmop spin time sync time clean up ...
G1CollectForAllocation 0.000 5.213 0.000 ...
其中sync time是5.213秒,说明从一个线程触发GC到所有线程都到达安全点,一共花了5秒多。这种情况下,问题多半出在某个线程身上,它可能在执行一段没有安全点的长循环,或者正在执行某些JNI调用。结合线程转储(thread dump),往往能定位到具体代码位置。
7. 从这些机制反推常见性能问题的根因
7.1 为什么线程数越多,安全点同步越慢
安全点同步本质上是所有线程的一次“集合”。线程数越多,集合的协调成本越高。虽然HotSpot对安全点同步做了很多优化,比如最新的同步协议在大多数场景下都能做到O(1)或者近O(1)的复杂度,但线程数量极端庞大时(比如上千个线程),每次GC到达安全点的等待时间仍然不可忽视。
在实际业务中,线程池大小设置不当会造成大量空闲线程被频繁唤醒和挂起,这种现象会间接恶化安全点同步阶段的耗时。因此,合理配置线程池大小、避免大而空的线程池,对降低GC停顿是有实际帮助的。
7.2 大对象分配为什么可能引发严重的卡表扫描
大对象直接进入老年代,如果它内部有引用指向新生代对象,那这张卡就会被频繁标记为脏。如果大对象跨越了多个卡页,一次写操作可能需要同时标记多张卡,这就增加了写屏障的开销。更致命的是,大对象所在的卡如果其中有一个引用发生了变化,整个卡里的内容在Minor GC时都要被扫描一遍。
所以,频繁创建大对象不但会造成老年代空间压力,还可能在无形中增加每次Minor GC的根扫描时间。调优时除了关注堆大小,还要审视代码里是否存在频繁创建大数组、大缓冲区的场景。
7.3 JNI边界上的安全点问题
如果在Java代码里通过JNI调用了一个C++函数,这个C++函数执行期间,当前线程不会执行Java字节码,也不会有安全点检查。如果C++函数运行时间很长,且没有主动让出执行权,其他线程触发GC时就必须等待这个线程完成JNI调用回到Java代码后才能到达安全点。
这种问题在实际生产中并不少见。解决办法通常是在JNI实现里主动调用JNI_ENTRY或者JNI_EXIT宏,让JVM有机会在JNI边界插入安全点检查;或者在C++代码里定期调用某些JVM提供的回调接口来检查是否需要暂停。从这个角度看,安全点的影响面并不局限于纯Java代码,它也渗透到了JNI、反射、动态代理等所有可能脱离JIT编译代码执行的地方。
8. 基于这些机制调优时的实战心得
8.1 优先排查安全点同步异常
遇到GC停顿远超预期时,我的判断顺序一般是这样:先看安全点日志,确认问题出在安全点同步阶段还是GC本身阶段。如果sync time很长,说明有线程迟迟没到安全点,这时线程转储往往能看出是哪些线程卡在什么地方。如果sync time很短,但GC暂停时间很长,那问题在垃圾回收器本身,比如标记阶段耗时过久或者存活对象过多。
8.2 留意JIT编译与OopMap的关系
JIT编译是分层编译的,C1编译速度快但优化浅,C2编译慢但优化深。OopMap的精度在不同编译层次下并不完全一致。某些极端情况下,一个方法被C2深度优化后,局部变量的生命周期被缩短,原本在C1看来是引用的槽位,在C2的OopMap中可能已经不存在了。这本身是正确性保证的需求,但也可能导致GC根集合的变化,间接影响对象存活判定。
如果线上服务有明显的周期性停顿,可以观察是否是某个热点方法触发了C2编译,导致后续GC行为变化。虽然并不常见,但这类问题排查起来相当隐蔽,需要结合-XX:+PrintCompilation日志来辅助判断。
8.3 卡表相关参数的调整空间不大,但方向要对
和卡表直接相关的参数不多,比较常用的是-XX:+UseCondCardMark。这个开关的作用是,在写屏障中先判断卡是否已经是脏的,再决定是否要写内存。开启后可以减少冗余的内存写操作,但增加了判断分支,所以在低竞争场景下可能有微小提升,高竞争场景下反而可能劣化。是否开启,建议做AB测试再决定,不建议拍脑袋加参数。
G1和ZGC都有自己的一套写屏障和记忆集实现。ZGC甚至完全抛弃了传统的卡表,改用染色指针和读屏障。这是技术上的一次大跃迁,但从原理角度看,它要解决的问题——如何高效追踪跨区域引用——和卡表是一致的。理解卡表,对理解ZGC的读屏障也有直接帮助。
8.4 万变不离其宗:学会用机制解释现象
很多Java开发者背了一堆GC调优参数,遇到问题就一个个试,效果却不理想。原因在于他们缺少一套“用底层机制解释现象”的思维方式。
比如,新生代调大后Minor GC频率下降,但每次停顿反而上升了,因为单次需要复制的存活对象更多了,而且卡表扫描范围也变大了。比如,显式调用System.gc()会导致Full GC,但如果开启了-XX:+ExplicitGCInvokesConcurrent,它可能触发一次并发收集,而不是完全停顿的Full GC。这些行为差异,只有理解了GC的底层调度逻辑,才能真正融会贯通。
从OopMap到安全点,从记忆集到卡表,这四个机制就像JVM垃圾回收的地基。地基不稳,上面楼层再高也是虚的。我没有在文章里给出一个放之四海而皆准的调优参数组合,因为那本来就不存在。但如果你能把这四个机制的原理吃透,再去看GC日志、看JVM参数,思路会比以前清晰很多。调优从来不是参数堆砌,而是先理解机制,再对症下药。
