来聊聊JVM中安全点的概念

写在文章开头

近期在分享关于synchronized关键字的文章的时候提到了一个关于安全点的概念,有读者反馈这块知识点讲的有些潦草,遂以此文简单介绍一下JVM中关于安全点的概念。

我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili ,也欢迎您了解我的开源项目 mini-redis:github.com/shark-ctrl/...

为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。

详解safepoint基本概念

什么是安全点?为什么需要安全点

在正式讲解安全点之前,我们不妨复习一下JVM中垃圾回收的基本过程,我们以CMS垃圾回收器为例,其垃圾回收过程在完成GC Roots查找与收集之后就会按照如下步骤执行:

  1. 初始标记
  2. 并发回收
  3. 最终标记(重新标记)
  4. 并发清除

要知道固定可作为GC Roots的节点主要是:

  1. 全局引用:例如常量或者静态变量。
  2. 执行上下文即栈帧中的变量表。

对于现代java应用而言,光是方法区就可能有数百上千兆,所以对于这些起源的引用也并非一件容易的事情。这也就意味着JVM在进行垃圾回收时并不能通过逐个扫描检查来实现。 就目前主流的JVM来说,针对根节点枚举基本都是采用空间换时间的策略,也就是使用一组OopMap,全称为"Object Pointer Map"(对象指针映射),本质上就是一个位图索引,它会通过以下两个时机完成对象信息的缓存:

  1. 类加载完成后,hotSpot就会基于类的偏移量信息计算出来并缓存。
  2. JIT阶段也会在特定的时机(这一点后续会详细说明)计算出栈或寄存器中的那些位置是引用,并将其缓存。

如此一来,下次进行根枚举时就可以直接基于OopMap高效完成:

但是java进程的运行的瞬息万变的,可能此刻的对象在下一刻就不可用,下一刻又有新的对象诞生,这种引用关系的实时变化亦或者说导致OopMap内容变化的指令是非常多的,若针对每一个指令都设置对应的oopMap,那么内存的开销是非常高昂的。

所以就有了安全点(safepoint)的概念,这也就是我们上文所提及的特定的位置,基于这个设定,用户的程序仅仅会在特定的情况下生成oopMap,同理在垃圾回收时,也要求所有线程达到安全点后才能够暂停并进入STW从而开始进行初始标记、最终标记等操作:

例如下面这段代码:

ini 复制代码
Object o=new Object();

对应汇编码如下,可以看到0x00000000031ffb8fcall指令,它指明偏移量40-852处有一个普通对象指针Oop(Ordinary Object Pointer)

perl 复制代码
0x00000000031ffb80: mov    $0xf5,%edx
  0x00000000031ffb85: mov    %ecx,%ebp
  0x00000000031ffb87: mov    %rbx,0x28(%rsp)
  0x00000000031ffb8c: data16 xchg %ax,%ax
  0x00000000031ffb8f: callq  0x00000000030957a0  ; OopMap{[40]=Oop off=852}
                                                ;*new  ; - java.lang.String::<init>@58 (line 205)
                                                ; - java.lang.String::substring@52 (line 1933)
                                                ;   {runtime_call}

JVM如何让线程跑到最近的安全点

对于安全点上的线程中断策略,大体来说是有两种:

  1. 抢占式:当需要进入安全点时,JVM会主动挂起所有的用户线程,如果线程未在安全点则等到该线程进入安全点进入安全点并完成中断。这种做法最大的缺点就是时间不可控即很可能存在性能不稳定亦或者吞吐量的波动,所以截至目前还有那款虚拟机采用抢占式的方式完成线程中断。
  2. 主动式:这种方式是让线程去维护一个标志位,需要进入安全点时修改该变量,用户线程就会在合适的时机检查这个变量值,如果这个值为真时就进入安全点。

线程什么时候需要进入安全点

除了常见的垃圾回收标记触发STW使得所有线程需要进入安全点以外,对应的进入安全点的时机还有:

  1. 使用jstatjmapjstack等命令,为保证监控堆栈信息的实时正确性,所有线程需要STW并进入安全点暂停。
  2. JDK8默认情况下定时进入安全点,保证一些需要进入安全点的操作能够及时运行。
  3. JIT编译代码优化例如:OSR(栈上替换即一种运行时替换栈帧的技术)或者去优化即Bailout(将JIT编译后的代码回退,解释器模式),因为可能存在执行指令的变化,线程就需要进入安全点。
  4. java agent需要对类进行增强导致类重新定义,需要修改类的相关信息,所以需要进入安全点。
  5. 高并发情况下,锁升级机制会涉及偏向锁撤销,需要进入STW检查每个线程的使用状态,所以也需要进入安全点。

JVM如何保证线程高效进入安全点

