Skip to content

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 轮询指令:

  1. 方法调用:每次调用另一个方法之前(包括 invokevirtualinvokestatic 等)
  2. 循环回边:每次循环体执行完回到循环头之前
  3. 异常抛出:抛出异常时
  4. JNI 返回:从 native 方法返回 Java 代码时

轮询实现很简单:JVM 维护一个全局的 safepoint_poll 标志位(实际是 SafepointSynchronize::_staterunning 变为 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

注意最后一行:spinblock 都为零,但 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 代码,直接标记为 SafeRegion
  • Unsafe.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 loopsafepoint 看 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 定位具体线程栈。

参考

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1