专家视角看JVM是如何让所有高速运行的线程“瞬间静止”

张开发
2026/4/21 3:24:01 15 分钟阅读

分享文章

专家视角看JVM是如何让所有高速运行的线程“瞬间静止”
专家视角看JVM是如何让所有高速运行的线程“瞬间静止”前言当 GC 发生时JVM 是如何让所有正在高速运行的线程“瞬间静止”的1. 核心指挥官SafepointSynchronize::begin()2. 线程如何感应主动轮询机制PollingA. 解释执行模式InterpretedB. JIT 编译模式CompiledC. 源码参考ThreadStateTransition3. 信号处理从段错误到“静止”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. 线程如何感应主动轮询机制PollingJava 线程并不是被动被中断的它们在高速运行时会不断地“查看”自己是否需要停下来。为了保证性能这种查看必须极快。为了保证性能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 的虚拟地址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_block0){// 这里的等待通常伴随有重试和超时逻辑Threads_lock-wait(true,1);}// 4. 此时所有线程已静止GC 开始_state_at_safepoint;}6. 思考为什么会有“Safepoint 导致的长停顿”我们需要识别出这种机制的局限性长循环陷阱在 OpenJDK 8 中如果是那种for (int i0; iint_max; i)这种受限循环Counted LoopJIT 编译器默认不会在循环体内插入轮询点。如果循环体执行时间很长整个 JVM 必须等这个循环跑完才能进入 Safepoint。对策使用-XX:UseCountedLoopSafepoints参数。内存页权限切换开销虽然test指令很快但mprotect系统调用和随后的信号处理Signal Handling是有成本的尤其是在数千个线程并发时。计算密集型任务大量纯数值计算可能导致轮询点间隔过长。JNI 阻塞虽然 Native 代码不阻止 GC但如果 Native 代码运行时间极长它持有的局部引用可能导致 GC 扫描耗时增加。总结静止的真谛JVM 让线程“瞬间静止”的本质是利用 OS 的内存保护机制将一个软件层面的状态检查“我该停吗”转化为一个硬件级别的陷阱Trap。这套设计的精妙之处在于在 99% 的运行时间内线程几乎不需要付出任何代价就能保持高度的警觉。当 GC 降临时它又利用了操作系统的信号机制强制原本互不干涉的线程整齐划一地进入阻塞状态。题外话为什么不直接用pthread_suspend很多资深开发者会问为什么不直接利用操作系统的信号强制挂起线程一致性强制挂起可能使线程停在任何地方比如正在修改一个 JVM 内部数据结构的中途。通过安全点JVM 确保线程停在“代码边界”上此时对象的引用关系是清晰、可枚举的。性能系统调用的上下文切换成本极高。基于内存页权限触发的异常处理SIGSEGV在正常运行时几乎是零开销的。

更多文章