因JIT引起的Thread.sleep滞后问题

问题来源

StackOverFlow上的一个问题:The main thread exceeds the set sleep time

对问题中的原代码有调整

    public static AtomicInteger num = new AtomicInteger(0);

    public static void main(String[] args) throws ParseException, InterruptedException {
        Runnable runnable = () -> {
            for (int i = 0; i < 1000000000; i++) {
                num.getAndAdd(1);
            }
        };

        Thread t1 = new Thread(runnable);
        Thread t2 = new Thread(runnable);
        t1.start();
        t2.start();

        long tm = System.currentTimeMillis();
        Thread.sleep(1000);
        System.out.println("num = " + num);

        tm = System.currentTimeMillis() - tm;
        System.out.println("tm: " + tm);
    }
  • 设置主线程 sleep 1000ms,但实际上输出会等到两个子线程计算结束的才输出
num = 2000000000
tm: 31041
  • 如果将时间调整为100ms,则主线程不会等待子线程结束
num = 6813227
tm: 101

StackOverFlow上的解答

https://stackoverflow.com/questions/67068057/the-main-thread-exceeds-the-set-sleep-time

直接翻译,需要查看原文的点击问题链接查看

这个问题与HotSpot安全点机制(Safepoint mechanism)有重大的关系。

背景知识

  • HotSpot JVM通常会在循环中添加安全点(safepoint poll),以便在JVM需要执行stop-the-world操作时可以暂停线程。安全点也是有一定的性能开销的,所以JIT编译器会尽可能地消除它。其中一种优化是从计数循环(counted loops)中删除安全点。

  • for (int i = 0; i < 1000000000; i++)是一个典型的计数循环: 它有一个递增的循环变量(计数器)和有限的循环次数。JDK 8 JIT编译这种循环时不使用安全点。然而,例子中的是一个耗时很长的循环; 它需要几秒钟才能完成。在运行此循环时,JVM将无法停止线程。

  • HotSpot JVM使用安全点不仅仅是为了GC,还为 许多其他操作 使用安全点。特别是,当有 清理任务要做时,它会定期停止Java线程。周期由-XX:GuaranteedSafepointInterval选项控制,默认值为1000毫秒。

这个例子中发生了什么

  1. 例子中启动了两个很长的不中断循环(其中没有安全点)。
  2. 主线程休眠1秒。
  3. 在1000毫秒之后(GuaranteedSafepointInterval), JVM尝试在一个安全点停止Java线程,以便进行定期清理,但要等到计数的循环完成后才能这样做。
  4. Thread.Sleep方法从native返回,发现安全点操作正在运算,所以挂起直到操作结束。
  • 此时主线程正在等待循环完成——正如观察到的那样。

  • 当您将睡眠持续时间更改为100毫秒时,固定周期的安全点发生在Thread.Sleep返回之后,因此该方法没有被阻塞。

  • 又或者,保持1000毫秒的sleep,但是增加 -XX:GuaranteedSafepointInterval=2000,主线程也不需要等待。

如何修复

  • -XX:+UseCountedLoopSafepoints 选项关闭消除安全点的优化。在这个例子中,Thread.Sleep会如期睡1秒钟。

  • 此外,如果你改变int ilong i,循环将不再被视为计数循环(counted loops),所以就不会看到所提及的安全点的效果。

  • 从JDK 10开始,HotSpot实现了长循环拆分暴露(Loop Strip Mining)优化,在没有太多开销的情况下,解决了在计数循环中安全点的问题。因此,示例在JDK 10及以后版本中应该可以开箱即用。

  • 问题的解释和解决方案可以在 这个问题 的描述中找到。

问题延展

HotSpot JVM 中的安全点

原文地址:Safepoints in HotSpot JVM

什么是安全点
  • 在HotSpot中,JVM Stop-the-World 的暂停机制称为安全点。在安全点期间,所有运行java代码的线程都被挂起。运行native代码的线程可以继续运行,只要它们不与JVM交互(比如尝试通过JNI访问Java对象,调用Java方法或从native返回Java,这些将挂起线程直到安全点结束)。
  • 停止所有线程是必要的,以确保安全点发起者独占JVM数据结构,并可以做疯狂的事情,比如移动堆中的对象或替换当前运行的方法的代码(On-Stack-Replacement)。
安全点如何工作
  • HotSpot JVM中的安全点协议是需要共同协作的。每个应用程序线程都需要在安全点被触发时,检查自己的安全点状态,并将自身置于安全点的安全状态。
  • 对于已编译的代码(JIT模板解释器),JIT在某些点(通常是调用返回后或循环回跳时)在代码中插入安全点检查。对于解释型代码(字节码解释器),JVM使用两个字节表示调试表,会在需要安全点检查时切换调度表的状态。
  • 安全点状态检查本身以非常巧妙的方式实现。正常的内存变量检查需要昂贵的内存屏障(memory barriers)。不过,安全点实现为内存读障碍。在需要安全点时,JVM取消映射带有该地址的内存页,从而引发应用程序线程上的页错误(由JVM的处理程序处理)。通过这种方式,HotSpot可以友好地维护它的JIT代码CPU流水线(CPU pipeline),同时确保正确的内存语义(页面未映射将迫使处理核心出现内存屏障)。