我们以线程运行JIT编译好的代码为例,它的设计与实现步骤为:

  1. JVM初始化一个异常处理器,专门捕获对应的page fault缺页中断异常。
  2. JIT编译代码期间,会基于我们上述的规则在特定位置插入一条精简的指令,作为安全点检查。
  3. VM线程通知当前线程进入安全点,将线程内部维护的内存页即polling page设置为不可读。
  4. 线程执行这条机器码指令发现内存页不可读,触发缺点中断。
  5. 异常处理器捕获这个异常,线程进入安全点。

对应的我们也给出这段精简的汇编码指令,即test %eax,0x160100 ; {poll}这段指令,这段指令本质上就是执行poll操作检查安全点,尝试访问线程内存页对应地址为0x160100,如果发现不可访问则触发缺页中断进入安全点:

ini 复制代码
0x01b6d627: call   0x01b2b210         ; OopMap{[60]=Oop off=460}      
                                       ;*invokeinterface size      
                                       ; - Client1::main@113 (line 23)      
                                       ;   {virtual_call}      
 0x01b6d62c: nop                       ; OopMap{[60]=Oop off=461}      
                                       ;*if_icmplt      
                                       ; - Client1::main@118 (line 23)      
 0x01b6d62d: test   %eax,0x160100      ;   {poll}      
 0x01b6d633: mov    0x50(%esp),%esi      
 0x01b6d637: cmp    %eax,%esi     

如何设置安全点

而这个轮询操作的合适的时机也就是触发安全点的时机,对于安全点的选定不能太过于频繁导致过分增加内存的负荷,如果进入安全点的选定太少同样也会导致线程无法及时进入安全点而导致未能及时GCOOM,所以我们对于安全点的选定一定要符合能够让程序长时间的运行为标准:

  1. 无界循环或者大循环:对于长时间的while(true)或者以long类型为轮询次数的for循环,JVM会插入安全点保证循环能够在需要停顿时及时停顿:
scss 复制代码
 for (long i = 0; i < 10_0000_0000; i++) {
                counter.getAndAdd(1);
                //safe point
}
            
while (true){
                counter.getAndAdd(1);
                //safe point
 }
  1. 在执行方法调用返回之前:
arduino 复制代码
private boolean function() {
        //do something
        return true;
        //safe point 
    }
  1. 可能抛出异常的位置,例如下面这段代码:
arduino 复制代码
  public static void main(String[] args)  {
        int result = 10 / 0;
    }

对应的汇编码如下,可以看到因为算数可能存在异常,它就在0x00000000033f0695设置了一个test安全点检查:

perl 复制代码
0x00000000033f066c: mov    $0xa,%eax         ; 将被除数10加载到eax寄存器
0x00000000033f0671: mov    $0x0,%esi         ; 将除数0加载到esi寄存器(准备触发除零异常)
0x00000000033f0676: cmp    $0x80000000,%eax  ; 检查被除数是否为Integer.MIN_VALUE(-2^31)
0x00000000033f067c: jne    0x00000000033f068d ; 如果不是则跳转到常规除法流程
0x00000000033f0682: xor    %edx,%edx         ; 清零edx寄存器(特殊溢出处理路径)
0x00000000033f0684: cmp    $0xffffffff,%esi  ; 检查除数是否为-1(配合前面对MIN_VALUE的检查)
0x00000000033f0687: je     0x00000000033f0690 ; 如果是-1则跳过除法(避免MIN_VALUE/-1溢出)
0x00000000033f068d: cltd                     ; 将eax符号位扩展到edx(准备64位被除数)
0x00000000033f068e: idiv   %esi              ; 执行有符号除法eax/esi(此处会触发除零异常)
                                              ; 对应Java代码:int result = 10 / 0;
                                              ; 隐式异常处理分支:dispatches to 0x00000000033f069c
0x00000000033f0690: add    $0x30,%rsp        ; 调整栈指针(清理栈帧)
0x00000000033f0694: pop    %rbp              ; 恢复调用者的基址指针
0x00000000033f0695: test   %eax,-0x28b059b(%rip) ; 安全点检查:{poll_return}
                                              ; 检查线程本地polling page是否可访问
                                              ; 不可访问则进入安全点处理

用一次GC解释不同状态的线程如何进入safepoint

