线程进入safepoint机制解析
前言
本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限,文中内容难免存在疏漏,恳请读者不吝指正。
线程进入Safepoint机制概述
并不是所有的 Java 线程都在执行 JIT 编译后的机器码。JVM 将执行状态分为以下四大类,它们进入 Safepoint 的机制各不相同:
| 线程当前执行状态 | 进入 Safepoint 的机制原理 |
|---|---|
| Compiled (JIT 编译码) | 执行到由 C1/C2 插入的 Polling 指令,触发 SIGSEGV 信号中断进入。 |
| Interpreted (解释执行) | 字节码解释器(Template Interpreter)拥有一张派发表(Dispatch Table)。当触发 Safepoint 时,VM Thread 会把正常的派发表替换为 Safepoint 专用派发表。这样解释器在执行下一条字节码指令时,会自动路由到安全点处理代码。 |
| Native (JNI 运行中) | 执行 Native 代码的线程不需要立即挂起,因为 Native 代码不会直接修改 Java 堆对象。VM Thread 会直接将其标记为“已处于安全点”。但当该 Native 线程执行完毕,准备**返回 Java 世界(Transition Back)**时,会在边界处检查 Safepoint 状态,若仍处于 STW,则在该边界处被挂起。 |
| Blocked (阻塞/睡眠状态) | 线程处于 Object.wait()、Thread.sleep() 或等待锁(Mux/Lock)状态。这类线程同 Native 一样,被 VM 视为默认安全。它们在被唤醒或者获取到锁准备恢复运行的瞬间,也会进行边界检查并阻塞。 |
处于Interpreted(解释执行)线程进入safepoint机制解析
当 Java 线程处于 Interpreted(解释执行)状态时,它并未被编译为本地机器码,而是由 HotSpot 的模板解释器(Template Interpreter)逐条将字节码(Bytecode)翻译为机器指令并执行。
在这种状态下,JVM 无法使用类似于 JIT 编译码的硬件缺页中断(Polling Page)机制,因为解释执行的底层逻辑本身就是一个高度抽象的代码循环。为了让解释执行的线程以极低的开销、极高的实时性进入 Safepoint,OpenJDK 8 设计了一套双派发表动态切换(Dispatch Table Swapping)机制。
解释执行状态下 Safepoint 触发与流转全景
在解释器运行期,每个字节码执行完毕后,都必须通过一个“派发表”跳转到下一个字节码的执行入口。HotSpot 正是利用了这一必经之路实现安全点拦截:
[正常执行模式]
Bytecode (e.g., _iload) ---> 查找 _active_table (_normal_table) ---> 执行 iload 对应的汇编机器码 ---> dispatch_next
[VM Thread 发起 Safepoint]
VM_Thread ---> 调用 Interpreter::notice_safepoints() ---> 将 _active_table 指针重定向到 _safe_table
[拦截流转模式]
Bytecode (e.g., _iload) ---> 查找 _active_table (_safe_table) ---> 路由到安全点拦截桩 (Safepoint Codelet)
|
v
InterpreterRuntime::at_safepoint()
|
v
线程状态变更为 _thread_blocked 并挂起
一、 VM Thread 触发源头:全局动态重定向派发表
当 VM Thread 启动全局安全点请求(例如要执行 GC)时,会调用 SafepointSynchronize::begin()。针对解释执行线程,它会通过修改全局指针,将正常状态下的字节码派发表,一键替换为带安全点检查的“安全派发表”。
源码路径:hotspot/src/share/vm/runtime/safepoint.cpp
void SafepointSynchronize::begin() {
assert(Thread::current()->is_VM_thread(), "Only VM thread can execute this");
// ... 省略非核心的锁流转与计数器维护代码 ...
// 关键步骤 1:通知模板解释器,全局安全点已开启
if (UseCompilerSafepoints && UseLoopCounter) {
// 改变解释器的全局分派策略
Interpreter::notice_safepoints();
}
// ... VM Thread 进入自旋或等待状态,直到所有线程(包括解释线程)报告已进入 Safepoint ...
}
源码路径:hotspot/src/share/vm/interpreter/templateInterpreter.cpp
void TemplateInterpreter::notice_safepoints() {
if (Universe::is_fully_initialized()) {
// _active_table 是当前解释器正在使用的活动派发表指针(本质是一个多维数组,存放各字节码的入口机器码地址)
// _safe_table 是在 JVM 启动初始化时就预先生成好的、带有安全点前置检查的派发表
// 此处直接完成指针的原子级替换,后续任何线程一旦执行“下一条”字节码分派,都将走入安全点逻辑
_active_table = _safe_table;
}
}
二、 线程感知:字节码分派期的无感拦截
正在执行 Java 字节码的线程,其核心循环由 dispatch_next 汇编代码控制。当它试图迈向下一个字节码边界时,会动态从当前的 _active_table 寻址。由于上一步指针已被 VM Thread 掉包,拦截在此处自然发生。
源码路径:hotspot/src/cpu/x86/vm/interpreterMacroAssembler_x86_64.cpp
void InterpreterMacroAssembler::dispatch_next(TosState state, int step) {
// step 代表当前字节码的长度,用于推进 BCP (Bytecode Pointer)
// 1. 将下一个目标字节码的 Opcode(操作码,如 0x15 代表 iload)加载到 rbx 寄存器
load_unsigned_byte(rbx, Address(rsi, step));
// 2. 将 BCP 指针(rsi 寄存器)向前推进,指向该目标字节码
increment(rsi, step);
// 3. 执行核心分派:基于最新的 _active_table 基地址进行间接跳转
// 汇编级表现为:jmp [base_of_active_table + rbx * 8]
// 如果此时 _active_table 已经是 _safe_table,即便 rbx 里面是普通的 iload,
// 最终跳入的也不是 iload 的常规执行机器码,而是预设的安全点前置拦截桩 (Codelet)
dispatch_base(state, Interpreter::dispatch_table());
}
三、 运行时劫持:进入安全点桩与状态跃迁
一旦跳入 _safe_table 所指向的特制拦截桩,线程不能继续往下执行任何 Java 业务逻辑,它会保存当前的执行上下文,并立刻通过 InterpreterRuntime 跃迁到虚拟机内部(VM 运行时)。
源码路径:hotspot/src/share/vm/interpreter/interpreterRuntime.cpp
// IRT_ENTRY 是一个功能强大的包装宏(Interpreter Runtime Entry)
// 当解释执行的线程通过 call 指令冲进这个 C++ 函数时,该宏会自动处理以下两件核心事务:
// a) 将当前线程的状态由 _thread_in_Java 正式变更为 _thread_in_vm
// b) 在栈帧中妥善锚定当前的最后一个 Java 帧(Last Java Frame),以便 GC 能够准确扫描根集合 (Root Set)
JRT_ENTRY(void, InterpreterRuntime::at_safepoint(JavaThread* thread))
// 关键步骤 2:判断当前是否真的处于全局同步状态
// 如果是,则将当前线程挂起,直到安全点操作结束
SafepointSynchronize::block(thread);
JRT_END
四、 最终归宿:将线程置于 _thread_blocked 并进入OS级挂起
进入 SafepointSynchronize::block 后,解释线程已经处于 _thread_in_vm。但为了让 VM Thread 彻底放心执行垃圾回收等独占性操作,该线程必须进一步将自己降级为“完全阻塞”状态,并在操作系统的条件变量上冬眠。
源码路径:hotspot/src/share/vm/runtime/safepoint.cpp
void SafepointSynchronize::block(JavaThread *thread) {
// ... 验证合法性检查 ...
// 1. 备份原有的线程状态(此时一般为 _thread_in_vm)
JavaThreadState state = thread->thread_state();
// 2. 关键状态置换:将线程状态变更为 _thread_blocked(完全阻塞)
// 一旦此状态写入成功,VM Thread 在扫描全局线程树时,就会认为该解释线程已经到达安全状态
thread->set_thread_state(_thread_blocked);
// 3. 阻塞的核心循环:只要全局安全点状态没有被释放(即不等于 _not_synchronized)
// 线程就会在底层的信号量或条件变量上无限期等待
while (_state != _not_synchronized) {
// 线程在此处交出 CPU 控制权,进入休眠状态,等待 VM Thread 唤醒
wait_for_safepoint_notification();
}
// ------------------ 当 GC 或其他 VM 操作结束,VM Thread 调用了 end() ------------------
// 4. 安全点解除后,恢复线程原本的状态,重新将控制权交还给 Java 线程流
thread->set_thread_state(state);
// 退出此方法后,重返解释器,切换回正常的 _normal_table 派发表,继续后续字节码的高速执行
}
解释状态与编译状态进入Safepoint的本质对比
为了更直观地理解系统工程师在排查 Safepoint 问题时的落脚点,我们可以将 Interpreted 与 Compiled 两种状态下的行为做个深度横向对比:
| 特性维度 | Interpreted (解释执行状态) | Compiled (JIT 编译码状态) |
|---|---|---|
| 检测触发媒介 | 软件层面:动态掉包底层的指针数组基地址(_active_table = _safe_table) | 硬件层面:修改全局物理内存页权限,使之引发信号异常(PROT_NONE) |
| 最长就位延迟 | 极低(按单个字节码计):下一条字节码分派时立刻拦截(通常仅需数个 CPU 周期) | 可能极高:必须等待执行到方法返回(Return)或非计数循环的回边(Backedge) |
| 典型性能损耗 | 完全零损耗:正常运行时,直接走 _normal_table,无任何前置条件判断开销 | 极微小开销:在机器码中固化了 testl 读指令,占用了极其微小的指令流水线周期 |
| 长循环锁死风险 | 无风险:即便存在大循环,只要在循环内不断执行字节码,就会在两个字节码之间被拦截 | 高风险:若 C2 编译器将标准计数长循环(Counted Loop)中的安全点消除,则会导致严重停顿 |
在复杂的生产环境中,虽然解释执行状态的线程进入 Safepoint 极其迅速且稳定,但在高并发、高负载的应用里,频繁在 VM 状态与 Java 状态间进行切换(特别是伴随偏向锁撤销或 JVMTI 监控等微型安全点)依然可能引发 Interpreter 层的分派颠簸。

687

被折叠的 条评论
为什么被折叠?



