引言

每个Java开发都需要和GC打交道,因为Java虚拟机主要为Java程序员提供代码执行、代码编译以及内存管理三大功能,内存管理又主要分为内存使用和内存回收两个方面,而自动化GC就是内存管理中回收的主要实现方式,但是想要让Java虚拟机进入GC还需要安全点的加持才能顺利实现,那安全点到底是个什么东西,Java虚拟机又是怎么通过安全点进入GC的?

暂停线程的机制

既然安全点的作用是用来暂停正在执行的线程的,那么我们先来思考怎么让正在执行的线程暂停下来的问题。

编码层面

如果我们是使用别人开发设计好的编程语言来实现线程暂停,那必然需要在编码层面进行 显式地写入状态检测和暂停线程执行的代码,我相信如下的示例代码,大家一定都非常熟悉了:

public class ProducerConsumer {
    private final Queue<String> queue = new LinkedList<>();
    private final int capacity = 5;
    private final Lock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();   // 缓冲区未满
    private final Condition notEmpty = lock.newCondition();  // 缓冲区非空

    public void produce(String item) throws InterruptedException {
        lock.lock();
        try {
            while (queue.size() == capacity) {
                System.out.println("缓冲区满,生产者等待...");
                notFull.await();
            }
            queue.offer(item);
            System.out.println("生产: " + item);
            notEmpty.signal(); // 唤醒消费者
        } finally {
            lock.unlock();
        }
    }
}

queue.size() == capacity 就是状态检测代码,而notFull.await()则是控制线程进行暂停的功能代码。

语言层面

我们往更深一层,在语言层面如果我们希望暂停线程,那又要怎么实现呢?最简单的方式就是在执行的代码指令中插入状态检测和线程暂停的指令:我们都知道代码会经过词法分析和语法分析最终生成抽象语法树,在生成语法树的时候就可以寻找合适的节点插入对应的检测代码节点,在后续的代码生成环节就会生成对应的指令,这种隐式地指令注入对使用语言进行编码的程序员是完全透明不可见的。

但是往指令中插入检测代码需要考虑的另一个问题是布点的频率到底是多高?比如在每一条生成的指令后面都插入检测指令?还是按比例每多少条指令就插入一条检测指令?这里需要考虑到的就是性能和实时性之间的取舍,如果布点太密,对性能肯定会造成不小的影响,如果布点太少,那么想要暂停所有的线程就需要等待比较久的时间。

所以一般的解决方法就是在代码的非计数循环回边函数返回处布上安全点检测代码,因为这些地方正是会导致代码长时间运行的区间卡点,比如非计数循环由于无法预测循环次数所以必须加上安全点,相对比,计数循环由于循环次数较少并可预测则无需插入安全点(当然在某些性能场景下为了防止安全点进入的时间过长也可以对计数循环插入安全点)。

也许读者会问,为什么不在长代码中间插入安全点?因为代码的长度是有限的,特别是Java方法受Java字节码文件格式的限制,代码长度最多也就65535字节,其中的代码指令在cpu流水线中执行是非常快的,所以没有必要在长代码中间插入安全点。

机器代码层面

让我们继续往机器代码层面深入。这时我们需要为检测节点生成对应的机器代码,我们先考虑暂停线程的代码实现,由于代码在执行时暂停线程的几率和常规代码执行的几率相比是相对较小的,所以暂停线程的代码基本没有什么性能方面的顾虑,可以把暂停线程的代码做成通用的处理例程——从检测节点跳转到此例程入口就可以执行对应的暂停功能指令。而检测指令的生成就需要考虑性能问题了,因为频繁的状态检测会对常规代码指令的执行造成一定的影响,通常的实现方式有两种:

  • comp addr val + jmp addr: 显式地检测内存数据配合分支跳转指令完成对状态的检测以及跳转到暂停例程入口
  • test addr val:隐式地检测内存指令,当内存地址非法时会触发指令异常从而生成SIGSEGV段异常信号,由信号处理器引导跳转到暂停例程入口,而非法地址的检测由cpu中的mmu内存管理单元完成,其在对虚拟地址映射到物理地址时会检测地址的权限(读写执行),遇到非法地址时触发异常,这是完全借助cpu和操作系统功能的一种实现机制

