1. 项目背景
某低延迟量化交易系统的运维团队最近遭遇了一个"幽灵故障":生产环境每隔45秒左右,所有业务线程会集体停顿约200毫秒,然后毫无征兆地恢复运行。GC日志「一片空白」——在这些停顿区间内没有任何GC事件发生。CPU使用率正常,内存水位稳定,磁盘IO也未见异常。运维团队百思不得其解:"我们已经把GC都调好了,为什么还有长停顿?"
答案隐藏在JVM底层的safepoint机制中。Safepoint(安全点)是HotSpot虚拟机用来将所有Java线程协调到已知状态的核心机制——GC只是safepoint的「一个消费者」而已。除此之外,以下操作都依赖safepoint来完成:偏向锁撤销(biased lock revocation)、逆优化(deoptimization)、JVMTI调试操作、堆转储(heap dump via jmap)、线程栈转储(jstack -F)、类重定义(class redefinition)、以及某些JFR采集操作。每一次safepoint都意味着「全线程暂停」——当最后一个线程到达safepoint之前,所有已到达的线程都在等待。这个"最后一个到达的线程"决定了本次safepoint的TTSP(Time To Safepoint)。
更隐蔽的是,C2编译器在激进优化中对「可计数循环」(counted loop)进行编译时,可能会消除循环内的safepoint轮询指令——这意味着一个线程可能在一个长循环中「狂奔数秒」而从不检查safepoint标志,从而导致整个JVM的所有线程都被它拖住。在低延迟系统中,理解safepoint的内部工作机制、学会解读safepoint日志、掌握JDK 10+引入的Handshake(握手)机制,是诊断"莫名停顿"的关键技能。本章将从HotSpot源码层面剖析safepoint的设计、实现、触发路径,并通过实战演示如何定位和解决生产环境中的safepoint停顿问题。
2. 项目设计
小胖:(气喘吁吁地冲进办公室)大师!生产环境出大事了!交易系统每隔45秒就要卡200毫秒,我看了GC日志,根本没有GC!操作系统上一切正常,CPU、内存、磁盘全都好好的。所有监控面板都是绿色的,但延迟就是飙上去又降下来。这太诡异了,是不是JVM的bug?
大师:(放下茶杯,微微一笑)小胖,听说过"safepoint"吗?
小胖:safepoint?就是GC前所有线程都要停下来的那个点?但是我们没有GC啊。
大师:GC只是safepoint的一个使用者。JVM里需要safepoint的操作多了去了。我给你画个全景——safepoint本质上是一个全局同步协议:JVM里有一个叫VMThread的线程,它要做一些「必须所有Java线程都暂停」才能干的事。它做的事情流程是这样的:首先设置一个全局标志位(_safepoint_counter),然后等待。每个Java线程呢,在解释执行或者编译执行的代码里,都有一些「轮询点」(safepoint poll),这些轮询点分布在方法返回处和循环回边(loop back-edge)上。线程跑到轮询点的时候,就会检查这个全局标志位。如果发现"哦,标志位被设置了",就主动把自己挂起,等VMThread干完活再唤醒它。只有当「所有」Java线程都到达safepoint之后,VMThread才开始执行真正的工作;执行完了,清除标志位,所有线程继续跑。
小白:(凑过来)等一下,那如果有一个线程在一个特别长的循环里,里面没有轮询点,会怎么样?
大师:问得好。这正是问题的核心。编译后的代码里,safepoint轮询指令是一条简单的内存读操作——test %eax, [polling_page]——它去读一个特殊的「轮询页」(polling page)的内存地址。这个轮询页在正常情况下是可读的,所以CPU执行这条指令不会触发异常,几乎零开销。但当VMThread需要全局safepoint时,它会把这个轮询页设置为「不可读」——于是所有正在执行test指令的线程都会触发一个SIGSEGV信号,信号处理器(signal handler)捕获后把线程带到safepoint。这个设计非常聪明:正常路径零开销,需要暂停时才触发中断。
小胖:那为什么C2编译器会"去掉"这个轮询指令呢?
大师:这里有个很经典的坑。C2编译器在优化「可计数循环」(counted loop)时——一个典型例子是for (int i = 0; i < 100000000; i++)这种——它知道这个循环精确执行多少次。为了极致性能,C2可能会做"strip mining"或完全消除循环内的轮询检查。尤其是在某些JDK版本中,如果循环的迭代次数是已知的、有限的,C2可能判断"这个循环不会跑太久,不需要在循环体内插入safepoint轮询"。但在实际运行中,即使是一个简单的int计数循环,如果里面做了大量计算,也可能运行好几秒——而在这几秒内,这个线程永远不会到达safepoint!其他所有线程(可能就是几十个甚至几百个)都在等它。这就是TTSP爆炸的根因。
小白:TTSP是什么?
大师:Time To Safepoint,即「到达安全点的时间」。这是衡量safepoint性能的核心指标:从VMThread发起safepoint请求的那个时刻开始,到最后一个线程到达safepoint的时刻为止,这中间的时间差就是TTSP。如果TTSP很大——比如200毫秒——那所有线程就是被那个最慢的家伙拖累了整整200毫秒。在safepoint日志里你可以清清楚楚地看到是哪个线程拖了后腿。
小胖:可是safepoint一定要停掉所有线程吗?有没有轻量级的替代方案?
大师:JDK 10引入了Handshake(握手)机制,正是为了解决这个问题。Handshake和全局safepoint的区别在于:Handshake是有目标的——它可以只针对「一个特定线程」或「一小部分线程」发请求,不需要全JVM暂停。Handshake通过线程到线程的直接通信来完成:请求方通过内存屏障把操作写入目标线程的handshake操作字段,然后目标线程在下次轮询时执行这个操作。整个过程不需要VMThread参与,也不需要设置全局标志位。Handshake现在被广泛应用于偏向锁撤销、栈水印采样(stack watermark sampling)、线程级别的JVMTI操作等等。
小胖:那我怎么在日志里看这些信息呢?
大师:JVM提供了强大的诊断日志:-Xlog:safepoint*=debug。日志里每一行safepoint事件都显示了:这次safepoint是什么操作触发的(operation name)、TTSP是多少毫秒、总共阻塞了多少个线程、哪个线程是最后到达的(vmop),以及其他详细的时间分解。比如你看到一行日志里有"Total time for which application threads were stopped: 0.2001234 seconds",你就知道停顿了200毫秒。继续往前找"Time to safepoint"字段,如果它很大,就说明是线程到达慢的问题;如果TTSP很短但"执行时间"很长,那就是操作本身很慢(比如全堆dump)。
小白:那关键的源码文件都有哪些?
大师:核心文件主要有这几个:src/hotspot/share/runtime/safepoint.cpp——这是safepoint协议的主体实现,包括SafepointSynchronize::begin()和SafepointSynchronize::end()两个最核心的方法;src/hotspot/share/runtime/thread.cpp——每个JavaThread的safepoint检查逻辑在check_safepoint_and_suspend_for_native_trans等方法里;src/hotspot/share/code/compiledIC.cpp及相关的nmethod代码——编译后代码中的safepoint轮询指令就是在这里插入的;src/hotspot/share/runtime/handshake.cpp——Handshake机制的完整实现;src/hotspot/share/runtime/vmOperations.hpp——定义了所有VM_Operation子类,也就是所有可能触发safepoint操作的类型。
小胖:这些信息怎么帮助我们定位45秒一次的200毫秒停顿呢?
大师:有了上面的知识,你就可以打开safepoint日志,先看TTSP分布——如果TTSP平均只有几微秒但偶尔飙到200毫秒,那就是线程到达延迟的问题。再找到那个TTSP爆点的日志行,看触发的操作类型(vmop名称)。比如你看到"RevokeBias",那就是偏向锁撤销触发的——45秒的间隔恰好在偏向锁的默认延迟时间范围内(BiasedLockingStartupDelay默认是4秒,加上散列分布就会在45秒左右触发批量撤销)。如果看到"ThreadDump"或者"HeapDump",那就是监控系统(比如JMX或者Prometheus exporter)在定期抓堆导致的。如果你同时开启-Xlog:handshake*=debug,还能看到哪些操作是通过Handshake执行的(不用全局暂停),哪些走的是全局safepoint。
技术映射(全章汇总)
| 概念/机制 | 源码位置 | 触发条件 | 停顿范围 | 性能影响 | 诊断手段 |
|---|---|---|---|---|---|
| Safepoint协议 | safepoint.cpp:SafepointSynchronize::begin() |
VM_Operation被入队到VMThread | 所有Java线程 | TTSP × 线程数,全局暂停 | -Xlog:safepoint*=debug |
| Safepoint轮询 | compiledIC.cpp / nativeInst_x86.cpp |
每次方法返回或循环回边 | 单个线程,读内存 | 正常路径零开销 | hsdis反汇编查看test指令 |
| 轮询页机制 | safepointMechanism.cpp |
VMThread设置页不可读 | 触发SIGSEGV | 零开销,SIGSEGV路径约几μs | strace -e trace=fault |
| TTSP指标 | safepoint.cpp 内 _time_to_safepoint |
每次safepoint | 取决于最慢线程 | 延迟预算核心指标 | 日志中Time to safepoint |
| CountedLoop优化 | C2编译器 loopTransform.cpp |
int计数循环无safepoint轮询 | 仅单线程无轮询 | 可能导致TTSP爆炸 | -XX:+PrintAssembly + 反汇编 |
| Handshake机制 | handshake.cpp:Handshake::execute() |
线程级操作请求 | 仅目标线程 | 不阻塞其他线程 | -Xlog:handshake*=debug |
| 偏向锁撤销 | biasedLocking.cpp |
锁竞争+偏向模式 | 全局safepoint (旧) / Handshake (新) | JDK 15后大幅改善 | -XX:-UseBiasedLocking可关闭 |
| 逆优化 | deoptimization.cpp |
编译假设失效 | 全局safepoint | 约几ms到几十ms | -XX:+TraceDeoptimization |
| JVMTI操作 | jvmtiEnv.cpp |
外部工具调用 | 取决于操作类型 | jstack约几ms,heapdump可能几百ms | jstack/jmap/jcmd |
| SafepointTimeout | safepoint.cpp 内检测逻辑 |
TTSP > 阈值 | 打印警告+线程栈 | 诊断TTSP爆炸 | -XX:+SafepointTimeout + -XX:SafepointTimeoutDelay=2000 |
| NonSafepoint帧 | frame.cpp / pcDesc.cpp |
配置开启后记录所有PC | 仅在crash时 | 影响hs_err内容 | -XX:+UnlockDiagnosticVMOptions -XX:+DebugNonSafepoints |
| 线程阻塞检测 | thread.cpp:JavaThread::check_safepoint |
每次轮询 | 单线程检查 | 约几ns | 源码级分析 |
3. 项目实战
3.1 环境准备
本章实战需要以下环境:
| 组件 | 版本/配置 | 说明 |
|---|---|---|
| JDK | OpenJDK 21 fastdebug build | fastdebug版本包含更详细的safepoint日志和断言检查 |
| JMH | 1.37 | 用于精确控制和测量性能基准 |
| 操作系统 | Linux x86_64 | 生产环境普遍架构 |
| 关键JVM参数 | -Xlog:safepoint*=info:file=safepoint.log |
记录safepoint事件到文件 |
| 关键JVM参数 | -Xlog:handshake*=debug:file=handshake.log |
记录handshake事件 |
| 关键JVM参数 | -XX:+SafepointTimeout -XX:SafepointTimeoutDelay=2000 |
检测超过2秒的TTSP |
| 反汇编工具 | hsdis (HotSpot Disassembler) | 检查编译后代码中的轮询指令 |
如果是非fastdebug的release版本,safepoint日志依然可用,但部分调试标志(如DebugNonSafepoints)需要诊断模式启用。
3.2 分步实现
步骤一:制作长TTSP场景
首先编写一段Java代码,故意构造一个C2编译器会优化掉safepoint轮询的int计数长循环,人为制造TTSP爆炸场景:
// LongSafepoint.java - 演示C2 CountedLoop消除safepoint轮询导致的长TTSP
public class LongSafepoint {
private static volatile boolean running = true;
// 线程1: 在int计数循环中执行密集计算,无safepoint轮询
static class IntensiveLoop implements Runnable {
public void run() {
long sum = 0;
// 关键:int计数器 → C2识别为counted loop → 可能消除safepoint轮询
for (int i = 0; i < 5_000_000_000; i++) {
// 模拟计算密集操作
sum += (i * 2654435761L) & 0xFF;
// tip: 循环内无双精度浮点运算、无对象分配、无方法调用
// 这些操作都可能隐式插入safepoint轮询
}
System.out.println("IntensiveLoop finished, sum=" + sum);
}
}
// 线程2: 每100ms触发一次System.gc()作为safepoint请求者
static class SafepointRequester implements Runnable {
public void run() {
while (running) {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// System.gc()通过VM_GC_Operation入队到VMThread
// VMThread发起全局safepoint
System.gc();
}
}
}
public static void main(String[] args) throws Exception {
// JVM预热:让C2充分编译IntensiveLoop
for (int i = 0; i < 10000; i++) {
long s = 0;
for (int j = 0; j < 1000; j++) {
s += (j * 2654435761L) & 0xFF;
}
if (s == -1) System.out.println("warmup"); // 防止死代码消除
}
Thread t1 = new Thread(new IntensiveLoop(), "IntensiveLoop");
Thread t2 = new Thread(new SafepointRequester(), "SafepointRequester");
t1.start();
Thread.sleep(2000); // 确保t1已经进入大循环
t2.start();
t1.join(30000);
running = false;
t2.join(2000);
System.out.println("Test finished.");
}
}
编译并运行:
# 编译
javac LongSafepoint.java
# 运行,开启safepoint日志
java -XX:+UnlockDiagnosticVMOptions \
-XX:+SafepointTimeout \
-XX:SafepointTimeoutDelay=2000 \
-Xlog:safepoint*=info:file=safepoint.log \
-Xlog:safepoint+stats=info:file=safepoint-stats.log \
LongSafepoint
运行后查看safepoint.log,预期能看到类似如下的日志输出(以下为带注释的示例):
[0.453s][info][safepoint] Application time: 0.4530453 seconds
[0.465s][info][safepoint] Entering safepoint region: EnableBiasedLocking
[0.466s][info][safepoint] Leaving safepoint region
[0.467s][info][safepoint] Total time for which application threads were stopped: 0.0013462 seconds,
Stopping threads took: 0.0008231 seconds
[0.467s][info][safepoint] Application time: 0.0010021 seconds
... (多次正常GC safepoint)...
[2.523s][info][safepoint] Entering safepoint region: GenCollectForAllocation
[2.523s][info][safepoint] Application time: 0.0001234 seconds
[4.891s][warn][safepoint] # SafepointSynchronize::begin: Timeout detected:
[4.891s][warn][safepoint] # SafepointSynchronize::begin: timeout is 2000 ms
[4.891s][warn][safepoint] # SafepointSynchronize::begin:
Thread 0x00007f8c3c001800 [IntensiveLoop] is not at a safepoint
[4.891s][warn][safepoint] # SafepointSynchronize::begin:
Thread 0x00007f8c3c001800 state: _thread_in_Java
[4.891s][info][safepoint] Total time for which application threads were stopped: 2.3681234 seconds,
Stopping threads took: 2.3678901 seconds <--- TTSP 超过2秒!
[4.891s][info][safepoint] Leaving safepoint region
日志解读:
- 第1~4行:第一次safepoint(偏向锁启用),TTSP非常短(0.8ms),一切正常。
- 第6行:一次GC触发的safepoint进入。
- 第7~11行:关键信息!
SafepointTimeout被触发,warn级别日志输出,明确指出IntensiveLoop线程尚未到达safepoint,且状态为_thread_in_Java(线程正在执行Java代码,没有在轮询点检查safepoint标志)。 - 第12行:最终这个线程在被阻塞长达2.36秒后终于到达safepoint——因为循环跑了足够多次后终于到达了方法返回点或者在某个loop back-edge上轮询了。
这个实验清晰地复现了CountedLoop消除safepoint轮询导致TTSP爆炸的典型场景。在生产中,类似的根因往往隐藏在某些"看似无害"的int计数循环中——比如计算分片数、对某段内存做校验和、解析大报文等。
步骤二:safepoint日志全面解读
启用debug级别的safepoint日志,逐一触发不同类型的safepoint操作,学习解析日志的每一个字段:
java -XX:+UnlockDiagnosticVMOptions \
-Xlog:safepoint*=debug:file=safepoint-debug.log \
-Xlog:vmoperation*=debug:file=vmops.log \
LongSafepoint
下面以一个完整的日志事件行为例,逐字段解读:
[10.234s][debug][safepoint] Safepoint synchronization initiated using ThreadBlocking.
(15 threads)
[10.234s][debug][vmoperation] begin VM_Operation (0x00007f8c40001000): GenCollectForAllocation,
mode: _safepoint, triggered by JavaThread: 0x00007f8c3c002800 [main]
[10.234s][debug][safepoint] thread 0x00007f8c3c001000 [SafepointRequester]
is at a safepoint, _thread_in_native_trans
[10.234s][debug][safepoint] thread 0x00007f8c3c001800 [IntensiveLoop]
not at a safepoint, _thread_in_Java
[10.234s][debug][safepoint] thread 0x00007f8c3c002000 [main]
is at a safepoint, _thread_in_vm
... (等待IntensiveLoop线程)...
[12.601s][debug][safepoint] thread 0x00007f8c3c001800 [IntensiveLoop]
is at a safepoint, _thread_in_vm
[12.601s][debug][safepoint] All threads are at safepoint.
Time to safepoint: 2.367 seconds
[12.602s][debug][vmoperation] VM_Operation (0x00007f8c40001000):
executed in 0.001345 seconds
[12.602s][debug][safepoint] Leaving safepoint region
[12.602s][info][safepoint] Total time for which application threads were
stopped: 2.368456 seconds, Stopping threads took: 2.367123 seconds
字段含义逐项解析:
| 日志字段 | 含义 | 诊断价值 |
|---|---|---|
Safepoint synchronization initiated using ThreadBlocking |
safepoint协议采用ThreadBlocking模式(线程主动阻塞)。还有ThreadBlockingNone模式适用于单线程场景 | 确认safepoint方式 |
(15 threads) |
当前JVM中共15个Java线程需要参与safepoint | 线程越多,协调开销越大 |
begin VM_Operation: GenCollectForAllocation |
本次safepoint的触发者:堆分配失败触发的GC。其他常见值:RevokeBias(偏向锁撤销)、ThreadDump(线程转储)、HeapDumper(堆转储)、Deoptimize(逆优化)、CGC_Operation(并发GC周期)、EnableBiasedLocking(启用偏向锁) |
定位"谁在触发safepoint"的关键字段 |
mode: _safepoint |
操作需要在safepoint执行。其他模式:_no_safepoint(不需要暂停)、_concurrent(可在并发阶段执行) |
确认操作是否需要全局暂停 |
_thread_in_Java |
线程正在执行Java字节码或编译后代码。这是最可能卡住的状态,因为需要等待线程执行到轮询点 | 代表该线程尚未到达safepoint |
_thread_in_native_trans |
线程正从native代码返回Java代码的过渡态 | 可能短期卡住,通常很快恢复 |
_thread_in_vm |
线程已在VM内部(运行JVM运行时代码),已完成safepoint检查 | 该线程已经安全 |
_thread_blocked |
线程在JVM内部阻塞(如monitor等待),已安全 | 被动安全状态 |
Time to safepoint: 2.367 seconds |
TTSP = 从VMThread发起safepoint到所有线程到达的时间。如果这个值大,说明有线程迟迟不到 | 性能诊断的核心指标 |
executed in 0.001345 seconds |
VM操作本身的执行时间(线程已全部暂停后) | 如果这个值大,说明操作本身很慢(如大堆dump) |
Total time for which application threads were stopped |
停顿总时间 = TTSP + 操作执行时间 + 恢复时间 | 对业务影响的最终指标 |
现在逐一触发不同操作并观察日志:
// SafepointTriggers.java - 逐一触发各类safepoint操作
import java.lang.management.ManagementFactory;
import java.util.concurrent.locks.LockSupport;
public class SafepointTriggers {
private static final Object LOCK = new Object();
public static void main(String[] args) throws Exception {
// 确保JVM预热
Thread.sleep(5000);
// 1. GC触发safepoint
System.out.println("=== Trigger: System.gc() ===");
System.gc();
// 2. 偏向锁撤销触发safepoint (JDK < 15有效)
System.out.println("=== Trigger: Biased Lock Revocation ===");
biasedLockTest();
// 3. 逆优化触发safepoint
System.out.println("=== Trigger: Deoptimization ===");
deoptimizationTest();
// 4. 线程dump触发safepoint
System.out.println("=== Trigger: Thread Dump ===");
Thread.getAllStackTraces();
System.out.println("All triggers completed.");
}
static void biasedLockTest() throws Exception {
Object lock = new Object();
// 线程1: 获取偏向锁
Thread t1 = new Thread(() -> {
synchronized (lock) {
// 持有偏向锁
}
});
t1.start();
t1.join();
// 线程2: 竞争同一把锁 → 触发偏向锁撤销
Thread t2 = new Thread(() -> {
synchronized (lock) {
// 撤销t1的偏向锁,升级为轻量级锁
}
});
t2.start();
t2.join();
}
static void deoptimizationTest() {
// 多次分层编译后可能触发逆优化
for (int i = 0; i < 100000; i++) {
hotMethod(i);
}
}
static int hotMethod(int x) {
// 这个方法会被多次编译(C1 → C2),
// 在某些条件下C2版本会被丢弃(逆优化)
return x * x + x;
}
}
运行并解读日志:
java -XX:+UnlockDiagnosticVMOptions \
-Xlog:safepoint*=debug:file=safepoint-debug.log \
-Xlog:vmoperation*=debug:file=vmops.log \
SafepointTriggers
关键日志片段及注释:
# === GC 触发的safepoint ===
[debug][safepoint] Safepoint synchronization initiated. (3 threads)
[debug][vmoperation] begin VM_Operation: GenCollectForAllocation, mode: _safepoint
# === 偏向锁撤销触发的safepoint ===
[debug][safepoint] Safepoint synchronization initiated. (3 threads)
[debug][vmoperation] begin VM_Operation: RevokeBias, mode: _safepoint
# 注释:在JDK 15+中,偏向锁默认禁用(-XX:-UseBiasedLocking)
# 在JDK 10-14中,偏向锁撤销可能通过Handshake完成(不产生全局safepoint)
# === 逆优化触发的safepoint ===
[debug][safepoint] Safepoint synchronization initiated. (3 threads)
[debug][vmoperation] begin VM_Operation: Deoptimize, mode: _safepoint
# 注释:逆优化必须走全局safepoint,因为需要修改所有线程的执行状态
# === 线程dump触发的safepoint ===
[debug][safepoint] Safepoint synchronization initiated. (3 threads)
[debug][vmoperation] begin VM_Operation: ThreadDump, mode: _safepoint
# 注释:Thread.getAllStackTraces()需要所有线程暂停来获取一致的栈快照
如何定位"最慢线程":在safepoint日志中,当TTSP异常大时,查看在TTSP时间段内各线程的状态变化。最终状态为_thread_in_Java后面跟着转变成_thread_in_vm之间间隔很长的那个线程,就是罪魁祸首。可以通过JVM参数-XX:+SafepointTimeout配合-XX:SafepointTimeoutDelay=N,JVM在TTSP超过N毫秒时自动打印所有未达到safepoint的线程栈,极大提高诊断效率。
步骤三:Handshake机制验证
Handshake是JDK 10引入的轻量级线程协调机制,对比实验如下:
// HandshakeDemo.java - 演示Handshake vs 全局Safepoint
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
public class HandshakeDemo {
private static volatile boolean running = true;
private static AtomicLong counter = new AtomicLong(0);
// 业务线程:持续在工作,使用偏向锁对象
static class Worker implements Runnable {
private final Object localLock = new Object();
private long workDone = 0;
public void run() {
while (running) {
// 获取偏向锁 → 可能被撤销 → 触发Handshake (JDK 10-14)
// 或全局safepoint (JDK < 10)
synchronized (localLock) {
workDone++;
}
counter.incrementAndGet();
}
System.out.println(Thread.currentThread().getName() +
" workDone=" + workDone);
}
}
// 线程2: 不使用锁,纯计算,不受偏向锁撤销影响
static class PureCompute implements Runnable {
public void run() {
long sum = 0;
while (running) {
sum += counter.get() * 2654435761L;
}
System.out.println(Thread.currentThread().getName() + " sum=" + sum);
}
}
public static void main(String[] args) throws Exception {
// 启动两个worker(都会使用偏向锁)
Worker worker1 = new Worker();
Worker worker2 = new Worker();
Thread w1 = new Thread(worker1, "Worker-1");
Thread w2 = new Thread(worker2, "Worker-2");
Thread pure = new Thread(new PureCompute(), "PureCompute");
w1.start();
w2.start();
pure.start();
// 让偏向锁生效
Thread.sleep(5000);
// 定期触发偏向锁撤销
ScheduledExecutorService scheduler =
Executors.newScheduledThreadPool(1);
scheduler.scheduleAtFixedRate(() -> {
// 创建新线程竞争锁 → 撤销偏向锁
// JDK 10-14: 触发Handshake(仅影响持有锁的线程)
// JDK < 10: 触发全局safepoint(影响所有3个线程)
Thread competitor = new Thread(() -> {
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
competitor.start();
}, 0, 500, TimeUnit.MILLISECONDS);
Thread.sleep(30000);
running = false;
w1.join();
w2.join();
pure.join();
scheduler.shutdown();
System.out.println("PureCompute counter=" + counter.get());
}
}
运行此测试,对比两种情况:
# JDK 8: 偏向锁撤销走全局safepoint(所有线程被暂停)
java -Xlog:safepoint*=info:file=safepoint-jdk8.log HandshakeDemo.txt
# JDK 21: 偏向锁已默认关闭,Handshake用于其他场景
# 改为测试线程栈采样(JFR或JVMTI层面的handshake触发)
java -Xlog:handshake*=debug:file=handshake.log HandshakeDemo
Handshake日志解读(以JDK 17/21环境为例):
[10.234s][debug][handshake] HandshakeOperation: DirectThread by JavaThread 0x00007f8c3c002800
[main] for JavaThread 0x00007f8c3c001800 [Worker-1]
[10.234s][debug][handshake] Target thread 0x00007f8c3c001800 not at safepoint,
arming poll
[10.234s][debug][handshake] Thread 0x00007f8c3c001800 polling for handshake...
state: _thread_in_Java
[10.235s][debug][handshake] Thread 0x00007f8c3c001800 executed handshake
(0x00007f8c40001200) in 0.000123s
[10.235s][debug][handshake] Handshake completed for 1 thread(s)
对比核心差异:
| 维度 | 全局Safepoint | Handshake |
|---|---|---|
| 影响范围 | 所有Java线程 | 仅目标线程(可指定1个到N个) |
| 阻塞时间 | TTSP + 操作时间,所有线程同步等待 | 仅目标线程短暂阻塞 |
| 典型场景 | GC、逆优化、堆dump | 偏向锁撤销(JDK 10-14)、栈水印采样、线程级JVMTI操作 |
| 触发方式 | VMThread设置全局标志 + 轮询页不可读 | 直接通过内存屏障写目标线程的handshake字段 + 设置handshake轮询位 |
| 源码主体 | safepoint.cpp (SafepointSynchronize) |
handshake.cpp (Handshake::execute) |
| 并发友好度 | 低——"一人迟到,全体罚站" | 高——互不干扰 |
| JDK版本 | JDK 1.0+ | JDK 10+ (最初用于偏向锁),持续扩展用途 |
关键发现:在JDK 10-14中,很多偏向锁撤销操作从全局safepoint迁移到了Handshake,这大幅降低了高并发锁竞争场景下的停顿问题。但需要注意的是,Handshake依然要求目标线程到达安全的轮询点——如果目标线程恰好也在一个无safepoint轮询的长循环中,Handshake同样会被延迟。区别仅在于:其他不相关的线程不会被连带暂停。
步骤四:预防与规避策略
基于前面的实验和分析,总结生产环境中预防safepoint停顿的核心策略:
一、代码层面规避
// 反例:int计数循环,C2可能消除safepoint轮询
public void dangerousLoop() {
for (int i = 0; i < 10_000_000; i++) {
processItem(i);
}
}
// 正例1:使用long计数器,C2不会识别为counted loop
// → 循环回边上必然有safepoint轮询
public void safeLoopWithLong() {
for (long i = 0; i < 10_000_000L; i++) {
processItem((int)i);
}
}
// 正例2:循环体内调用可能触发safepoint的方法
// 如Thread.yield(), Object.wait(timeout=0), 或任何native方法调用
public void safeLoopWithMethodCall() {
for (int i = 0; i < 10_000_000; i++) {
processItem(i);
if (i % 10000 == 0) {
Thread.yield(); // 显式插入safepoint检查点
}
}
}
// 正例3:使用"不可数"的循环条件(遍历集合)
// C2无法预知迭代次数 → 必然保留safepoint轮询
public void safeLoopWithCollection(List<Item> items) {
for (Item item : items) {
processItem(item);
}
}
二、JVM参数层面诊断与防护
# 1. 开启safepoint超时检测
# 当任何一次safepoint的TTSP超过2秒时,打印警告并输出未到达线程的栈
-XX:+SafepointTimeout
-XX:SafepointTimeoutDelay=2000
# 2. 记录非safepoint点的PC信息
# 这样在hs_err_pid文件中可以看到未到达safepoint的线程具体在执行什么代码
-XX:+UnlockDiagnosticVMOptions
-XX:+DebugNonSafepoints
# 3. 持续监控safepoint日志
-Xlog:safepoint*=info:file=safepoint.log:filesize=50m:filecount=5
-Xlog:safepoint+stats=info:file=safepoint-stats.log
# 4. 在JDK 15+中,偏向锁已经默认禁用
# 如果还在JDK < 15上运行且不需要偏向锁,主动禁用以减少safepoint
-XX:-UseBiasedLocking
# 5. 限制C2编译的激进优化(谨慎使用,有性能损失)
-XX:+UseCountedLoopSafepoints
# 此参数强制C2在counted loop中保留safepoint轮询
# 6. 控制GC的safepoint频率
-XX:+UseG1GC
-XX:MaxGCPauseMillis=50
-XX:G1ReservePercent=10
三、监控层面持续观测
Prometheus JVM exporter可以采集safepoint相关指标,配置如下:
# prometheus-jmx-config.yml
rules:
- pattern: 'java.lang<name=Runtime, type=Runtime>(safepoint|Safepoint).*'
name: jvm_safepoint_$1_$2
type: GAUGE
- pattern: 'java.lang<name=*, type=Runtime>(TotalSafepointTime|SafepointCount)'
name: jvm_safepoint_$1
type: COUNTER
关键监控指标:
jvm_safepoint_count(safepoint总次数)—— 频率异常可能意味着GC过于频繁或偏向锁撤销风暴jvm_safepoint_total_time_ms(累计停顿时间)—— 高频发safepoint的累积效应jvm_safepoint_time_to_safepoint_max(最大TTSP)—— 业务延迟的P99上限- 配合
-Xlog:safepoint*=info日志,按vmop类型分类统计safepoint次数
3.3 测试验证
以下验证矩阵确保各场景的safepoint行为符合预期:
| 测试场景 | 预期行为 | 验证方法 | 通过标准 |
|---|---|---|---|
| int计数长循环 + GC触发 | TTSP > 500ms | safepoint日志检查TTSP字段 | TTSP在日志中 > 500ms 且有 SafepointTimeout 警告 |
| long计数长循环 + GC触发 | TTSP < 5ms | safepoint日志检查TTSP字段 | 所有safepoint的TTSP < 5ms |
| 循环内Thread.yield() + GC触发 | TTSP < 2ms | safepoint日志检查TTSP字段 | 每次yield()后safepoint检查生效 |
| 偏向锁撤销 (JDK 10-14) | 仅目标线程受影响 | 对比PureCompute线程的lock-free计数(无下降) | PureCompute的计数器不受影响(Handshake模式) |
| 偏向锁撤销 (JDK 8) | 所有线程受影响 | PureCompute计数器出现停顿 | PureCompute的计数器在撤销期间有gap(全局safepoint模式) |
| SafepointTimeout开启 | 超2秒自动打印warn | 运行长循环测试 | 控制台出现warn级别的SafepointTimeout日志 |
| DebugNonSafepoints开启 + kill -3 | hs_err包含完整栈 | 查看hs_err_pid文件 | 栈中无" |
| Handshake日志 (JDK 21) | handshake事件独立记录 | handshake.log内容不包含"all threads stopped" | 每次handshake仅1个目标线程被操作 |
4. 项目总结
4.1 优点与缺点
| 机制 | 优点 | 缺点 |
|---|---|---|
| 全局Safepoint | 实现简单可靠;保证所有线程状态一致;适合需要全局一致性的操作(GC、逆优化) | "一人迟到全体罚站"的单点阻塞模型;TTSP受最慢线程支配;高线程数时协调开销↑ |
| Safepoint轮询(test指令) | 无safepoint时零开销(仅读内存);利用OS内存保护机制而非主动检查 | 依赖信号处理路径,实现复杂;SIGSEGV路径有一定延迟 |
| Handshake | 仅影响目标线程,不阻塞无关线程;无需全局标志位同步;更细粒度的线程控制 | 依然需要目标线程到达轮询点;实现更复杂(需要处理并发handshake);并非所有VM操作都可以通过Handshake完成 |
| CountedLoop消除轮询(C2优化) | 极致的循环执行性能;消除多余的栈帧和检查开销 | 可能导致TTSP爆炸;隐蔽性强,难以在开发/测试阶段发现 |
| SafepointTimeout诊断 | 自动检测异常TTSP;生产环境可用(低开销);自动打印线程栈定位问题 | 仅在问题发生后报警(非预防);阈值设置需要权衡灵敏度和噪音 |
| -XX:+DebugNonSafepoints | crash时栈信息完整;帮助定位无safepoint代码段 | 轻微性能开销(约1-3%);仅在crash时有价值 |
4.2 适用场景
需要深入理解safepoint机制的场景:
- 低延迟交易/支付系统(P99延迟要求 < 10ms):safepoint导致的200ms停顿是不可接受的。需要审计所有代码中对int计数循环的使用,配置
-XX:+SafepointTimeout和safepoint日志持续监控。 - 大规模线程池应用(线程数 > 500):线程越多,全部到达safepoint的协调开销越大,偏向锁撤销的累积效应也越显著。
- 高频GC应用(每秒多次GC):每次GC = 一次safepoint。如果GC频繁,safepoint的累积停顿时间可以占总CPU时间的显著比例。
- 使用JVMTI工具链的环境(APM、调试器、profiler):很多JVMTI操作需要safepoint,不恰当的agent可能每隔几秒触发一次全局暂停。
- JDK版本迁移场景(JDK 8 → JDK 17/21):偏向锁的默认行为变化、Handshake的引入范围变化,都需要重新评估safepoint行为。
不需要过于关注safepoint的场景:
- 纯离线批处理任务:200ms的停顿对几十分钟的总运行时间无影响。
- 单线程或少线程应用:协调开销可以忽略。
- 主要使用native代码的应用(如数据库通过JNI调用C库):native线程不参与safepoint(处于
_thread_in_native状态时线程被视为"已安全")。
4.3 注意事项
| 注意维度 | 详细内容 |
|---|---|
| JDK版本差异 | JDK 8: 偏向锁撤销走全局safepoint;无Handshake机制;-XX:+PrintSafepointStatistics日志格式不同。JDK 10-14: Handshake引入,逐步将偏向锁撤销迁移到Handshake,但某些条件下降级为全局safepoint。JDK 15+: 偏向锁默认禁用(-XX:-UseBiasedLocking),Handshake覆盖更多操作(栈水印采样、JFR线程栈采集)。JDK 17+: -Xlog:safepoint*=debug 统一日志格式,废弃PrintSafepointStatistics |
| GC算法影响 | 串行GC/Parallel GC:每次Young/Full GC = 一次全局safepoint,暂停时间长。CMS:初始标记需要safepoint,并发标记不需要,但重标记又需要。G1:Young GC需要safepoint,Mixed GC也需要,但并发标记阶段不需要。ZGC/Shenandoah:暂停时间不依赖堆大小(ZGC < 1ms),但依然需要safepoint来启动并发标记 |
| 信号处理影响 | Safepoint依赖SIGSEGV信号处理路径。如果应用自己注册了SIGSEGV handler(如某些native库),可能干扰safepoint轮询页机制,导致未定义行为 |
| 容器环境 | 容器中的线程数可能被cgroup限制,如果线程数接近限制,safepoint协调的效率会下降。此外,CPU throttle可能导致线程的轮询间隔变长 |
| 监控工具的safepoint开销 | jstack、jmap、JMX某些操作、JFR的某些事件(如每线程分配采样)会触发safepoint。不当的监控频率(如每10秒jstack一次)本身就是问题来源 |
| C1/C2编译器差异 | C1编译器总是插入safepoint轮询(保守),所以纯C1编译的代码不会出现CountedLoop消除safepoint的问题。C2的激进优化才是safepoint轮询被消除的根源 |
4.4 常见踩坑经验
案例一:CountedLoop消除safepoint轮询 → 多秒级TTSP → P99延迟飙升至数百毫秒
某支付系统在生产环境观察到P99延迟周期性地从2ms飙升至800ms,与交易量无关。开启-XX:+SafepointTimeout -XX:SafepointTimeoutDelay=2000后,日志显示一条告警:
[warn][safepoint] Thread [OrderMatcher-3] (_thread_in_Java) not at safepoint
进一步检查代码,发现OrderMatcher-3线程正在执行一个订单匹配算法:
// 问题代码
int totalOrders = orderBook.getSize(); // 返回int
for (int i = 0; i < totalOrders; i++) {
matchOrder(orderBook.get(i));
}
totalOrders是int类型,C2识别为counted loop并消除了循环内的safepoint轮询。当订单簿中有大量订单时(高峰可达20万+),单次匹配循环耗时超过2秒。修复方法:将循环计数器改为long类型——最简单的改动,C2不再将其识别为counted loop,循环回边缘留safepoint轮询。修复后P99延迟回到3ms。
案例二:偏向锁撤销风暴 → 高并发场景下safepoint链式反应 → 级联停顿
某微服务在JDK 8上运行,使用了大量synchronized块保护共享数据结构。业务高峰期(QPS > 50000),大量的偏向锁撤销操作触发频繁的全局safepoint。日志显示每秒超过100次RevokeBias类型的safepoint,累积TTSP超过总CPU时间的30%。每次撤销都需要暂停所有线程(包括正在处理其他请求的线程),导致吞吐量雪崩式下降。
根因:JDK 8的偏向锁撤销必须走全局safepoint。高并发下锁竞争激烈,每次竞争触发一次撤销 = 一次全局safepoint,形成"撤消费-暂停-更多请求积压-更多锁竞争"的恶性循环。
修复方案:
- 升级到JDK 17(偏向锁默认禁用,完全消除此类safepoint);
- 短期方案:添加
-XX:-UseBiasedLocking禁用偏向锁(损失偏向锁的单线程性能优势,但消除safepoint风暴); - 代码层面:将高竞争数据结构改为
java.util.concurrent包中的无锁/少锁实现。
案例三:JMX监控线程每分钟触发heap dump → 隐蔽的性能回归
运维团队发现每分钟整分时刻(如10:00:00, 10:01:00)延迟都会有一个50-80ms的尖刺,非常规律。GC日志正常,safepoint日志显示在对应时刻有一个HeapDumper类型的safepoint操作,TTSP约3ms,但操作本身耗时约60ms(因为堆较大,dump需要遍历整个堆对象图)。
进一步排查发现:运维团队配置了一个Prometheus exporter,每分钟调用ManagementFactory.getMemoryPoolMXBeans()和HotSpotDiagnosticMXBean.dumpHeap()来做内存快照。dumpHeap()操作被JVM映射为VM_HeapDumper——这个操作需要在全局safepoint中执行,导致整个JVM停顿。
修复方案:降低监控频率至5分钟一次,并改用非safepoint的JFR Streaming方式采集heap统计信息(JFR的ObjectAllocationInNewTLAB等事件不需要全局safepoint)。
4.5 思考题
-
进阶问题:假设你在一个JDK 21的生产环境中开启了
-XX:+SafepointTimeout -XX:SafepointTimeoutDelay=1000,同时也在运行JFR profiling(-XX:StartFlightRecording)。某天你收到SafepointTimeout告警,safepoint日志显示TTSP超1秒,且超时线程的栈显示它正在执行JFR的事件写入代码(jfr::Event::commit())。请分析:是JFR本身导致了TTSP爆炸,还是JFR的栈信息只是一个"无辜的旁观者"?如何通过额外的日志信息来区分这两种情况? -
跨版本问题:某团队从JDK 8迁移到JDK 21后,发现某些之前运行良好的应用延迟反而增加了。排查发现JDK 21中增加了"stack watermark"机制用于并发GC的线程栈处理。该机制大量使用Handshake来实现。请分析:在什么情况下Handshake虽然不触发全局safepoint,但仍然可能导致业务线程的延迟抖动?Handshake和safepoint的轮询检查在底层实现上有何不同?
下一章预告:第33章深入G1/ZGC屏障与并发标记源码——从「会调参」到「懂算法为何正确」。我们将拆解G1的SATB(Snapshot-At-The-Beginning)标记屏障和ZGC的彩色指针(Colored Pointers)读写屏障在C2 JIT编译后的机器码形态,分析并发标记如何在不暂停应用的情况下正确遍历对象图,并深入理解"为什么ZGC能做到亚毫秒级暂停"这一问题的底层数据结构与算法答案。
延伸阅读与资源
Java 工程师进阶:从 JVM 生产排障到OpenJDK原理
MySQL Server 9从入门到进阶的实战之旅
Nacos 3.x 注册配置中心实战修炼:从入门到源码扩展
Elasticsearch从入门到进阶的实战之旅
RabbitMQ从入门到进阶的实战之旅:从单机到大促高可用架构
Celery 入门到进阶之路:从异步任务到自研调度平台
LangGraph 生产级实战进阶:从零到生产级Agent工作流开发
Dify 从入门到源码:LLM 应用平台实战修炼
从零到生产级:FastAPI 异步高并发、源码与 SRE 实战
实战SQLAlchemy 2.0: 从 CRUD 到生产级架构
从零打造企业级 AI 助手:LangChain RAG、Agent 与生产实战
后端工程师 AI 转型课:Ollama 私有化大模型从入门到生产
MongoDB 实战进阶与内核修炼
NumPy 从入门到生产落地:全链路实战指南(科学计算/向量化/性能调优)
Milvus向量数据库实战修炼:从 0 到 1 精通向量检索与生产落地
Redis 8 实战精讲:从 CRUD 到源码,构建高可用缓存系统
Python 3实战精进:从脚本到高并发订单引擎
python入门:Rquests从菜鸟脚本到企业级SDK的网络实战圣经

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号