VM线程(JVM内部的一种特殊的系统线程)需要触发GC时,它需要所有的线程都进入安全点,从而实现STW

  1. 线程正在运行字节码:这种情况也就是常规的情况,这解释器会查看线程是否被设置为poll armed,如果为真则将其block阻塞。
  2. 线程运行JIT编译好的代码:JIT会在指定位置插入安全点检查,如果需要进入安全点,JVM则会将线程内部维护的内存页即polling page设置为不可读,当线程在安全点检查时感知到这一点之后,就会直接将内存block
  3. 线程正在运行native代码:VM线程不会等待该线程进入阻塞状态,而是将线程设置为poll armed,当线程执行完成native代码并返回时看到这个标识,如果还是需要停在safepoint,则会直接block阻塞。
  4. 正在阻塞的线程,这种情况下线程就会一直阻塞,直到所有线程完成safepoint对应的操作。
  5. 正在执行状态切换,或者VM线程状态(即执行虚拟机线程正在工作的一种状态)的线程:这种情况下安全点检查相关的代码会不断轮询该线程,直到该线程因为状态切换或者锁定safepoint checked monitor(安全点监视锁)完成阻塞。

需要注意这一小节,笔者强调的是如何进入安全点(各个线程如何进入安全点),而不是何处插入安全点(插入安全点时机的选择),读者在阅读本文时一定要梳理清楚插入安全时机设置和进入安全点的时机设置的区别。

实践-基于主线程休眠了解安全点的工作过程

代码示例

这段代码比较简单,两个子线程异步累加源自类,主线程休眠1s后输出自己休眠的时长:

ini 复制代码
        AtomicInteger counter = new AtomicInteger(0);

        Runnable runnable = () -> {
            for (int i = 0; i < 10_0000_0000; i++) {
                counter.getAndAdd(1);
            }
            System.out.println(Thread.currentThread().getName() + "运行结束,counter:" + counter.get());
        };

        Thread t1 = new Thread(runnable, "t1");
        Thread t2 = new Thread(runnable, "t2");
        t1.start();
        t2.start();
        //主线程开始休眠
        long begin = System.currentTimeMillis();
        System.out.println("主线程开始休眠");
        Thread.sleep(1000);
        //休眠结束后,等待其他线程进入安全点才开始结束休眠,所以输出耗时19s
        long cost = System.currentTimeMillis() - begin;
        System.out.println("主线程结束休眠,耗时:" + cost + "ms");

很多读者可能认为主线程打印的耗时差不多是1s,但事与愿违,输出的结果竟然是30s:

makefile 复制代码
主线程开始休眠
t1运行结束,counter:2000000000
t2运行结束,counter:2000000000
主线程结束休眠,耗时:29956ms

由上述我们讲解进入,主线程调用native休眠返回后,程序因为某种原因进入全局安全点,而两个子线程因为是有界循环未能进入安全点,最终导致主线程长时间等待两个子线程进入安全而导致休眠打印变长:

基于日志印证执行流程

对此我们基于-XX:+SafepointTimeout -XX:SafepointTimeoutDelay=2000参数,查看进入安全点超过2s后,主线程(包括其他未强调的用户线程)在等待那些线程进入安全点。

很明显,从输出结果来看所有线程都在等待两个子线程进入安全点:

更进一步,我们添加-XX:+PrintSafepointStatistics参数,查看所有线程进入安全点的耗时,可以发现:

  1. JVM内部非安全点的线程自旋等待进入安全点时间和主线程休眠时间基本一致。
  2. 同步所有线程进入安全点时间block和上述休眠结束的耗时基本一致。

由此印证的问题根因:子线程未能及时进入安全点

最后一个问题,这个触发安全点的时机是什么呢?

通过-XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal打印JVM参数可以发现下面这个参数,查阅资料我们可以知晓该参数GuaranteedSafepointInterval,会默认1s让程序进入一次全局安全点:

所以我们最终得出根因,假设主线程在程序启动100ms进入休眠,程序对应的执行步骤为:

  1. 代码执行,JVM启动,由于GuaranteedSafepointInterval参数配置为1000ms,所以在1s时需要设置一个安全点标识位,让所有用户线程更新oopMap
  2. 主线程在1100ms时完成了1s的休眠准备退出native的sleep,检查安全点标识为真因为缺页异常进入安全点。
  3. 两个子线程因为有界循环没能进入安全点继续循环。
  4. 主线程和其他用户线程等待t1、t2完成循环。

优化思路

基于上述的分析,我们可以得出导致主线程休眠过长的时间的主要原因:

  1. 由于GuaranteedSafepointInterval定时触发安全点,导致主线程休眠结束后进入安全点。
  2. 主线程进入安全点期间,子线程因为有界循环而未能进入安全点。

所以,基于原因1我们可以通过-XX:+UnlockDiagnosticVMOptions -XX:GuaranteedSafepointInterval=0强制关闭进入安全点,得到如下效果:

makefile 复制代码
主线程开始休眠
主线程结束休眠,耗时:1014ms

对于原因2,将有界循环改为long,让JIT识别到这个大循环而插入安全点:

关于安全点更进一步的理解

