文章目录
问题来源
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秒。
- 在1000毫秒之后(GuaranteedSafepointInterval), JVM尝试在一个安全点停止Java线程,以便进行定期清理,但要等到计数的循环完成后才能这样做。
Thread.Sleep
方法从native返回,发现安全点操作正在运算,所以挂起直到操作结束。
-
此时主线程正在等待循环完成——正如观察到的那样。
-
当您将睡眠持续时间更改为100毫秒时,固定周期的安全点发生在
Thread.Sleep
返回之后,因此该方法没有被阻塞。 -
又或者,保持1000毫秒的sleep,但是增加
-XX:GuaranteedSafepointInterval=2000
,主线程也不需要等待。
如何修复
-
-XX:+UseCountedLoopSafepoints
选项关闭消除安全点的优化。在这个例子中,Thread.Sleep会如期睡1秒钟。 -
此外,如果你改变
int i
为long 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),同时确保正确的内存语义(页面未映射将迫使处理核心出现内存屏障)。
以上是原文,可能不太好理解,可以简单如下理解:
- 安全点的检查就是在代码的某些位置插入了些安全点的检查代码
- 应用线程到达安全点时,是否需要进入安全点状态,通常只是个状态判定,比如线程是否被标记为
poll armed
,或者本地local polling page
是否为脏,是否已经在block
状态(已经block的,安全点未结束前,不离开block状态)等 - 应用线程如果发现需要进入安全点状态,则并把自己状态改为安全(把自己阻塞block)
- 需要所有应用线程都到达安全点安全状态(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等。