以上是原文,可能不太好理解,可以简单如下理解:

  1. 安全点的检查就是在代码的某些位置插入了些安全点的检查代码
  2. 应用线程到达安全点时,是否需要进入安全点状态,通常只是个状态判定,比如线程是否被标记为poll armed,或者本地 local polling page 是否为脏,是否已经在block状态(已经block的,安全点未结束前,不离开block状态)等
  3. 应用线程如果发现需要进入安全点状态,则并把自己状态改为安全(把自己阻塞block)
  4. 需要所有应用线程都到达安全点安全状态(block阻塞状态),才开始做安全点的动作。
安全点何时被使用

以下列出了一些HotSpot JVM需要使用安全点的原因

  • 垃圾回收需要暂停时
  • 代码逆优化
  • 刷新 Code Cache
  • 类重定义(比如 hot swap/Java Instrumentation,Java中一种热插拔、热部署、线上诊断工具等)
  • 偏向锁的消除
  • 各种调试操作(例如,死锁检查、堆栈dump)
安全点的故障诊断

通常安全点就是有用的。因此,您可以不太关心它们(除了GC之外,它们中的大多数都非常快)。但如果有什么可以打破它,它最终也会破坏,所以这里有一些有用的诊断:

  • -xx:+PrintGCApplicationStoppedTime -这将报告所有安全点的暂停时间(与GC相关或不相关)。不幸的是,这个选项的输出缺乏时间戳,但它仍然有助于将问题缩小到是否在安全点中。
  • -xx:+PrintSafepointStatistics - xx:PrintSafepointStatisticsCount=1 -这两个选项将强制JVM在每个安全点之后报告原因和时间(它将报告到stdout,而不是GC日志)。

什么是 JIT

模板解释器

Java中执行引擎有两种解释器,一种是字节码解释器,一种是模板解释器

  • 字节码解释器
    • Java字节码的解释执行器
    • C++代码,解释并执行Java字节码
  • 模板解释器
    • 将Java 字节码转为硬编码
    • 模板解释器会申请一块内存,将硬编码写入,申请函数指针,指向该内存
    • 调用时使用函数指针调用即可

模板解释器的运行模式设置

  • -Xint 纯字节码解释器
  • -Xcomp 纯模板解释器
  • -Xmixed (默认) 字节码解释器 + 模板解释器
即时编译(JIT)

JIT(Just in time)即时编译,会将热点代码的Java字节码转换为硬编码,有两种JIT编译器

  • C1
    • client模式下的即时编译器
    • 触发的条件相对C2比较宽松:需要收集的数据较少
    • 编译的优化比较浅:基本运算在编译的、final
    • c1编译器编译生成的代码执行效率较c2低
  • C2
    • server模式下的即时编译器
    • 触发的条件比较严格,一般来说,程序运行一段时间之后才会触发
    • 优化比较深
    • 编译生成的代码执行效率较C1更高

当然还有以下几种

  • 混合编译
    • 程序运行初期触发C1编译器
    • 程序运行一段时间后触发C2编译器
  • GraalVM
    • JDK14引入

JIT的触发

  • JIT的最小单位不是一个函数,而是代码块,如for, while等
  • 多次被调用的方法,或者一个方法体内循环次数转交的,称之为热点代码
  • 热点代码被调用多次时,触发JIT,可以使用参数-XX:CompileThreshold设置,默认值可以通过以下命令查看
# linux
java -client -XX:+PrintFlagsFinal -version | grep CompileThreshold

# windows
java -client -XX:+PrintFlagsFinal -version | find "CompileThreshold"

热度衰减

  • 如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度, 如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay) ,而这段时间就称为此方法统计的半衰周期(Counter Half Life Time)
  • 进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,可以使用虚拟机参数 -XX:-UseCounterDecay 来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码
    另外,可以使用 -XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒

热点代码存在什么位置

  • 热点代码存在方法区中,称之为CodeCache
  • 可以使用以下命令查看CodeCache相关的参数及默认值
# linux
java -client -XX:+PrintFlagsFinal -version | grep CodeCache

# windows
java -client -XX:+PrintFlagsFinal -version | find "CodeCache"
  • 其中比较重要的几个参数
    • -XX:InitialCodeCacheSize 用于设置初始CodeCache大小
    • -XX:ReservedCodeCacheSize 用于设置Reserved code cache的最大大小
    • -XX:CodeCacheExpansionSize 用于设置code cache的扩容大小

回到最开始的问题

  • 既然问题是因为JIT编译优化未正确放置安全点引发的,那么关闭JIT是否可以解决开关的问题?
    • 答案是可以的,使用 -Xint-Djava.compiler=NONE 关闭JIT,可以解决开头的问题。
    • 当然关闭的JIT的代价是高昂的,只使用字节码解释器,程序效率很低
  • 当然本文中提到的解决方案,都是从不改变代码本身来考虑的,然而实际工程中解决此类问题,应该使用多线程相关的功能,如 latches,barriers,locks,blockedqueues也可以是最基本的yield,wait/notify,synchronized等。

参考网址

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值