主题
SafePoint 与 SafeRegion:为什么 GC 停顿有时和堆大小无关
问题
线上有个诡异现象:一台 4G 堆的机器 Full GC 只停 50ms,另一台 512M 堆的小机器反而停了 800ms。堆越大停顿越久,这是常识,但反过来就让人摸不着头脑了。
问题出在 JVM 的 SafePoint 机制上。GC 停顿由两部分组成:真正的 GC 工作(跟堆大小相关)和 "到达安全点"的时间(跟堆大小基本无关,但跟线程数和 JIT 编译质量强相关)。后者在低延迟场景下经常被忽略。
分析
SafePoint 是什么,为什么需要它
JVM 在执行 GC 前,必须确保所有线程都停在某个"安全"的位置,这个位置叫 SafePoint。为什么不能直接强行暂停?因为线程可能正在执行以下操作:
- 修改对象引用(写屏障)
- 修改栈帧里的局部变量
- 操作 OopMap(对象引用映射表)
如果在这些操作中间强行打断,GC 无法知道线程栈里哪些位置是引用、哪些是普通数据,可能把整型数当成对象指针去回收,直接崩掉。
SafePoint 的本质是:线程告诉 JVM "我现在处于一个所有寄存器/栈上的引用都已知的状态,你可以安全地 GC 了"。
SafePoint 的插入位置
JIT 编译后的代码在以下位置会插入 SafePoint 轮询指令:
- 方法调用:每次调用另一个方法之前(包括
invokevirtual、invokestatic等) - 循环回边:每次循环体执行完回到循环头之前
- 异常抛出:抛出异常时
- JNI 返回:从 native 方法返回 Java 代码时
轮询实现很简单:JVM 维护一个全局的 safepoint_poll 标志位(实际是 SafepointSynchronize::_state 从 running 变为 synchronizing),线程在轮询点检查这个标志位的值。如果被置位,线程就主动阻塞等待 GC 完成。
一个常见的误解:SafePoint 只在 GC 时才有用。实际上,JVM 的很多操作都需要 SafePoint:偏向锁撤销、类重新定义(
retransform)、JIT 逆优化(deoptimization)等,都会触发全局安全点。
TTSP(Time To SafePoint):被忽略的停顿大头
普通 GC 日志里看不到"到达安全点"的时间,但那段时间实打实的是 STW。可以用 -XX:+PrintSafepointStatistics 看:
bash
-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1输出示例:
vmop [threads: total initially_running wait_to_block] [time: spin block sync cleanup vmop] page_trap_count
16.226: Deoptimization [ 21 0 0 ] [ 0 0 0 0 0 ] 0
16.228: RevokeBias [ 21 0 0 ] [ 0 0 0 0 0 ] 0
18.314: G1CollectionPause [ 21 0 0 ] [ 0 0 0 0 0 ] 0
29.501: G1CollectionPause [ 21 0 0 ] [ 0 0 0 0 0 ] 0
30.002: G1CollectionPause [ 21 0 0 ] [ 0 0 0 0 0 ] 0
30.281: G1IncCollectionPause [ 21 0 0 ] [ 0 0 0 0 0 ] 0
30.584: G1IncCollectionPause [ 21 0 0 ] [ 0 0 0 0 0 ] 0
30.850: G1IncCollectionPause [ 21 0 0 ] [ 0 0 0 0 0 ] 0
139.318: G1IncCollectionPause [ 21 20 0 ] [ 0 1000 1000 0 0 ] 0注意最后一行:spin 和 block 都为零,但 sync = 1000ms。这 1000ms 就是 JVM 等待所有线程到达 SafePoint 的时间。这 1 秒跟堆大小完全无关,GC 正文还没开始。initially_running 是 20,说明有 20 个线程在 JVM 发起 GC 时还在跑着,花了 1 秒才都停下来。
为什么有的线程很久才到 SafePoint
计数循环 vs 可数循环是关键。JIT 编译器会优化掉不带分支的简单计数循环中的 SafePoint 轮询:
java
// 不可数循环(hot loop,JIT 可能会保留轮询)
for (int i = 0; i < list.size(); i++) {
// 有方法调用,自然有 SafePoint
process(list.get(i));
}
// 可数循环(counted loop,JIT 可能消除轮询)
for (int i = 0; i < 1000000; i++) {
sum += i; // 纯算术,没有方法调用,没有分支
}当 JIT 判断一个循环是"可数循环"(循环次数可确定、无分支、无方法调用)时,它可能会消除循环内的 SafePoint 轮询,因为"理论上这个循环很快就能跑完"。但如果循环次数很大(比如百万级),这个"很快"就变成了数百毫秒。
这就是经典的 SafePoint 漂移问题:一个线程卡在"几毫秒就能跑完"的循环里,实际跑了 800ms 才到 SafePoint,整台机器的 GC 停顿就多了 800ms。跟堆大小一毛钱关系都没有。
SafeRegion:线程不跑但安全
SafePoint 解决的是"正在运行的 Java 线程"如何停下来。但有一种线程,它虽然不是 Java 执行状态,但也不能被 GC 忽略:执行 JNI 代码的线程,以及处于阻塞状态的线程。
SafeRegion 是 SafePoint 的补充概念:一块代码区域,执行期间可以安全地不参与 GC。线程进入 SafeRegion 时告知 JVM:"我现在没碰 Java 堆,GC 你尽管搞"。线程退出 SafeRegion 前检查 GC 是否在进行,如果是就等待完成再退。
典型场景:
- JNI 方法调用:JNI 方法不操作 Java 堆(除非显式调用了
JNIEnv接口),可以安全地进入 SafeRegion Thread.sleep()/Object.wait()/LockSupport.park():阻塞态线程不执行 Java 代码,直接标记为 SafeRegionUnsafe.park()同样适用
如果线程没有正确进入 SafeRegion(比如 JNI 实现有 bug,没有通过 JNIEnv 告知 JVM 自己进入了 native 代码),JVM 只能等它自己回到 SafePoint,GC 停顿就会白白延长。
怎么排查 SafePoint 问题
第一步:打开 SafePoint 日志
bash
-XX:+PrintSafepointStatistics -XX:PrintSafepointStatisticsCount=1
-XX:+UnlockDiagnosticVMOptions -XX:+PrintSafepointStatisticsTimeout看 sync 列的时间。如果 sync 持续 > 100ms,说明有线程迟迟不到 SafePoint。
第二步:识别热点线程
用 -XX:+PrintSafepointStatisticsTimeout 和 -XX:+SafepointTimeout(默认 false),当线程在 SafePoint 等待超时时,会打印线程栈:
bash
-XX:+SafepointTimeout -XX:SafepointTimeoutDelay=500输出类似:
# SafepointSynchronize::begin: Timeout detected:
# thread 0x00007f8b1c008000, name: 'main', state: _thread_in_Java
# JavaThread: 0x00007f8b1c008000
# stack:
Java frames (JIT compiled):
j com.example.HotLoop.calculate()I+16
j com.example.HotLoop.run()V+12第三步:JIT 编译日志确认
bash
-XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+PrintInlining搜索 counted loop 或 safepoint 看 JIT 是否消除了某个热点循环的 SafePoint 轮询。
第四步:看看是不是偏向锁在搞鬼
偏向锁撤销也会触发全局 SafePoint。如果 RevokeBias 频繁出现,且 sync 时间明显,就是偏向锁在作祟。JDK 15 默认禁用了偏向锁,JDK 21 完全移除了,但如果你还在用 JDK 8-11,可以:
bash
-XX:-UseBiasedLocking # 关掉,如果没有大量无竞争 synchronized 场景修复方案
方案一:针对问题循环
如果定位到某个计数循环 JIT 消除了 SafePoint,可以用 -XX:+UseCountedLoopSafepoints 强制在计数循环边插入轮询(JDK 9+ 默认开启,但某些场景仍然会被优化掉)。或者在循环体里加一个轻量操作:
java
// 在循环体里加一个 volatile 读,这个操作会插入内存屏障,间接引入 SafePoint
for (int i = 0; i < 1000000; i++) {
sum += i;
if (Thread.interrupted()) break; // 或者 Thread.yield(),但不是所有 JVM 实现都有用
}方案二:控制线程数
线程越多,SafePoint 同步的开销越大。线程数 = 核数 × 2 以内通常没问题,超过 100 个线程时 SafePoint 同步的时间会非线性增长。
方案三:关掉偏向锁
如果 gc+metaspace=gc 日志里频繁出现 RevokeBias 导致的 SafePoint,直接关掉:
bash
-XX:-UseBiasedLocking方案四:低延迟场景用 ZGC
ZGC 的并发标记和重定位大部分工作不需要全局 SafePoint。但注意,ZGC 在初始化、VMOperation 等场景仍然需要短暂的安全点(通常 < 1ms)。
总结
SafePoint 是 JVM GC 的基础设施,不是 bug。但它的设计隐含了一个假设——"所有线程都能在几毫秒内到达安全点"。这个假设在某些场景下会失效:
- 计数循环的 SafePoint 消除,导致线程长达数百毫秒不响应 GC 请求
- 线程数过多,SafePoint 同步时间非线性增长
- 偏向锁频繁撤销,引入额外的全局安全点
排查时,-XX:+PrintSafepointStatistics 是必开参数,看 sync 列;如果 sync 时间 > 100ms,再配合 -XX:+SafepointTimeout 定位具体线程栈。
参考
- JEP 312: Thread-Local Handshakes(JDK 10 引入,部分 SafePoint 操作可改为线程局部操作,减少全局同步)
- JEP 376: ZGC: Concurrent Thread-Stack Processing(ZGC 并发处理线程栈,减少安全点依赖)
- HotSpot 源码:
src/hotspot/share/runtime/safepoint.cpp中SafepointSynchronize::begin()方法 - SafePoint 在 HotSpot 中的实现(Red Hat 博客)