接下来我们分析一下这两种实现方式在cpu流水线中执行时的差异,对于第一种实现方法,需要一次内存读取操作和一次分支判断,而对于第二种实现方式,需要一次内存读取操作和一次简单ALU计算:

  • 内存读取操作:
    • 对于内存读取操作,在能够命中L1缓存的情况下也需要最少3~4个周期的延迟,而如果不命中则需要承受从低层级存储设备读取数据的超长延迟惩罚。不过好在现代cpu使用超标量流水线对指令进行并行执行,而这个安全点状态数据的内存读取必然跟常规业务逻辑的代码指令不存在读写依赖(参考上节隐式地指令注入),也就是说这个内存读取指令完全可以并发地被流水线所执行,所以这种内存读取所带来的延迟可以被完全隐藏掉。
    • 但是如果常规业务逻辑的代码指令太少或太简单,以至于此内存读取操作替代了常规业务指令变成了流水线执行的关键路径,那么这种情况下内存读取带来的延迟就无法被完成隐藏,当然,这种简单业务代码一般会被编译器进行内联优化掉,或者通过其他的优化手段避免在这种代码尾部插入安全点检测代码,此相关内容不再展开详述。
  • 分支判断操作:
    • 第一种实现方法需要一次分支判断,在分支预判成功的情况下最多也就是2个周期的延迟,虽然只比一次简单ALU计算(1个周期)的延迟稍微大一点,但是当分支预判错误的情况下,带来的延迟惩罚就是10~20个周期,而且分支预判错误带来的延迟无法被流水线并行执行所隐藏,因为分支预判错误后需要通过“气泡”丢弃掉正在执行的错误指令并重新发射正确的指令到流水线中,这会实打实地影响正常业务代码指令的执行。
    • 再者,分支预判除了带来延迟影响,还会额外占用流水线上执行单元的资源端口,分支端口是比较宝贵的资源端口,正常cpu拥有多个ALU计算单元,而只有一到两个分支预测单元,这个必然也会从资源层面上影响到流水线的整体吞吐。

所以第二种实现方式在某些方面上是优于第一种实现方式的,并在其他优化手段辅助下可以明显减少状态判断带来的性能影响。

在这里插入图片描述

cpu-clock-cycles的参考图

另外需要说明的是,第二种实现方式在不需要执行线程暂停时性能是更优的,但是当需要执行线程暂停时因为使用了异常机制,其带来的性能损耗会比简单的comp and jump高,毕竟当流水线检测到指令异常时也需要按照发射顺序中止掉异常指令后的正常指令并重新发射异常处理的相关指令,转而引导系统进入异常信号处理流程。万幸的是,代码在执行时暂停线程的几率和常规代码执行的几率相比是相对较小的,无论是从二八原则还是从阿姆达尔定律(Amdahl)的角度出发分析这种优化都是合理的,我们日常工作中也可以借鉴这种"性能损耗转移"的优化策略以保证正常流程的性能不受太大影响。

至此,相信读者朋友们对如何暂停执行中的线程有了一定的了解,接下来我们就来看看Java虚拟机是如何实现安全点的。

安全点实现原理

由于Java虚拟机的业务领域场景比较复杂,导致其安全点的实现也比较复杂,这个是由其业务本质(Essential Complexity)所决定的,也是系统复杂性的下限,我们主要从五种不同的业务场景说明其实现原理。

一、什么是安全点?

安全点(Safepoint) 是 JVM 中一个非常关键的机制:当 JVM 需要执行某些"全局性"操作(如 GC、偏向锁撤销、栈采样、Class Redefine 等)时,必须保证所有 Java 线程都暂停在一个对象引用关系明确、可枚举根节点的位置,这样虚拟机才能安全地遍历堆和栈。


二、整体流程架构

下面是Java虚拟机进入安全点的代码入口 SafepointSynchronize::begin() 的整体流程:

VMThread 调用 begin

获取 Threads_lock

arm_safepoint 装载安全点

synchronize_threads 自旋等待所有线程到达

_state = _synchronized

do_cleanup_tasks 执行清理

VM 操作执行

disarm_safepoint 解除装载

VMThread 调用 end 唤醒所有线程


三、arm_safepoint() —— 装载安全点的核心

arm_safepoint 是整个机制的关键。它的核心动作分为两步:

1. 设置全局状态

_wait_barrier->arm(static_cast<int>(_safepoint_counter + 1));
Atomic::release_store(&_safepoint_counter, _safepoint_counter + 1);
OrderAccess::storestore();
_state = _synchronizing;   // 全局进入"同步中"

2. 装载每个线程的本地轮询页

