什么?工作五年还不了解SafePoint?

理解 JVM 的 Safepoint:一次全面但不啰嗦的讲解

某天夜里,公司的服务挂了几分钟, 由于服务自动重启丢失了现场,没有排查线索,于是领导建议我写个脚本监测cpu,内存使用率,在占用率高时,使用jstack,jmap保存现场信息。

于是... 过了几天,服务又挂了, 经过排查,是因为监测脚本有问题,在没问题的时间节点执行了jmap, 导致jvm长时间卡顿。 但是为什么,执行了jmap会导致jvm卡顿呢, 这就要说到本期要讲的Safepoint


什么是 safepoint

Safepoint(安全点) 是指程序执行过程中一个特定的位置,在这个位置上 JVM 能安全地暂停线程,以执行某些需要全局一致性操作的任务(如 GC)。换句话说,只有当所有线程都到达了 Safepoint,JVM 才能安全地执行某些操作。

区别于初识安全点的时候局限于GC中的安全点概念,这里给安全点一个比较全面的定义:

Safepoint 可以理解成是在代码执行过程中的一些特殊位置,当线程执行到这些位置的时候,线程可以暂停。在 SafePoint 保存了其他位置没有的一些当前线程的运行信息,供其他线程读取。这些信息包括:线程上下文的任何信息,例如对象或者非对象的内部指针等等。我们一般这么理解 SafePoint,就是线程只有运行到了 SafePoint 的位置,他的一切状态信息,才是确定的,也只有这个时候,才知道这个线程用了哪些内存,没有用哪些;并且,只有线程处于 SafePoint 位置,这时候对 JVM的堆栈信息进行修改,例如回收某一部分不用的内存,线程才会感知到,之后继续运行,每个线程都有一份自己的内存使用快照,这时候其他线程对于内存使用的修改,线程就不知道了,只有再进行到 SafePoint 的时候,才会感知。


Safepoint 的作用场景

JVM 中需要全线程一致状态的操作都依赖 Safepoint,比如:

使用场景 描述
垃圾回收(GC) Stop-The-World 停顿前需等待所有线程进入 safepoint。
Deoptimization JIT 优化失败时需要回退到解释模式,也需要线程暂停。
偏向锁撤销 偏向锁被其他线程竞争时需要撤销,期间不能有线程执行相关代码。
类卸载(Class Unloading) 清理无用类时需暂停所有线程。
线程堆栈分析 JFR(Java Flight Recorder)或诊断工具需要稳定的栈信息。 一些jvm自带工具:jstack jmap等
jvm默认轮询进入 每经过-XX:GuaranteedSafepointInterval 配置的时间,都会让所有线程进入 Safepoint

Safepoint 的实现原理

JVM 想让线程暂停,自己却不能直接中断线程,那该怎么办?

答案是:插点 + 协议暂停

JVM 会在程序运行时自动在一些"合适的地方"插入 safepoint 位置,通常包括:

  • 方法调用前
  • loop 回跳处
  • 分支跳转处
  • 触发异常的地方

当 JVM 触发一次 safepoint 请求时:

  1. JVM 设置一个 全局标志位,表示"要停一下了";
  2. 所有线程在执行到 safepoint 位置时,主动检查该标志位
  3. 如果发现有 safepoint 请求,就"乖乖停下",直到 JVM 操作完成;
  4. 等操作做完后,再恢复执行。

线程不是随时都能停,而是"下一个 safepoint 见"。


什么时候会进入 safepoint

以下场景会触发 JVM 进入 safepoint:

GC(垃圾回收)

包括 Minor GC、Major GC、Full GC,只要需要 STW(Stop-The-World),就必须进入 safepoint。

JVM 定期检查(默认参数)

JVM(jdk1.8)有个默认参数 GuaranteedSafepointInterval, 这个参数默认是开启的, 每经过-XX:GuaranteedSafepointInterval 配置的时间,都会让所有线程进入 Safepoint,一旦所有线程都进入,立刻从 Safepoint 恢复。这个定时主要是为了一些没必要立刻 Stop the world 的任务执行,可以设置-XX:GuaranteedSafepointInterval=0关闭这个定时

