专家视角看JVM是如何让所有高速运行的线程“瞬间静止”
- 前言
- 当 GC 发生时,JVM 是如何让所有正在高速运行的线程“瞬间静止”的
- 1. 核心指挥官:`SafepointSynchronize::begin()`
- 2. 线程如何感应:主动轮询机制(Polling)
- A. 解释执行模式(Interpreted)
- B. JIT 编译模式(Compiled)
- C. 源码参考:`ThreadStateTransition`
- 3. 信号处理:从段错误到“静止”
- 4. 线程状态的“分而治之”
- 5. 源码关键点回顾 (hotspot/src/share/vm/runtime/safepoint.cpp)
- 6. 思考:为什么会有“Safepoint 导致的长停顿”?
- 总结:静止的真谛
- 题外话
- 为什么不直接用 `pthread_suspend`?
前言
本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。
当 GC 发生时,JVM 是如何让所有正在高速运行的线程“瞬间静止”的
在 JVM 的世界里,让所有线程“瞬间静止”的操作,是一场由VM Thread发起、全体 Java 线程高度配合的“优雅停步”。Global Safepoint(全局安全点)机制是实现“Stop The World”(STW)的幕后推手。我们需要看穿 OpenJDK 8如何利用操作系统的内存分页(Memory Paging)和硬件指令级的巧妙配合,将数千个高速运转的线程在毫秒级时间内精准拦截。
在 OpenJDK 8源码中,这一机制的实现核心隐藏在safepoint.cpp、interpreterRuntime.cpp以及 JIT 编译器的指令序列中。
1. 核心指挥官:SafepointSynchronize::begin()
当 GC 线程(如 CMS、G1 的标记阶段或 Parallel GC 的整理阶段)发出 STW 请求时,VM Thread(一个特殊的内核线程)会调用hotspot/src/share/vm/runtime/safepoint.cpp中的SafepointSynchronize::begin()方法。
这是“静止”过程的总控逻辑:
- 设置状态位:将全局变量
_state设为_synchronizing。 - 翻转轮询页(Polling Page)权限:这是最绝妙的物理操作。JVM 会通过
os::make_polling_page_unreadable()调用系统的mprotect,将一个预先分配好的4KB 轮询页面设置为“不可读”。 - 等待所有线程挂起:VM Thread 会循环检查
_waiting_to_block计数器,直到所有正在运行的线程都进入安全点状态。
2. 线程如何感应:主动轮询机制(Polling)
Java 线程并不是被动被中断的,它们在高速运行时会不断地“查看”自己是否需要停下来。为了保证性能,这种查看必须极快。为了保证性能,JVM 不能在业务代码里加锁。它采用了一种**“主动轮询”**的硬件策略。
A. 解释执行模式(Interpreted)
在解释器中,JVM 在字节码的分派表(Dispatch Table)中做了手脚。在hotspot/src/cpu/x86/vm/templateTable_x86_64.cpp中,解释器在执行特定的字节码(如return、goto、backward branch)时处插入一段逻辑。它会去读取一个特定的内存地址——Polling Page。
- 当
SafepointSynchronize开启时,解释器会将当前的执行跳转到InterpreterRuntime::at_safepoint。 - 每当执行完一条字节码,或者在循环的跳转(Backedge)处,解释器都会检查这个状态。
B. JIT 编译模式(Compiled)
对于经过 C1/C2 优化的代码,JVM 会在代码块中插入物理指令。
- 指令插入点:通常在方法返回处(Return)和非计数循环的回跳处(Loop backedge)。
- 汇编代码:JIT 编译器(C1/C2)会在生成的机器码中插入一条看似无用的指令。
- 正常情况下:这个地址(Polling Page)是可读的,
test指令执行只需几个 CPU 周期,几乎零开销。 - GC 发生时:如前所述,VM Thread 将该页设为不可读。此时,任何执行到这里的线程都会触发一个SIGSEGV(段错误)信号。
- 在 x64 架构下,它通常长这样:
test %eax, 0x1601000 ; 0x1601000 是 Polling Page 的虚拟地址
- 正常情况下:这个地址(Polling Page)是可读的,
C. 源码参考:ThreadStateTransition
在interfaceSupport.hpp中,ThreadStateTransition::transition负责在进入/离开 VM 时检查安全点。如果 GC 正在进行,该宏会阻塞线程,防止其在不恰当的时机回到 Java 世界。
3. 信号处理:从段错误到“静止”
当线程触发了 SIGSEGV 信号,它并不会崩溃,而是控制权瞬间转移到操作系统的信号处理器(Signal Handler)。JVM 在os_linux_x86.cpp中预先注册了JVM_handle_linux_signal。
路径:
hotspot/src/os/linux/vm/os_linux.cpp中的JVM_handle_linux_signal。逻辑:信号处理器识别出这是由于 Safepoint Polling Page 导致的异常,于是它会将该线程的上下文(Context)保存起来,并将线程状态从
_thread_in_Java切换为_thread_blocked。阻塞:线程随后会在一个信号量或条件变量上
wait,直到 GC 完成,VM Thread 重新将轮询页设为可读并发出唤醒信号。简单的处理流程:
- 识别陷阱:信号处理器发现报错地址正是 Polling Page。
- 上下文重定向:它不是让程序崩溃,而是修改该线程的指令指针(RIP),将其导向一段预先备好的代码:
SafepointSynchronize::block()。 - 线程状态转换:在
block()内部,线程会将自己的状态从_thread_in_Java切换为_thread_blocked。
4. 线程状态的“分而治之”
JVM 并不需要等“所有”线程都执行到轮询点。根据线程当前所处的环境,JVM 采取了不同的策略:
| 线程当前状态 | JVM 处理策略 | 是否需要等待其到达轮询点? |
|---|---|---|
| 执行 Java 字节码 | 必须等待执行到下一个test指令或字节码边界。 | 是(这是导致 Safepoint 停顿过长的主要原因)。 |
| 执行 Native 代码 (JNI) | 它们已经不在 Java 环境中,不影响 GC。但当它们从 JNI 返回欲切回 Java 状态时,会检查全局安全点标志。如果正在 GC,它们会原地“挂起”。 | 否。但该线程从 Native 返回 Java 时会检查状态并阻塞。 |
| 已阻塞 (Blocked/Waiting) | 视为已在安全点。VMThread只需要确认它们的状态并将其“锁定”,不允许在 GC 期间唤醒即可。 | 否。 |
| 正在执行 VM 内部逻辑 | 会在完成当前原子操作后,在ThreadStateTransition处检查状态并自愿挂起。 | 是。 |
5. 源码关键点回顾 (hotspot/src/share/vm/runtime/safepoint.cpp)
// 简化后的逻辑逻辑voidSafepointSynchronize::begin(){// 1. 改变状态_state=_synchronizing;// 2. 使轮询页不可读 (触发 SIGSEGV)os::make_polling_page_unreadable();// 3. 循环等待所有线程while(_waiting_to_block>0){// 这里的等待通常伴随有重试和超时逻辑Threads_lock->wait(true,1);}// 4. 此时,所有线程已静止,GC 开始!_state=_at_safepoint;}6. 思考:为什么会有“Safepoint 导致的长停顿”?
我们需要识别出这种机制的局限性:
- 长循环陷阱:
在 OpenJDK 8 中,如果是那种for (int i=0; i<int_max; i++)这种受限循环(Counted Loop),JIT 编译器默认不会在循环体内插入轮询点。如果循环体执行时间很长,整个 JVM 必须等这个循环跑完才能进入 Safepoint。- 对策:使用
-XX:+UseCountedLoopSafepoints参数。
- 对策:使用
- 内存页权限切换开销:
虽然test指令很快,但mprotect系统调用和随后的信号处理(Signal Handling)是有成本的,尤其是在数千个线程并发时。 - 计算密集型任务:大量纯数值计算可能导致轮询点间隔过长。
- JNI 阻塞:虽然 Native 代码不阻止 GC,但如果 Native 代码运行时间极长,它持有的局部引用可能导致 GC 扫描耗时增加。
总结:静止的真谛
JVM 让线程“瞬间静止”的本质是:利用 OS 的内存保护机制,将一个软件层面的状态检查(“我该停吗?”)转化为一个硬件级别的陷阱(Trap)。
这套设计的精妙之处在于,在 99% 的运行时间内,线程几乎不需要付出任何代价就能保持高度的警觉。当 GC 降临时,它又利用了操作系统的信号机制,强制原本互不干涉的线程整齐划一地进入阻塞状态。
题外话
为什么不直接用pthread_suspend?
很多资深开发者会问:为什么不直接利用操作系统的信号强制挂起线程?
- 一致性:强制挂起可能使线程停在任何地方(比如正在修改一个 JVM 内部数据结构的中途)。通过安全点,JVM 确保线程停在“代码边界”上,此时对象的引用关系是清晰、可枚举的。
- 性能:系统调用的上下文切换成本极高。基于内存页权限触发的异常处理(SIGSEGV)在正常运行时几乎是零开销的。