什么是安全点(Safepoint)?STW 是怎么实现的?什么是安全区域?
简化版
安全点(Safepoint) 是代码中一些「特定位置」,线程只有运行到安全点才能安全地被暂停下来做 GC——因为此时对象引用关系是明确一致的(JVM 知道栈上哪些是引用、指向哪)。GC 需要 STW(Stop The World,暂停所有业务线程)时,不是「立刻强停」,而是让所有线程各自跑到最近的安全点再停下。JVM 用「主动式中断」实现:设一个标志,线程在安全点检查这个标志,发现要停就挂起。安全区域(Safe Region) 是为「已经睡眠/阻塞、跑不到安全点」的线程准备的——一段「引用关系不变」的代码区域,线程进入前声明「我在安全区,随时可以 GC」,GC 就不用等它。
详细版
为什么需要安全点:GC 要枚举 GC Roots、移动对象、更新引用,这些操作要求「对象引用关系在这一刻是确定、一致的」。但线程随时都在改引用,不能在任意位置停——必须停在「JVM 能准确知道栈和寄存器里哪些是对象引用」的位置,这就是安全点。
安全点设在哪:不是每条指令都是安全点(那样记录信息太多、太频繁检查太慢),JVM 只在「会长时间执行」的地方设安全点:
- 方法返回前 / 方法调用后
- 循环的回跳处(循环末尾跳回开头,防止长循环迟迟不到安全点)
- 抛异常的位置
这些点的共同特点:程序可能在这里"停留较久",适合插入检查
STW 的实现——主动式中断:
GC 要 STW 时:
1. 设置一个全局标志"需要暂停"
2. 每个线程在安全点会主动轮询这个标志(读一个内存页)
3. 线程发现标志被设 → 主动在安全点挂起自己
4. 所有线程都到安全点挂起 → GC 开始
不是 GC 去强行中断线程,而是线程自己跑到安全点检查后停——所以叫"主动式"
安全区域解决睡眠线程问题:
问题:一个线程 sleep 或 blocked,它不执行代码 → 永远跑不到安全点 → GC 一直等它?
解法:安全区域(Safe Region)——一段"引用关系不会变化"的代码区
线程进入安全区前,标记"我在安全区,可以 GC"
GC 发起时不用等这些线程(它们引用不变,安全)
线程离开安全区前,检查 GC 是否完成:没完成就等 GC 结束再走
⚠️ STW 停顿 = 「等所有线程到安全点」的时间 +「GC 实际工作」的时间。有时 GC 本身很快,但某个线程迟迟到不了安全点(如一个超大数组的 for 循环、JIT 优化掉了循环里的安全点检查),会导致「进入安全点」耗时很长——这是排查「GC 停顿异常长但 GC 日志显示 GC 很快」问题的关键。
完整版教学
一、为什么不能在任意位置暂停线程
GC 工作时(枚举 Roots、移动对象、更新引用),需要一个「引用关系一致的快照」——它必须准确知道:每个线程的栈帧、寄存器里,哪些数据是对象引用、分别指向堆里哪个对象。
如果在任意位置停线程:
线程可能正执行到"计算引用地址的一半"、"对象移动了但引用还没更新"的中间态
→ JVM 无法准确判断栈上哪些是引用 → GC 会漏扫或错扫 → 灾难
所以线程必须停在「JVM 有完整、准确的引用信息」的位置。JVM 通过一种叫 OopMap 的数据结构记录「在某个位置,栈和寄存器里哪些槽位是对象引用」。但为每条指令都生成 OopMap 太占空间,所以 JVM 只在特定位置生成——这些位置就是安全点。线程只有在安全点,JVM 才有它的 OopMap,才能安全地枚举根、移动对象。
二、安全点设在哪:够用但不过密
安全点不能太密(每条指令都设 → OopMap 数据爆炸、检查开销大),也不能太疏(线程半天到不了安全点 → GC 等太久)。JVM 的折中是:只在「可能长时间执行/停留」的位置设安全点:
① 方法调用后(invoke 返回)
② 循环回跳处(回边)—— 关键!防止长循环迟迟不到安全点
③ 方法返回前
④ 抛异常处
选这些点的逻辑是:程序执行流在这些地方「有喘息」,插入一个「检查是否要停」的成本可接受,且能保证线程不会「长时间跑不到安全点」。尤其是循环回跳处——如果循环里没有安全点,一个跑几亿次的循环会让线程长时间无法响应 STW 请求,所以循环末尾必须有安全点检查(除非 JIT 判定是「可数循环」做了优化,这反而可能引发问题,见第五节)。
三、STW 的实现:主动式中断
STW 的实现很巧妙——不是 GC「强行掐断」线程(那样不安全,可能停在中间态),而是主动式中断:让线程自己跑到安全点、自己检查、自己停:
GC 需要 STW 时:
1. JVM 把一个特殊的内存页设为"不可读"(作为"要暂停"的信号)
2. 每个线程在安全点处,会执行一条"读这个内存页"的指令(轮询)
3. 正常时:读成功,继续跑
STW 时:这个页不可读 → 读操作触发一个"陷阱"(SIGSEGV 信号)
→ JVM 的信号处理器接管,把线程挂起在安全点
4. 所有线程都挂起 → GC 开始工作
用「读一个内存页」而不是「if 判断标志」,是一种极致优化——正常情况下这条读指令几乎零开销(不影响热路径性能),只有真要 STW 时才通过「页不可读触发陷阱」批量停住所有线程。这体现了「让最常见的路径最快」的设计哲学。
四、安全区域:解决「跑不到安全点」的线程
主动式中断有个漏洞:如果一个线程正在 sleep、wait、或阻塞在 IO 上,它根本不执行代码,永远跑不到安全点——难道 GC 要一直等它醒来?这显然不行。安全区域(Safe Region) 解决这个问题:
安全区域 = 一段"对象引用关系不会发生变化"的代码区域
(线程在里面睡觉/阻塞,不会改任何引用,所以对 GC 来说是"安全"的)
线程进入安全区(如即将 sleep)前:
→ 标记"我进入安全区了,引用关系冻结,GC 随时可以对我进行"
→ GC 发起 STW 时,不用等这些"在安全区的线程"
线程离开安全区(如 sleep 结束)时:
→ 检查 GC 是否正在进行/未完成
→ 若 GC 还没结束:线程等待,直到 GC 完成才离开安全区继续跑
→ 若没有 GC:正常离开
核心思想:安全点是「线程主动跑过去」的点,而安全区域是「线程待在里面不动、声明自己安全」的区。两者配合,既处理了「正在运行的线程」(跑到安全点停),也处理了「睡眠/阻塞的线程」(在安全区不用等)。这样 GC 才能保证「所有线程要么在安全点、要么在安全区」,从而安全地开始工作。
五、STW 停顿的隐藏成本:到达安全点的时间
一个重要的实战认知:STW 停顿时间 = 「所有线程到达安全点」的时间 + 「GC 实际工作」的时间。很多人只关注后者,但前者也可能很长:
GC 日志显示 "GC pause 500ms",但 GC 实际工作只有 50ms?
可能问题:某个线程迟迟到不了安全点,其他线程都停了在等它
常见原因:
① 超大数组的 for 循环被 JIT 判定为"可数循环",优化掉了循环内的安全点检查
→ 这个循环跑几秒都不检查 STW 标志 → 全场等它
② 大对象的长时间 System.arraycopy 等本地操作
③ 线程在没有安全点的长计算里
排查工具:-XX:+PrintSafepointStatistics(打印安全点统计,看「到达安全点耗时」)。这是一类隐蔽的性能问题——GC 本身很快,但「等线程进安全点」拖长了整体停顿。理解「STW 分两段」,才能对症下药(如调整循环、加 -XX:+UseCountedLoopSafepoints 让可数循环也插安全点)。
六、安全点与各收集器停顿的关系
不同收集器对安全点的依赖和优化程度不同:
| 收集器 | STW 与安全点 |
|---|---|
| Serial/Parallel | 整个 GC 全程 STW,所有线程停在安全点 |
| CMS/G1 | 只在初始标记、重新标记等阶段短暂 STW,其余并发 |
| ZGC | STW 极短,只做「扫描线程栈根」这类固定量工作,仍要所有线程到安全点 |
关键认知:无论多先进的收集器,「要 STW 的那一刻」都必须让所有线程到达安全点/安全区——这是枚举根、保证一致性的前提。ZGC 之所以停顿短,不是因为不用安全点,而是它把「需要 STW 才能做的工作」压缩到了极少(只扫线程栈根),其余搬移、标记、重映射都并发化了。所以安全点机制是所有收集器的共同基础,只是 STW 阶段的工作量各不相同。
记忆钩子:「安全点=引用关系一致的可暂停位置(方法调用/循环回跳/返回/抛异常处,有 OopMap);STW 靠主动式中断(线程轮询不可读的内存页,触发陷阱挂起);安全区域给睡眠/阻塞线程用(引用冻结,GC 不用等);STW 时间 = 等所有线程到安全点 + GC 实际工作」。
七、常见误区与追问
- 误区:GC 时 JVM 直接强行中断所有线程。 是「主动式中断」——线程自己跑到安全点、轮询暂停标志后主动挂起;不能在任意位置强停,否则引用信息不一致。
- 误区:每条字节码指令都是安全点。 只在方法调用、循环回跳、方法返回、抛异常等「可能长时间停留」的位置设安全点,太密会导致 OopMap 爆炸和检查开销大。
- 误区:STW 停顿时间就是 GC 工作时间。 还包括「等所有线程到达安全点」的时间——某线程迟迟到不了安全点会拖长整体停顿,即使 GC 本身很快。
- 误区:睡眠线程会阻塞 GC。 睡眠/阻塞前线程进入安全区域(引用冻结),GC 不用等它;线程离开安全区前会检查并等待 GC 完成。
- 追问:安全点和安全区域有什么区别? 安全点是「线程主动跑过去暂停」的位置(针对运行中的线程);安全区域是「线程待在里面声明自己安全」的代码区(针对 sleep/阻塞、跑不到安全点的线程)。
- 追问:为什么 GC 日志显示 GC 很快但停顿很长? 可能某线程迟迟到不了安全点(如被 JIT 优化掉安全点检查的超大可数循环),其他线程都停着等它,用 -XX:+PrintSafepointStatistics 排查。
- 追问:主动式中断为什么用「读内存页」而非 if 判断? 正常执行时读一个页几乎零开销、不影响热路径;要 STW 时把页设为不可读,线程读时触发陷阱被信号处理器批量挂起,兼顾平时高性能和暂停时的批量控制。
八、加强记忆
安全点是代码中「引用关系一致、可以安全暂停」的位置——只有在这里,JVM 才有线程的 OopMap(知道栈/寄存器里哪些是对象引用),才能安全枚举 GC Roots、移动对象。它只设在「可能长时间停留」的地方(方法调用后、循环回跳处、方法返回前、抛异常处),既够用又不过密。GC 需要 STW 时用「主动式中断」——不强停线程,而是把一个内存页设为不可读,线程在安全点轮询读这个页,触发陷阱后主动挂起(正常时几乎零开销)。对于 sleep/阻塞、跑不到安全点的线程,用安全区域——一段引用关系冻结的代码区,线程进入前声明「我安全,GC 不用等我」,离开前检查 GC 是否完成。一个关键实战认知:STW 停顿 = 等所有线程到安全点的时间 + GC 实际工作时间,某线程(如被优化掉安全点的超大循环)迟迟不到会拖长整体停顿。所有收集器(含 ZGC)在要 STW 的那一刻都必须让线程到安全点/安全区,ZGC 只是把这类工作压到极少。一句话「安全点是可暂停的一致位置、主动式中断轮询内存页、安全区域给睡眠线程、STW 含等待到点的时间」。