jstack / jmap 等工具

当我们用 jstack 去 dump 线程栈,或者用 jmap 做内存分析时,JVM 为了确保信息一致性,也会让所有线程停在 safepoint。

Java Instrumentation(agent 动态注入)

在运行时动态插入代码(如使用 javaagent),为了保证字节码修改不影响运行状态,也会强制进入 safepoint。

偏向锁撤销

锁大部分情况是没有竞争的(某个同步块大多数情况都不会出现多线程同时竞争锁),所以可以通过偏向来提高性能。即在无竞争时,之前获得锁的线程再次获得锁时,会判断是否偏向锁指向我,那么该线程将不用再次获得锁,直接就可以进入同步块。但是高并发的情况下,偏向锁会经常失效,导致需要取消偏向锁,取消偏向锁的时候,需要 Stop the world,因为要获取每个线程使用锁的状态以及运行状态

JIT 编译 / Code Cache 清理

当发生 JIT 编译优化或者去优化,需要 OSR 或者 Bailout 或者清理代码缓存的时候,由于需要读取线程执行的方法以及改变线程执行的方法,所以需要 Stop the world


Safepoint 的副作用,如何避免

因为线程必须等运行到 safepoint 才能停下,如果线程在执行过程中迟迟没有检查 safepoint,就会出现"卡住很久"的现象。

比如,某些长时间不触发方法调用的死循环

java 复制代码
while(true) {
  i++;
}

这段代码就可能永远都不进入 safepoint,导致其它线程迟迟不能停,最终出现 STW 卡顿。

避免方法:

  1. 避免过长的"无方法调用"的循环逻辑
  2. 可以通过 -XX:+PrintSafepointStatistics 查看卡顿位置;
  3. 使用 -XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation 查看哪些方法编译成了本地代码;
  4. 避免过多频繁地使用 jstackjmap,尤其在高并发场景下。

通过代码直观感受 safepoint

我们写段代码来观察线程在运行期间的行为:

java 复制代码
package com.vv;

import java.util.concurrent.atomic.AtomicInteger;

public class Test {

    public static AtomicInteger counter = new AtomicInteger(0);

    public static void main(String[] args) throws Exception {
        long startTime = System.currentTimeMillis();

        Runnable runnable = () -> {
            System.out.println(interval(startTime) + "ms后," + Thread.currentThread().getName() + "子线程开始运行");

            for (long i = 0; i < 100000000; i++) {
                counter.getAndAdd(1); // 注意:这是方法调用,天然就是一个 safepoint 检查点
            }

            System.out.println(interval(startTime) + "ms后," + Thread.currentThread().getName() + "子线程结束运行, counter=" + counter);
        };

        Thread t1 = new Thread(runnable, "zz-t1");
        Thread t2 = new Thread(runnable, "zz-t2");

        t1.start();
        t2.start();

        System.out.println(interval(startTime) + "ms后,主线程开始sleep.");
        Thread.sleep(1000L); // 此时尝试用 jstack 查看线程状态

        System.err.println(interval(startTime) + "ms后,主线程结束sleep.");
        System.err.println(interval(startTime) + "ms后,主线程结束,counter:" + counter);
    }

    private static long interval(Long startTime) {
        return System.currentTimeMillis() - startTime;
    }
}

示例代码中主线程 启动两个子线程 ,然后主线程睡眠1s,通过打印时间来观察主线程和子线程的执行情况。

按照预期,主线程会在1s左右后,打印日志,实际情况却是:

主线程在2.3s后才打印

先说结论

由于VMThread的某些操作需要STW,主线程在sleep结束前进入了JVM全局安全点,然后主线程要等待其他线程全部进入安全点,所以主线程被长时间没有进入安全点的其他线程给阻塞了,即两个子线程。

为什么会进入安全点?

前文中我们讲过进入安全的点场景,其中有一个 jvm默认轮询进入 的场景,就是因为这个,才导致所有线程进入safepoint

