2404 字
约 8 分钟
14
S05-A20 · Safepoint:为什么"停顿 200ms"变成了 2 秒
2024-12-31
2026-11-10

一次 Full GC 的日志写着"停顿 180ms",而监控上延迟尖刺有 2 秒。查下来不是 GC 慢,而是所有线程到达 safepoint 花了 1.8 秒——其中一个线程正在执行一个巨大的计数循环,而循环体里没有"安全点轮询",于是它迟迟不肯停下。

JVM 的 stop-the-world 不是"立刻停",而是"请求所有线程走到安全点后停下"。而"走到安全点"需要线程配合——这个"等待"是"全局"的:只要有一个线程没到,其余几千个线程都得在安全点等着。所以 TTSP 是"最慢那个线程"的时间,而不是"平均"。
如果某个线程长时间不经过轮询点,整个 JVM 就得等它。


1. 一次 safepoint 停顿的三段

段 含义
Time to safepoint(TTSP) 等所有线程到达
停顿本体 做 GC/偏向锁撤销等
恢复 线程继续

第一段是最容易被忽略的:GC 日志里记的"pause"通常只含第二段,而真实用户感知的延迟包含第一段。

所以"GC 停顿 180ms、延迟尖刺 2s"是自洽的:1.8s 花在"等线程"上。这个自洽性很重要,因为它把"矛盾的数据"变成"一条线索":日志与监控不一致时,先怀疑"它们测的不是同一段",而不是怀疑其中一个错了。

2. TTSP 长的三个原因

原因 特征
长计数循环无轮询 单个线程占满 TTSP
JNI 调用长时间不返回 调用点不在安全区内
大量线程同时到达 线程数极多(数千)

第一项在"手写的热点循环"里最常见:for (long i = 0; i < 1e10; i++) sum += i 这种代码,JIT 会把它编译成"没有 safepoint poll 的紧循环"。

第二项(拆循环)的代价是"优化机会变少":小循环之间可能有边界检查与变量读写,于是"拆完之后更慢一点"。所以"拆到多大"要按"TTSP 能否接受"来定,而不是越小越好。
两个修法:在循环里插入"可被消除的"副作用(如写一个 volatile 变量),或把它拆成多个小循环。

// 反例:JIT 优化后无 safepoint 轮询的紧循环
long sum = 0;
for (long i = 0; i < 10_000_000_000L; i++) sum += i;   // TTSP 可达数秒

// 正解一:把循环拆小(每段之间有机会经过 safepoint)
for (long base = 0; base < 1e10; base += 1_000_000) {
    for (long i = base; i < base + 1_000_000; i++) sum += i;
}
// 正解二:循环里加一个"会被读的"副作用(慎用,可能抑制优化)

3. 怎么确认"是 TTSP 而不是 GC 慢"

手段 得到
-Xlog:safepoint 每次 safepoint 的 TTSP
-Xlog:gc+phases 停顿各阶段耗时
jcmd VM.info safepoint 统计汇总
JFR 的 SafepointBegin/End 精确时间线

第一项最直接:**JDK 9+ 的 -Xlog:safepoint 会打印"等待了多少毫秒"**第三项(jcmd VM.info)有个优点:它是汇总统计,能看到"总 TTSP / safepoint 次数 / 最长一次"。于是"偶发一次 2 秒"会不会被均值掩盖,一眼可知。
,而它通常是"你以为是 GC 的那段时间"。

第四项能关联到"当时哪个线程没到"——JFR 里有 SafepointStateSynchronization 之类的采样。

// 开启 safepoint 日志(含 TTSP)
java -Xlog:safepoint=info -jar app.jar
// 输出示例:
// Safepoint "G1CollectForAllocation", Time since last: 1200 ms, Reaching safepoint: 1832 ms, At safepoint: 2 ms, Total: 1834 ms
// 关键:Reaching safepoint(TTSP)远大于 At safepoint(停顿本体)

// safepoint 统计汇总
jcmd <pid> VM.info | grep -A5 "Safepoint"

4. 五类"安全点请求"的来源

来源 触发频率
GC(特别是 Full) 低但重
偏向锁撤销(-XX:-UseBiasedLocking 可关) 曾经高频
代码去优化(deopt) 中
jstack/jcmd/JFR 采样 按需
部分 System.gc() 手动

第三项的"叠加"效应值得注意:去优化本身会触发更多去优化(代码形状变了),于是一小段时间里 safepoint 请求密集,TTSP 累积成"几百毫秒的抖动"。
第三项在与"JIT 激进优化"配合时会出现:去优化需要所有线程到安全点,于是"代码变热 → 优化 → 又回退"的过程会叠加 safepoint 请求。

第二项在 JDK 15+ 已默认禁用偏向锁,因此老版本才需要考虑。

