
全局内存页轮询安全点机制前言全局内存页轮询安全点机制全局内存页轮询Global Polling Page安全点机制的系统级设计与演进一、 内存页初始化与权限生命周期管理源码分析一os_linux.cpp 中的轮询页分配与权限切换二、 JIT 编译器的轮询指令发射源码分析二assembler_x86.cpp 中的机器码生成三、 安全点同步与状态机转移源码分析三safepoint.cpp 中的全局同步四、 信号接管、上下文破坏与控制流重定向源码分析四os_linux_x86.cpp 中的信号劫持逻辑源码分析五汇编层面的安全点存根Stub执行流五、 全局轮询页机制的工程边界与现代演进现代演进从全局页轮询到线程局部握手Thread-Local Handshakes前言本文旨在记录近期研读Java源码的学习心得与疑难问题。由于个人理解水平有限文中内容难免存在疏漏恳请读者不吝指正全局内存页轮询安全点机制全局内存页轮询Global Polling Page安全点机制的系统级设计与演进在现代高性能 Java 虚拟机如 OpenJDK HotSpot中安全点Safepoint机制是支撑垃圾回收GC、偏向锁撤销、代码重定义RedefineClasses以及线程堆栈 Dump 等底层核心功能的基石。为了让所有处于运行状态的 Java 线程Mutators在到达安全点时能够迅速且低开销地挂起OpenJDK 8默认采用了基于硬件 MMU内存管理单元和操作系统信号机制的全局内存页轮询方案。在传统的虚拟机设计中显式的条件分支轮询如在每个循环回跳或方法退出处插入if (safepoint_pending) block();会带来双重性能惩罚首先是密集的内存读取和比较指令占用了宝贵的执行管道其次由于安全点触发的概率极低通常几分钟或几小时一次现代 CPU 的分支预测器Branch Predictor虽然在绝大多数时间里会预测“不跳转”但这种高频的无效检查依然会轻微污染分支目标缓冲BTB并在极少数安全点真正触发时造成严重的流水线冲刷Pipeline Flush。全局内存页轮询方案的核心思想是将软件层面的条件分支隐式化为硬件层面的访存行为。JVM 在启动时分配一个特定内存页Polling Page并赋予可读权限。JIT 编译器在生成机器码时只需在原有的安全点检查位置发射一条对该页面进行读取的test汇编指令。在正常运行期间该指令直接命中 L1/L2 数据缓存开销几乎为零。当 VM 线程需要发起安全点时通过系统调用mprotect将该页面的权限收回置为PROT_NONE。此时任何再次执行到该位置的应用线程都会引发 CPU 的缺页异常或段错误进而产生SIGSEGV信号。JVM 捕获该信号后在信号处理函数中篡改线程的上下文寄存器PC/RIP实现向安全点挂起存根Stub的无缝重定向。一、 内存页初始化与权限生命周期管理整个机制的起点位于虚拟机的操作系统抽象层。在 JVM 启动阶段os::init_2()函数负责完成底层平台的初始化。源码分析一os_linux.cpp中的轮询页分配与权限切换// 位于 openjdk/hotspot/src/os/linux/vm/os_linux.cppvoidos::init_2(void){// ... 省略其他庞杂的底层 OS 参数如线程栈、大页内存初始化代码 ...// 【核心步骤 1】通过 Linux 的 mmap 系统调用申请全局轮询页// 必须分配一个完整的物理内存页大小Linux x86_64 通常为 4KB// 初始权限赋予 PROT_READ可读映射类型为 MAP_PRIVATE | MAP_ANONYMOUS私有匿名映射不关联文件address polling_page(address)::mmap(NULL,Linux::page_size(),PROT_READ,MAP_PRIVATE|MAP_ANONYMOUS,-1,0);// 保证内存页必须成功挂载否则 JVM 无法建立安全点基础直接安全退出guarantee(polling_page!NULLpolling_page!MAP_FAILED,mmap failed to allocate polling page);// 将该页面的首地址记录在全局单例 os 类中供 JIT 编译器在编译期动态硬编码或间接寻址os::set_polling_page(polling_page);if(VerbosePrintMiscellaneous){tty-print([Safepoint Polling Page allocated at: INTPTR_FORMAT]\n,(intptr_t)polling_page);}// ...}// // 【核心步骤 2】安全点触发逻辑由 VMThread 独占调用// voidos::make_polling_page_unreadable(void){// 变更页面权限为 PROT_NONE无读、无写、无执行权限// 当系统内几百个 Java 线程在高频并发执行时VMThread 一旦执行此系统调用// 对应物理页在 CPU 核心中的 TLB页表缓存将会被失效TLB Shootdownif(mprotect((char*)os::get_polling_page(),Linux::page_size(),PROT_NONE)!0){fatal(err_msg(Could not disable polling page - mprotect failed with errno: %s,strerror(errno)));}}// // 【核心步骤 3】安全点恢复逻辑由 VMThread 在完成垃圾回收等任务后调用// voidos::make_polling_page_readable(void){// 恢复页面为 PROT_READ 状态后续恢复执行的 Java 线程再次执行 test 指令时将能够正常隐式通过if(mprotect((char*)os::get_polling_page(),Linux::page_size(),PROT_READ)!0){fatal(err_msg(Could not enable polling page - mprotect failed with errno: %s,strerror(errno)));}}二、 JIT 编译器的轮询指令发射当 Java 字节码被编译为本地机器码C1 或 C2 编译器时虚拟机会在三个关键点插入安全点轮询指令Safepoint Polls方法入口Method Entry如果方法栈帧较大防止大方法长期占据 CPU。方法退出Method Exit / Return在方法返回给调用者前进行检查。循环回跳点Loop Backedge防止非计数长循环Non-counted loops导致线程长期无法暂停。源码分析二assembler_x86.cpp中的机器码生成在 x86_64 架构下HotSpot 为了追求极致的高并发访问速度通常不直接在汇编里写死绝对的物理页地址而是利用线程本地存储TLS指针寄存器r15在 HotSpot 中专用于指向当前JavaThread对象。在JavaThread内部缓存了当前全局轮询页的地址。// 位于 openjdk/hotspot/src/cpu/x86/vm/assembler_x86.cppvoidMacroAssembler::safepoint_poll(Labelslow_path,Register thread_reg){// 判断当前 JVM 是否启用了全局页轮询机制默认开启if(SafepointSynchronize::do_call_back()){// 采用底层重定位标记方便代码缓存CodeCache的管理与GC扫描relocate(relocInfo::poll_type);// 【核心机器码发射】生成 test 指令// 汇编表现testl %eax, [r15 offset_to_polling_page]// 作用分析// r15 寄存器始终存放着当前线程的 JavaThread 结构体指针。// thread_reg 通常即为 r15polling_page_offset() 是该结构体内存储的 Polling Page 指针的偏移量。// 该指令读取 Polling Page 地址处的 4 字节数据并与 eax 寄存器做逻辑与AND运算。// 注意这里仅仅是为了引发一次“读内存”的操作并不关心运算结果也不会修改任何通用寄存器的值。testl(rax,Address(thread_reg,JavaThread::polling_page_offset()));}else{// 如果没有启用该机制则退化为昂贵的显式内存加载与分支跳转cmp32(Address(thread_reg,JavaThread::safepoint_state_offset()),SafepointSynchronize::_synchronizing);jcc(Assembler::equal,slow_path);}}三、 安全点同步与状态机转移当 JVM 决定发起全局安全点时VMThread会驱动整个状态机的运转。这个过程的核心在于保证“页面权限剥夺”与“线程计数对齐”的原子性。源码分析三safepoint.cpp中的全局同步// 位于 openjdk/hotspot/src/share/vm/runtime/safepoint.cppvoidSafepointSynchronize::begin(){// ... 检查并设置全局状态为 _synchronizing正在同步中...EventSafepointBegins_event(UNTIMED);// 关键临界区此时 VMThread 持有 Threads_lock 锁assert(Thread::current()-is_VM_thread(),Must be VMThread);// 【硬阻断开始】剥夺内存页权限// 这一步执行完毕后所有处于 _thread_in_Java 状态正在执行纯 Java 编译代码的线程// 只要触碰到上述的 testl 指令就绝无可能跨越百分之百会陷入 OS 内核异常os::make_polling_page_unreadable();// ...// 【线程状态大轮询】VMThread 开始循环检查系统内所有线程的状态// Java 线程在 JVM 中有多种存在状态// 1. _thread_in_Java正在执行 Java 编译码会被上述的 mprotect 机制强制拦截// 2. _thread_in_native正在执行 JNI 本地代码。由于本地代码无法访问 Java 堆// 它们不会破坏内存一致性因此允许其继续执行但当它们试图从 JNI 返回 Java 空间时// 会在返回检查JNI Handle Block处被拦截挂起。// 3. _thread_blocked本身已被阻塞如等待锁、睡眠直接被视为已处于安全点。for(JavaThread*curThreads::first();cur!NULL;curcur-next()){// 如果线程处于运行态VMThread 将等待其硬件陷入if(!cur-is_thread_safepoint_safe()){// 通过死循环或退避Backoff策略等待未到达安全点的 Java 线程计数归零}}// 更改全局状态为 _synchronized表示安全点完全建立GC 线程等可以安全切入_state_synchronized;}四、 信号接管、上下文破坏与控制流重定向这是整个机制中最精妙、也是最考验内核级系统工程能力的部分。当 Java 线程在执行testl时由于页面权限为PROT_NONEMMU 无法将该虚拟地址映射到具备可读属性的物理页表项直接向 CPU 报告缺页/越权故障。Linux 内核捕获后向当前执行线程投递一个SIGSEGV段错误信号。JVM 在启动时就已经通过sigaction注册了统一的信号处理函数JVM_handle_linux_signal。该函数在信号发生时被内核回调并传入了一个极为关键的参数void* ucVoid它实质上是一个指向ucontext_t结构体的指针。这个结构体完整保存了该线程被信号中断那一刻所有 CPU 寄存器的快照如 RIP、RSP、RAX 等。源码分析四os_linux_x86.cpp中的信号劫持逻辑// 位于 openjdk/hotspot/src/cpu/x86/vm/os_linux_x86.cppintJVM_handle_linux_signal(intsig,siginfo_t*info,void*ucVoid,intabort_if_unrecognized){// 将未定义类型指针强转为 Linux 平台的本地上下文结构ucontext_t*uc(ucontext_t*)ucVoid;// 从上下文结构体中抽取触发 SIGSEGV 时的硬件指令指针PC在 x86_64 上即为 RIP 寄存器address pc(address)os::Linux::ucontext_get_Pc(uc);if(sigSIGSEGV){// 从 siginfo_t 中获取引发异常的源目标内存地址MMU 汇报的 Fault Addressaddress fault_address(address)info-si_addr;// 【核心校验 1】判断发生错误的内存地址是否正好落在我们分配的全局轮询页范围内if(os::is_poll_address(fault_address)){// 【核心校验 2】判断发生异常的 PC 指令是否是一个合法的安全点轮询位置// 内部会去验证该 PC 是否位于 CodeCache 之中且其对应的元数据确实是一个 Poll Instructionif(SafepointSynchronize::is_poll_address(fault_address)||(SafepointSynchronize::is_synchronizing()cur_thread-thread_state()_thread_in_Java)){// 根据触发段错误时的 PC从虚拟机运行时运行时库中获取对应的“安全点处理存根 Stub”// 这个 Stub 是一段由虚拟机在启动时动态生成的、全汇编编写的代码块Safepoint Blobaddress stubSharedRuntime::get_poll_stub(pc);if(stub!NULL){// 【核心控制流篡改】这是最核心的系统级 Hook 技巧// 如果我们原封不动地返回系统将会重新执行引发错误的 testl 指令从而导致死循环段错误。// 此时我们直接改写 ucontext_t 寄存器结构体中的 PCRIP值将其强行赋值为安全点 Stub 的入口地址。os::Linux::ucontext_set_Pc(uc,stub);// 返回 true即 1告知 Linux 内核这个 SIGSEGV 已经被用户态程序内部消化并妥善处理了。// 内核在收到此返回值后会调用 sigreturn 系统调用恢复该线程运行。// 但在恢复时内核会将我们修改后的 ucontext_t 寄存器镜像重新刷入 CPU 硬件核心。// 导致的结果是线程一恢复立刻在用户态跳跃到寄存器指向的全局安全点 Stub 中去执行returntrue;}}}}// 如果不是虚拟机预期的安全点轮询引发的 SIGSEGV说明程序真的发生了空指针异常、堆栈溢出或内存非法访问// 此时将流转到 HotSpot 标志性的 VMError::report_and_die 崩溃处理逻辑生成 hs_err_pid.logreturnreport_and_die_on_other_handlers(sig,info,ucVoid);}源码分析五汇编层面的安全点存根Stub执行流一旦线程被信号重定向到SharedRuntime::get_poll_stub(pc)指向的动态汇编片它将执行以下底层操作# 概念性伪汇编逻辑摘自 HotSpot 动态运行时生成器 RuntimeBlob 构造期代码 # 目标保护现场彻底让出 CPU 控制权 # 1. 在当前物理栈上压入全部的通用寄存器RAX, RBX, RCX, RDX, RSI, RDI, R8-R15 以及 RFLAGS pushq %rax pushq %rcx # ... 保存 Java 线程当前的完整物理现场 ... # 2. 调用虚拟机 C 运行时的实际阻塞逻辑 # 传入当前线程的指针此时通常已通过前面的压栈或者寄存器规范化 callq SafepointSynchronize::block # 3. 在 block 内部线程的状态会被正式变更为 _thread_blocked # 随后线程将在 VMThread 释放信号量或恢复内存页可读前陷入 OS 级别的 Cond Var条件变量等待中。 # 当垃圾回收彻底结束VMThread 将其唤醒状态恢复为 _thread_in_Java。 # 4. 恢复现场当从 block 返回时说明安全点已经解除大门重新打开 popq %rcx popq %rax # ... 完美恢复被中断那一刻的全部寄存器状态 ... # 5. 最终返回回到原来触发安全点的下一条 Java 字节码编译指令继续无缝执行 retq五、 全局轮询页机制的工程边界与现代演进作为系统工程师在深度审视 OpenJDK 8的这套方案时需要清晰地看到其在特定硬件与大规模并发场景下的架构限制Architecture Limitations内核态切换的极端开销Slow Path Penalty虽然正常运行期间Fast Path只有一条test指令开销极低。但一旦发起安全点Slow Path每一个正在运行的 Java 线程触发SIGSEGV都意味着一次从用户态到内核态的上下文中断。如果一个 JVM 进程内运行着数万个线程例如高并发的网络应用或早期的微服务群发起安全点时将会引发严重的信号风暴Signal StormOS 内核需要同时处理成千上万个线程的信号分发与ucontext_t拷贝导致安全点建立时间Time To Safepoint, TTSP急剧飙升。全局阻断的粗粒度STW 语义过重mprotect是针对整个内存页进行权限控制的。这意味着一旦页面变更为不可读所有的Java 线程都必须停下来。在很多场景下例如只需要撤销某一个特定线程持有的偏向锁或者只需要扫描某一个线程的局部栈这种“一人犯错全家连坐”的全局阻断带来了不必要的长暂停。现代演进从全局页轮询到线程局部握手Thread-Local Handshakes正是因为上述由于“全局单页配置 内核信号引入”带来的高并发瓶颈OpenJDK 释出的后续高版本从 JDK 10 开始引入并在 JDK 11 中完全成熟中引入了线程局部握手Thread-Local Handshakes技术。新机制抛弃了全局唯一的共享内存轮询页转而为系统内的每一个 Java 线程独立分配一个独占的轮询地址Thread-Local Polling Page。当虚拟机只需要暂停或检查特定的线程 A 时VMThread 只会通过系统调用将线程 A 独占的那一个内存页属性修改为PROT_NONE而其他所有线程由于其独占的轮询页依然保持可读权限完全不会触发SIGSEGV能够继续以最高速并行运转。这一演进彻底消除了长 TTSP 风险将现代 JVM 的响应延迟和高并发控制能力推向了新的巅峰。