for (JavaThreadIteratorWithHandle jtiwh; JavaThread *cur = jtiwh.next(); ) {
    SafepointMechanism::arm_local_poll(cur);
}
OrderAccess::fence(); // storeload fence makes arm_local_poll writes visible to all JavaThread immediately

也就是说,VMThread 不会去主动暂停任何线程,而是设置一个"信号",等待各线程在自己的代码路径中主动检查并阻塞

Java虚拟机在JDK10及以后的版本实现了JEP 312: Thread-Local Handshakes提议优化,把原来的polling page机制进行了一轮优化,以实现精准控制单一Java线程的安全点暂停,避免全局暂停所有Java线程这种粗颗粒度的控制所带来的性能损耗:

class SafepointMechanism : public AllStatic {
  static void* _poll_armed_value;
  static void* _poll_disarmed_value;
  static address _polling_page;

  static void* poll_armed_value()                     { return _poll_armed_value; }
  static void* poll_disarmed_value()                  { return _poll_disarmed_value; }
  ...
}

优化后全局有两个polling_page,一个用于触发段异常暂停Java线程(bad page),一个用于正常轮询(good page),同时每个Java线程实例上都有一个_polling_page字段,每当需要暂停对应的Java线程时,就可以把bad page的地址赋值给Java线程的_polling_page字段,而暂停结束后再把good page赋值给Java线程的_polling_page字段,对应的就是以下的两个函数:

void SafepointMechanism::arm_local_poll(JavaThread* thread) {
  thread->set_polling_page(poll_armed_value());
}

void SafepointMechanism::disarm_local_poll(JavaThread* thread) {
  thread->set_polling_page(poll_disarmed_value());
}

两个polling_page的初始化如下:

void SafepointMechanism::default_initialize() {
  // Poll bit values
  intptr_t poll_armed_value = poll_bit();
  intptr_t poll_disarmed_value = 0;
  {
    // Polling page
    const size_t page_size = os::vm_page_size();
    const size_t allocation_size = 2 * page_size;
    char* polling_page = os::reserve_memory(allocation_size, NULL, page_size);
    os::commit_memory_or_exit(polling_page, allocation_size, false, "Unable to commit Safepoint polling page");
    MemTracker::record_virtual_memory_type((address)polling_page, mtSafepoint);

    char* bad_page  = polling_page;
    char* good_page = polling_page + page_size;

    os::protect_memory(bad_page,  page_size, os::MEM_PROT_NONE); //bad page是PROT_NONE,无法进行读写访问
    os::protect_memory(good_page, page_size, os::MEM_PROT_READ); //good page是PROT_READ,可以进行读访问

    log_info(os)("SafePoint Polling address, bad (protected) page:" INTPTR_FORMAT ", good (unprotected) page:" INTPTR_FORMAT, p2i(bad_page), p2i(good_page));
    _polling_page = (address)(bad_page);

    // Poll address values
    intptr_t bad_page_val  = reinterpret_cast<intptr_t>(bad_page),
             good_page_val = reinterpret_cast<intptr_t>(good_page);
    poll_armed_value    |= bad_page_val;
    poll_disarmed_value |= good_page_val;
  }

  _poll_armed_value    = reinterpret_cast<void*>(poll_armed_value);
  _poll_disarmed_value = reinterpret_cast<void*>(poll_disarmed_value);
}

以上所有的内存操作(reserve_memory, commit_memory, protect_memory)在Linux系统环境下都是通过mmap函数调用来实现。


四、五种场景下线程如何进入安全点

arm_safepoint 函数顶部的注释系统地说明了 Java 线程在 5 种不同状态 下是如何分别响应安全点请求:

场景 1:Running interpreted(解释执行中)

When executing branching/returning byte codes interpreter
checks if the poll is armed, if so blocks in SS::block().
  • 机制:解释器在执行**分支字节码(goto/if)返回字节码(return)**时,会插入安全点检查指令。
  • 触发:检查到 local poll 被装载后,调用 SafepointSynchronize::block() 自我阻塞。
  • 为什么选这些位置:循环回边和方法返回是最自然的"暂停时机",OopMap 信息明确,且能保证有限时间内到达。

场景 2:Running in native code(执行 JNI 本地代码)