"紧循环"还有一个更隐蔽的版本:库里的正则或 JSON 解析大对象。它看起来是"一次方法调用",而内部是"几十万次迭代的循环",于是它同样会让 TTSP 变长——而你的代码里"看不见循环"。

// 三类"看不见的紧循环"(同样让 TTSP 变长)
1 大文本正则匹配(回溯型)
2 大 JSON/大 XML 的一次性解析
3 大数组排序/拷贝(几千万元素以上的单次操作)
// 判据:这个调用会不会"长时间不返回",而不是"有没有 for 循环"

5. 三类环境下的差异

环境 差异
容器(CPU 限额) 线程调度被限,TTSP 变长
高线程数(数千) 到达耗时叠加
大量 JNI 调用点不响应

第一项在"CPU 打满"时最明显:线程要"被调度到"才能走到安全点第一项的量化很直观:CPU 100% 时,一个线程被重新调度可能要 100ms 级;而"几百个线程都等一轮"就是几百毫秒。所以"CPU 打满"与"safepoint 抖动"经常同时出现,而它们互为因果。
,而 CPU 被占满时,轮到它可能要几百毫秒。

第四项(System.gc())的排查方式是"搜代码与配置":框架或库可能在某处调了它(如某些内存敏感组件),而它在低峰期无害、高峰期致命。
第三项(JNI)的边界是"JNI 代码执行期间不进安全点",于是"长 JNI 调用"与"紧循环"效果相同。

"尖刺的周期性"是判断来源的强线索:固定间隔出现 → 定时任务或周期 GC;与流量相关 → 分配速率或线程数;无规律 → 外部触发(jstack、agent)。

// 三种周期性线索
每 5 分钟一次尖刺   → 定时任务(多半是紧循环或大批量处理)
随 QPS 上升而变密   → 分配速率触发 GC(是 GC 而非 TTSP)
无规律、每次几分钟  → 监控 agent 采样/外部诊断
// 用法:先定"周期从哪来",再看 TTSP

6. 排错:五种"safepoint 相关"

现象 成因 处理方向
GC 停顿短但延迟尖刺长 TTSP 长 -Xlog:safepoint
定时出现 1–2 秒尖刺 周期性紧循环 拆循环
CPU 打满时延迟更差 调度受阻 限流或加核
容器里比裸机差 配额与 cgroup 核对 CPU limit
jstack 卡很久 请求 safepoint 前在等 同上,看 TTSP

第四项很实际:容器把 -XX:ActiveProcessorCount 或 cgroup 配额设小后,GC 线程数与调度都会变第四项还有个"隐式"来源:容器的 CPU 配额被别的进程(sidecar、监控 agent)抢走,于是"看起来配额够,实际可用少"。
,于是 TTSP 从 50ms 涨到 500ms。

"低停顿收集器"并不是"没有 safepoint":ZGC/Shenandoah 也需要 safepoint 做"根扫描的起点"与"类卸载",只是"停顿时长与堆大小解耦"。所以"换成 ZGC"能减少"GC 本体",但TTSP 长的问题依然存在。

// 换收集器前后的预期
G1:TTSP 1800ms + 停顿 180ms → 尖刺 2s
ZGC:TTSP 1800ms + 停顿 5ms   → 尖刺 1.8s(几乎没改善)
// 结论:尖刺主因是 TTSP 时,换收集器的收益很小

7. 边界:什么情况不必关心 safepoint

场景 为什么 做法
短请求无长循环 无长 TTSP 不必
无 JNI、无线程堆积 到达很快 不必
延迟 SLO 宽松(秒级) 尖刺被容忍 不必
已用低停顿 GC(ZGC/Shenandoah) 停顿时长与堆大小解耦 关注别处

最后一条与"排查顺序"有关的边界:先看 TTSP,再看 GC。因为"GC 优化"(换收集器、调堆)成本高,而"拆一个紧循环"成本低——顺序反了会先做贵的事。
判断口径一句话:"GC 停顿"与"用户感知延迟"是否差一个数量级。差,就说明 TTSP 在中间吃掉了时间;不差,GC 就是真因。


8. 自测

  1. 一次 safepoint 停顿的三段是什么?为什么 GC 日志的"pause"通常不含第一段?
  2. "紧循环"为什么会让 TTSP 变长?两种修法的取舍是什么?
  3. 怎么确认"是 TTSP 而不是 GC 慢"?-Xlog:safepoint 的输出里该看哪个字段?
  4. 五类 safepoint 来源里,哪一类"曾经高频"而现在已默认禁用?
  5. 为什么"容器 + CPU 打满"会让 TTSP 变长?它有哪些同类现象?
  6. 判断"是否需要关心 safepoint"的那个问题是什么?
S05-A20 · Safepoint:为什么"停顿 200ms"变成了 2 秒
https://www.clxhxhhr.top/posts/6560/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。