全局内存页轮询安全点机制
- 前言
- 全局内存页轮询安全点机制
- 全局内存页轮询(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!=NULL&&polling_page!=MAP_FAILED,"mmap failed to allocate polling page");// 将该页面的首地址记录在全局单例 os 类中,供 JIT 编译器在编译期动态硬编码或间接寻址os::set_polling_page(polling_page);if(Verbose&&PrintMiscellaneous){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 Shootdown)if(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(Label&slow_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 通常即为 r15,polling_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*cur=Threads::first();cur!=NULL;cur=cur->next()){// 如果线程处于运行态,VMThread 将等待其硬件陷入if(!cur->is_thread_safepoint_safe()){// 通过死循环或退避(Backoff)策略,等待未到达安全点的 Java 线程计数归零}}// 更改全局状态为 _synchronized,表示安全点完全建立,GC 线程等可以安全切入_state=_synchronized;}四、 信号接管、上下文破坏与控制流重定向
这是整个机制中最精妙、也是最考验内核级系统工程能力的部分。当 Java 线程在执行testl时,由于页面权限为PROT_NONE,MMU 无法将该虚拟地址映射到具备可读属性的物理页表项,直接向 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(sig==SIGSEGV){// 从 siginfo_t 中获取引发异常的源目标内存地址(MMU 汇报的 Fault Address)address 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 Blob)address stub=SharedRuntime::get_poll_stub(pc);if(stub!=NULL){// 【核心控制流篡改】:这是最核心的系统级 Hook 技巧!// 如果我们原封不动地返回,系统将会重新执行引发错误的 testl 指令,从而导致死循环段错误。// 此时,我们直接改写 ucontext_t 寄存器结构体中的 PC(RIP)值,将其强行赋值为安全点 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 Storm),OS 内核需要同时处理成千上万个线程的信号分发与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 的响应延迟和高并发控制能力推向了新的巅峰。