When returning from the native code, a Java thread must check
the safepoint _state to see if we must block. If the
VM thread sees a Java thread in native, it does
not wait for this thread to block.
  • 关键点:VMThread 不需要等待 native 线程!因为 native 代码不会操作 Java 堆中的对象引用(即使有对象,也通过 JNI Handle 间接访问),所以处于 _thread_in_native 状态的线程对 GC 是"安全"的。
  • 返回时检查:当 native 调用结束、要重新进入 Java 代码执行时,会经过 _thread_in_native_trans 状态转换,此时检查 safepoint 状态,发现被装载则阻塞。
  • 内存屏障:注释中特别强调 “the VM thread issues a memory barrier instruction”,对应代码中的 OrderAccess::fence(),保证全局状态写入对各线程可见。

场景 3:Running compiled code(执行 JIT 编译代码)

Compiled code reads the local polling page that
is set to fault if we are trying to get to a safepoint.
  • 机制:JIT 编译器会在方法返回前循环回边插入对一个特殊"轮询页(polling page)"的读指令:
    test %eax, (poll_page)   ; 仅一条 load 指令
    
  • 装载方式SafepointMechanism::arm_local_poll 把该页设置成不可读(调用库函数mprotect 把内存地址设为 PROT_NONE)。
  • 触发:当线程执行到这条 load 指令时,会触发 SIGSEGV 信号,由信号处理器路由到 handle_polling_page_exception
    void SafepointSynchronize::handle_polling_page_exception(JavaThread *thread) {
        ...
        ThreadSafepointState* state = thread->safepoint_state();
        state->handle_polling_page_exception();
    }
    
  • 优势:正常运行时只有一条 load 指令,几乎零开销,只有真正需要进入安全点时才走异常路径。

场景 4:Blocked(线程已阻塞)

A thread which is blocked will not be allowed to return from the
block condition until the safepoint operation is complete.
  • 机制:处于 _thread_blocked 状态的线程(如等待 mutex、condvar、Object.wait 等)天然就是"安全的"——它们不在执行字节码,OopMap 已经保存。
  • 当它们尝试从阻塞状态返回(重新变为 runnable)时,状态转换路径上会检查 safepoint,于是会再次阻塞在 WaitBarrier 上。

场景 5:In VM or Transitioning(VM 内部状态转换中)

If a Java thread is currently running in the VM or transitioning
between states, the safepointing code will poll the thread state
until the thread blocks itself when it attempts transitions to a
new state or locking a safepoint checked monitor.
  • 状态举例_thread_in_vm_thread_in_vm_trans_thread_in_native_trans_thread_blocked_trans_thread_new_trans
  • 机制:VMThread 在 synchronize_threads自旋等待,调用 examine_state_of_thread 反复观察该线程的状态,直到它完成状态转换并在转换过程中"自我阻塞"。
  • 状态转换点的检查:当线程要从 _thread_in_vm 切换为 _thread_in_Java 时,必然要经过一段代码,那段代码会检查 safepoint 标志并主动调用 block()。这就是 SafepointSynchronize::block() 函数中处理的几种 _trans 状态:
    case _thread_in_vm_trans:
    case _thread_in_Java:
    case _thread_in_native_trans:
    case _thread_blocked_trans:
    case _thread_new_trans:
        thread->safepoint_state()->set_safepoint_id(safepoint_id);
        thread->set_thread_state_fence(_thread_blocked);
        _wait_barrier->wait(static_cast<int>(safepoint_id));
        ...
    

五、五种场景的对比总结

场景 线程状态 检查方式 VMThread 是否等待 阻塞位置
1. 解释执行 _thread_in_Java 字节码分支/返回处主动轮询 ✅ 等待 SafepointSynchronize::block()
2. Native 代码 _thread_in_native 返回时检查 ❌ 不等待(视为已安全) _trans 状态转换处的SafepointSynchronize::block()
3. JIT 编译代码 _thread_in_Java 读 polling page → SIGSEGV ✅ 等待 信号处理器 → SafepointSynchronize::block()
4. 已阻塞 _thread_blocked 无需检查(已安全) ❌ 不等待 原阻塞点 或 WaitBarrier
5. VM 内 / 状态转换中 _thread_in_vm_thread_*_trans VMThread 自旋观察 ✅ 等待 _trans状态转换处的 SafepointSynchronize::block()

总结

Java虚拟机的安全点实现比我们想象中的复杂,主要是上层的业务场景多,而且每种场景进入安全点的机制也有差别,同时Java虚拟机还需要对不同状态的线程进行协同,最终导致Java虚拟机进入安全点的代码比较复杂,但是无论上层业务多复杂,底层通过安全点状态检测+跳转到线程暂停功能代码的机制和我们一开始讨论的暂停线程的机制基本相符。

更多推荐