关于安全点的调优建议

  1. 高并发微服务的场景下建议关闭偏向锁-XX:-UseBiasedLocking
  2. 避免有界循环可以在循环到一定次数时,设置Thread.sleep(0)辅助进入安全点。
  3. int长有界循环改为long,让JIT为其插入安全点。
  4. 优化有界但是耗时长的循环代码。
  5. 关闭定时进入安全点-XX:+UnlockDiagnosticVMOptions -XX:GuaranteedSafepointInterval=0

JDK11对于安全点的优化

考虑到定时进入安全点的非必要性和高并发场景下的阻塞风险,jdk11默认情况下已经将该参数关闭,对此我们可以键入jinfo -flag GuaranteedSafepointInterval <pid>查看。

输出结果如下,可以看到笔者将项目改为JDK11之后,对应的参数值设置为0,即不定时进入安全点:

RocketMQ中对于安全点的优化

基于上述的调优建议,我们也给出RocketMQ对于安全点的优化,以4.8.0版本为例,MappedFile为避免零拷贝循环写入操作的耗时,它会定时的调用Thread.sleep(0);让线程调用native方法从而到达安全点,保证需要时进入STW完成GC:

ini 复制代码
public void warmMappedFile(FlushDiskType type, int pages) {
        long beginTime = System.currentTimeMillis();
        ByteBuffer byteBuffer = this.mappedByteBuffer.slice();
        int flush = 0;
        long time = System.currentTimeMillis();
        for (int i = 0, j = 0; i < this.fileSize; i += MappedFile.OS_PAGE_SIZE, j++) {
            byteBuffer.put(i, (byte) 0);
            //......

            // prevent gc
            //定期休眠,保证及时进入安全点,不阻塞其他需要进入安全点的线程
            if (j % 1000 == 0) {
                log.info("j={}, costTime={}", j, System.currentTimeMillis() - time);
                time = System.currentTimeMillis();
                try {
                    Thread.sleep(0);
                } catch (InterruptedException e) {
                    log.error("Interrupted", e);
                }
            }
        }

       //......
    }

后续版本, 为了保证更加合理的进入安全点,这块代码也被直接优化为long的长循环,让JIT自行优化,在合适的位置插入安全点保证线程能够及时进入安全点配合需要STW等工作:

小结

本文从GC工作原理引出安全点的作用、工作方式、常见配置和一些性能表现上的风险点,从而给出一些比较经典的实践案例,希望对你有帮助。

我是 SharkChili ,Java 开发者,Java Guide 开源项目维护者。欢迎关注我的公众号:写代码的SharkChili ,也欢迎您了解我的开源项目 mini-redis:github.com/shark-ctrl/...

为方便与读者交流,现已创建读者群。关注上方公众号获取我的联系方式,添加时备注加群即可加入。

参考

JVM的STW、安全点(safe point)和安全区域(safe region)理解:juejin.cn/post/692678...

JVM实现原理分析之safepoint:juejin.cn/post/704065...

一切皆是映射:浅谈操作系统内核的缺页异常(Page Fault):cloud.tencent.com/developer/a...

深入浅出解析 JVM 中的 Safepoint:blog.csdn.net/crg18438610...

JVM-SafePoint(安全点)和STW:juejin.cn/post/730724...

深入浅出解析 JVM 中的 Safepoint:blog.csdn.net/crg18438610...

图解 OopMap、Safe Point、Safe Region:zhuanlan.zhihu.com/p/441867302

JVM中的OopMap:www.cnblogs.com/strinkbug/p...

OSR(On-Stack Replacement)是怎样的机制?:www.zhihu.com/question/45...

相关推荐
Zane19944 分钟前
ClassName() 只是一步?拆开看 __new__ 和 __init__ 各自在干什么
后端·python
geovindu4 分钟前
java: Memento Pattern
java·开发语言·后端·备忘录模式·行为模式
星哥的编程之路7 分钟前
万字深度解析 Agent 学习路线
后端
程序边界10 分钟前
SQL Server数据迁移这件事,远比你想的复杂——但也远比你想的简单(下)
后端
Zane199413 分钟前
JRE 去哪了?一次讲清楚 JDK、JRE、JVM 到底是什么关系?
java·后端
JavaPub17 分钟前
Ontology 本体论是什么?从哲学概念到 AI、知识图谱与软件工程
后端
liuxiaocheng21 分钟前
聊聊 Vercel AI SDK 的流式协议:前后端到底是怎么"边想边说"的
前端·后端
得物技术33 分钟前
得物知识问答:复合检索 Agent 的系统设计实践
人工智能·后端·ai编程
andongni20343 分钟前
SpringBoot 入门实验报告
java·spring boot·后端
李广坤1 小时前
Agent 记忆系统详解:从"金鱼脑"到"过目不忘"的进化之路
后端