验证

通过 -XX:GuaranteedSafepointInterval = 0 关闭定时进入安全点,看看代码运行结果是怎么样的,注意,这个参数要结合XX:+UnlockDiagnosticVMOptions一起使用。

此时,我们看到, 主线程按照预期,在1s后打印了

主线程是在哪里进入的安全点?

通过Safepoint实现源代码:Safepoint.cpp里的注释,我们可以发现

当执行native方法时, 会进入safepoint, 而Thread.sleep()就是native方法。

Thread.sleep(0)的妙用

先看看一个来自RocketMQ(org.apache.rocketmq.store.logfile.DefaultMappedFile#warmMappedFile)代码里面的for循环,在循环里面,专门有个变量 j,来记录当前循环次数。

第一次循环以及往后每 1000 次循环之后,进入一个 if 逻辑。

作者真实的目的是为了在这里放置一个安全点,避免for循环运行时间过长导致系统长时间STW

子线程为什么无法进入安全点?

现在已经知道了主线程为什么进入会进入安全点,以及主线程在哪里进入的安全点,按照已知知识点JVM会在循环跳转处和方法调用处放置安全点,为什么子线程没有进入安全点?

可数循环和不可数循环

JVM为了避免安全点过多带来过重的负担,对循环有一项优化措施,认为循环次数较少的话,执行时间应该不会太长,所以使用int类型和范围更小的数据类型作为索引值的循环默认是不会被放置安全点的。这种循环被称为可数循环,相对应的,使用long或者范围更大的数据类型作为索引值的循环就被称为不可数循环,将被放置安全点。

在示例代码中,子线程的循环索引值数据类型是int,也就是可数循环,所以JVM没有在循环跳转处放置安全点。 把循环索引值数据类型改成long型,循环成为不可数循环,就能够成功在循环跳转处放置安全点,避免子线程长时间无法进入安全点阻塞主线程。

说到上文提到的Rocketmq源码,从代码的变更记录看,22年9月份有人对这段代码换了一种写法:把for循环变量类型定义成long型,同时注释掉了循环内部Thread.sleep(0)代码, 也是由于jvm认为范围更大的数不可循环,因此需要放置安全点

总结

Safepoint 是 JVM 中用来协调线程暂停的"集结点"。GC、类卸载、诊断工具调用等操作都依赖于它。虽然听起来只是 JVM 的"内部细节",但在系统调优、排查性能问题时,safepoint 就是你必须认识的老朋友

记住几个关键点:

  • JVM 停线程不是强制中断,而是等线程跑到 safepoint 再停;
  • 长时间不进入 safepoint 会导致 STW 卡顿;
  • 频繁触发 safepoint 会拖累系统整体性能;
  • 实战排查时,善用 jstackPrintSafepointStatistics 等工具洞察本质。

最后

如果文章对你有帮助,点个免费的赞鼓励一下吧!关注公众号:加瓦点灯, 每天推送干货知识!

参考

得物技术: juejin.cn/post/723182...

相关推荐
间彧1 小时前
Windows Server,如何使用WSFC+nginx实现集群故障转移
后端
间彧1 小时前
Nginx + Keepalived 实现高可用集群(Linux下)
后端
间彧1 小时前
在Kubernetes中如何部署高可用的Nginx Ingress Controller?
后端
间彧1 小时前
Ribbon负载均衡器和Nginx负载均衡器有什么区别
后端
间彧1 小时前
Nacos详解与项目实战
后端
间彧1 小时前
nginx、网关Gateway、Nacos、多个服务实例之间的数据链路详解
后端
间彧1 小时前
Nacos与Eureka在性能上有哪些具体差异?
后端
间彧2 小时前
详解Nacos健康状态监测机制
后端
间彧2 小时前
如何利用Nacos实现配置的灰度发布?
后端
毕业设计制作和分享2 小时前
springboot159基于springboot框架开发的景区民宿预约系统的设计与实现
java·spring boot·后端