当前位置:首页 > 科技  > 软件

一个 println 竟然比 volatile 还好使?

来源: 责编: 时间:2023-10-08 09:59:58 209观看
导读前两天一个小伙伴突然找我求助,说准备换个坑,最近在系统复习多线程知识,但遇到了一个刷新认知的问题……小伙伴:Effective JAVA 里的并发章节里,有一段关于可见性的描述。下面这段代码会出现死循环,这个我能理解,JMM 内存模

前两天一个小伙伴突然找我求助,说准备换个坑,最近在系统复习多线程知识,但遇到了一个刷新认知的问题……Hjq28资讯网——每日最新资讯28at.com

小伙伴:Effective JAVA 里的并发章节里,有一段关于可见性的描述。下面这段代码会出现死循环,这个我能理解,JMM 内存模型嘛,JMM 不保证 stopRequested 的修改能被及时的观测到。Hjq28资讯网——每日最新资讯28at.com

static boolean stopRequested = false;public static void main(String[] args) throws InterruptedException {    Thread backgroundThread = new Thread(() -> {        int i = 0;        while (!stopRequested) {            i++;        }    }) ;    backgroundThread.start();    TimeUnit.MICROSECONDS.sleep(10);    stopRequested = true ;}

但奇怪的是在我加了一行打印之后,就不会出现死循环了!难道我一行 println 能比 volatile 还好使啊?这俩也没关系啊Hjq28资讯网——每日最新资讯28at.com

static boolean stopRequested = false;public static void main(String[] args) throws InterruptedException {    Thread backgroundThread = new Thread(() -> {        int i = 0;        while (!stopRequested) {                        // 加上一行打印,循环就能退出了!        	System.out.println(i++);        }    }) ;    backgroundThread.start();    TimeUnit.MICROSECONDS.sleep(10);    stopRequested = true ;}

我:小伙子八股文背的挺熟啊,JMM 张口就来。Hjq28资讯网——每日最新资讯28at.com

我:这个……其实是 JIT 干的好事,导致你的循环无法退出。JMM 只是一个逻辑上的内存模型规范,JIT可以根据JMM的规范来进行优化。Hjq28资讯网——每日最新资讯28at.com

比如你第一个例子里,你用-Xint禁用 JIT,就可以退出死循环了,不信你试试?Hjq28资讯网——每日最新资讯28at.com

小伙伴:WK,真的可以,加上 -Xint 循环就退出了,好神奇!JIT 是个啥啊?还能有这种功效?Hjq28资讯网——每日最新资讯28at.com

JIT(Just-in-Time) 的优化

众所周知,JAVA 为了实现跨平台,增加了一层 JVM,不同平台的 JVM 负责解释执行字节码文件。虽然有一层解释会影响效率,但好处是跨平台,字节码文件是平台无关的。Hjq28资讯网——每日最新资讯28at.com

Hjq28资讯网——每日最新资讯28at.com

在 JAVA 1.2 之后,增加了 即时编译(Just-in-Time Compilation,简称 JIT) 的机制,在运行时可以将执行次数较多的热点代码编译为机器码,这样就不需要 JVM 再解释一遍了,可以直接执行,增加运行效率。Hjq28资讯网——每日最新资讯28at.com

Hjq28资讯网——每日最新资讯28at.com

但 JIT 编译器在编译字节码时,可不仅仅是简单的直接将字节码翻译成机器码,它在编译的同时还会做很多优化,比如循环展开、方法内联等等……
Hjq28资讯网——每日最新资讯28at.com

这个问题出现的原因,就是因为 JIT 编译器的优化技术之一 - 表达式提升(expression hoisting) 导致的。Hjq28资讯网——每日最新资讯28at.com

表达式提升(expression hoisting)

先来看个例子,在这个 hoisting 方法中,for 循环里每次都会定义一个变量 y,然后通过将 x*y 的结果存储在一个 result 变量中,然后使用这个变量进行各种操作Hjq28资讯网——每日最新资讯28at.com

public void hoisting(int x) {	for (int i = 0; i < 1000; i = i + 1) {		// 循环不变的计算 		int y = 654;		int result = x * y;				// ...... 基于这个 result 变量的各种操作	}}

但是这个例子里,result 的结果是固定的,并不会跟着循环而更新。所以完全可以将 result 的计算提取到循环之外,这样就不用每次计算了。JIT 分析后会对这段代码进行优化,进行表达式提升的操作:Hjq28资讯网——每日最新资讯28at.com

public void hoisting(int x) {	int y = 654;	int result = x * y;    	for (int i = 0; i < 1000; i = i + 1) {			// ...... 基于这个 result 变量的各种操作	}}

这样一来,result 不用每次计算了,而且也完全不影响执行结果,大大提升了执行效率。Hjq28资讯网——每日最新资讯28at.com

注意,编译器更喜欢局部变量,而不是静态变量或者成员变量;因为静态变量是“逃逸在外的”,多个线程都可以访问到,而局部变量是线程私有的,不会被其他线程访问和修改。Hjq28资讯网——每日最新资讯28at.com

编译器在处理静态变量/成员变量时,会比较保守,不会轻易优化。Hjq28资讯网——每日最新资讯28at.com

像你问题里的这个例子中,stopRequested就是个静态变量,编译器本不应该对其进行优化处理;Hjq28资讯网——每日最新资讯28at.com

static boolean stopRequested = false;// 静态变量public static void main(String[] args) throws InterruptedException {    Thread backgroundThread = new Thread(() -> {        int i = 0;        while (!stopRequested) {			// leaf method            i++;        }    }) ;    backgroundThread.start();    TimeUnit.MICROSECONDS.sleep(10);    stopRequested = true ;}

但由于你这个循环是个 leaf method,即没有调用任何方法,所以在循环之中不会有其他线程会观测到stopRequested值的变化。那么编译器就冒进的进行了表达式提升的操作,将stopRequested提升到表达式之外,作为循环不变量(loop invariant)处理:Hjq28资讯网——每日最新资讯28at.com

int i = 0;boolean hoistedStopRequested = stopRequested;// 将stopRequested 提升为局部变量while (!hoistedStopRequested) {    	i++;}

这样一来,最后将 stopRequested赋值为 true 的操作,影响不了提升的hoistedStopRequested的值,自然就无法影响循环的执行了,最终导致无法退出。Hjq28资讯网——每日最新资讯28at.com

至于你增加了 println 之后,循环就可以退出的问题。是因为你这行 println 代码影响了编译器的优化。println 方法由于最终会调用
FileOutputStream.writeBytes 这个 native 方法,所以无法被内联优化(inling)。而未被内敛的方法调用从编译器的角度看是一个“full memory kill”,也就是说 副作用不明 、必须对内存的读写操作做保守处理。Hjq28资讯网——每日最新资讯28at.com

在这个例子里,下一轮循环的 stopRequested 读取操作按顺序要发生在上一轮循环的 println 之后。这里“保守处理”为:就算上一轮我已经读取了 stopRequested 的值,由于经过了一个副作用不明的地方,再到下一次访问就必须重新读取了。Hjq28资讯网——每日最新资讯28at.com

所以在你增加了 prinltln 之后,JIT 由于要保守处理,重新读取,自然就不能做上面的表达式提升优化了。Hjq28资讯网——每日最新资讯28at.com

以上对表达式提升的解释,总结摘抄自 R大的知乎回答。R大,行走的 JVM Wiki!
Hjq28资讯网——每日最新资讯28at.com

我:“这下明白了吧,这都是 JIT 干的好事,你要是禁用 JIT 就没这问题了”Hjq28资讯网——每日最新资讯28at.com

小伙伴:“WK,一个简单的 for 循环也太多机制了,没想到 JIT 这么智能,也没想到 R 大这么”Hjq28资讯网——每日最新资讯28at.com

小伙伴:“那 JIT 一定很多优化机制吧,除了这个表达式提升还有啥?”Hjq28资讯网——每日最新资讯28at.com

我:我也不是搞编译器的……哪了解这么多,就知道一些常用的,简单给你说说吧Hjq28资讯网——每日最新资讯28at.com

表达式下沉(expression sinking)

和表达式提升类似的,还有个表达式下沉的优化,比如下面这段代码:Hjq28资讯网——每日最新资讯28at.com

public void sinking(int i) {	int result = 543 * i;	if (i % 2 == 0) {		// 使用 result 值的一些逻辑代码	} else {		// 一些不使用 result 的值的逻辑代码	}}

由于在 else 分支里,并没有使用 result 的值,可每次不管什么分支都会先计算 result,这就没必要了。JIT 会把 result 的计算表达式移动到 if 分支里,这样就避免了每次对 result 的计算,这个操作就叫表达式下沉:Hjq28资讯网——每日最新资讯28at.com

public void sinking(int i) {	if (i % 2 == 0) {		int result = 543 * i;		// 使用 result 值的一些逻辑代码	} else {		// 一些不使用 result 的值的逻辑代码	}}

JIT 还有那些常见优化?

除了上面介绍的表达式提升/表达式下沉以外,还有一些常见的编译器优化机制。Hjq28资讯网——每日最新资讯28at.com

循环展开(Loop unwinding/loop unrolling)

下面这个 for 循环,一共要循环 10w 次,每次都需要检查条件。Hjq28资讯网——每日最新资讯28at.com

for (int i = 0; i < 100000; i++) {    delete(i);}

在编译器的优化后,会删除一定的循环次数,从而降低索引递增和条件检查操作而引起的开销:Hjq28资讯网——每日最新资讯28at.com

for (int i = 0; i < 20000; i+=5) {    delete(i);    delete(i + 1);    delete(i + 2);    delete(i + 3);    delete(i + 4);}

除了循环展开,循环还有一些优化机制,比如循环剥离、循环交换、循环分裂、循环合并……Hjq28资讯网——每日最新资讯28at.com

内联优化(Inling)

JVM 的方法调用是个栈的模型,每次方法调用都需要一个压栈(push)和出栈(pop)的操作,编译器也会对调用模型进行优化,将一些方法的调用进行内联。
Hjq28资讯网——每日最新资讯28at.com

内联就是抽取要调用的方法体代码,到当前方法中直接执行,这样就可以避免一次压栈出栈的操作,提升执行效率。比如下面这个方法:Hjq28资讯网——每日最新资讯28at.com

public  void inline(){	int a = 5;    int b = 10;    int c = calculate(a, b);        // 使用 c 处理……}public int calculate(int a, int b){	return a + b;}

在编译器内联优化后,会将 calculate 的方法体抽取到 inline 方法中,直接执行,而不用进行方法调用:Hjq28资讯网——每日最新资讯28at.com

public  void inline(){	int a = 5;    int b = 10;    int c = a + b;        // 使用 c 处理……}

不过这个内联优化是有一些限制的,比如 native 的方法就不能内联优化Hjq28资讯网——每日最新资讯28at.com

提前置空

来先看一个例子,在这个例子中 was finalized! 会在 done.之前输出,这个也是因为 JIT 的优化导致的。Hjq28资讯网——每日最新资讯28at.com

class A {    // 对象被回收前,会触发 finalize    @Override protected void finalize() {        System.out.println(this + " was finalized!");    }    public static void main(String[] args) throws InterruptedException {        A a = new A();        System.out.println("Created " + a);        for (int i = 0; i < 1_000_000_000; i++) {            if (i % 1_000_00 == 0)                System.gc();        }        System.out.println("done.");    }}//打印结果Created A@1be6f5c3A@1be6f5c3 was finalized!//finalize方法输出done.

从例子中可以看到,如果 a 在循环完成后已经不再使用了,则会出现先执行finalize的情况;虽然从对象作用域来说,方法没有执行完,栈帧并没有出栈,但是还是会被提前执行。Hjq28资讯网——每日最新资讯28at.com

这就是因为 JIT 认为 a 对象在循环内和循环后都不会在使用,所以提前给它置空了,帮助 GC 回收;如果禁用 JIT,那就不会出现这个问题。Hjq28资讯网——每日最新资讯28at.com

这个提前回收的机制,还是有点风险的,在某些场景下可能会引起 BUG……Hjq28资讯网——每日最新资讯28at.com

HotSpot VM JIT 的各种优化项

上面只是介绍了几个简单常用的编译优化机制,JVM JIT 更多的优化机制可以参考下面这个图。这是 OpenJDK 文档中提供的一个 pdf 材料,里面列出了 HotSpot JVM 的各种优化机制,相当多……Hjq28资讯网——每日最新资讯28at.com

Hjq28资讯网——每日最新资讯28at.com

如何避免因 JIT 导致的问题?

小伙伴:“JIT 这么多优化机制,很容易出问题啊,我平时写代码要怎么避开这些呢”Hjq28资讯网——每日最新资讯28at.com

平时在编码的时候,不用刻意的去关心 JIT 的优化,就比如上面那个 println 问题,JMM 本来就不保证修改对其他线程可见,如果按照规范去加锁或者用 volatile 修饰,根本就不会有这种问题。Hjq28资讯网——每日最新资讯28at.com

而那个提前置空导致的问题,出现的几率也很低,只要你规范写代码基本不会遇到的。Hjq28资讯网——每日最新资讯28at.com

我:所以,这不是 JIT 的锅,是你的……Hjq28资讯网——每日最新资讯28at.com

小伙伴:“懂了,你这是说我菜,说我代码写的屎啊……”Hjq28资讯网——每日最新资讯28at.com

总结

在日常编码过程中,不用刻意的猜测 JIT 的优化机制,JVM 也不会完整的告诉你所有的优化。而且这种东西不同版本效果不一样,就算搞明白了一个机制,可能到下个版本就会完全不一样。Hjq28资讯网——每日最新资讯28at.com

所以,如果不是搞编译器开发的话,JIT 相关的编译知识,作为一个知识储备就好。Hjq28资讯网——每日最新资讯28at.com

也不用去猜测 JIT 到底会怎么优化你的代码,你(可能)猜不准……Hjq28资讯网——每日最新资讯28at.com

本故事纯属瞎编,请勿随意对号入座Hjq28资讯网——每日最新资讯28at.com

参考

  • JSR-133 Java Memory Model and Thread Specification 1.0 Proposed Final Draft
  • Oracle JVM Just-in-Time Compiler (JIT)
  • JVM JIT-compiler overview - Vladimir Ivanov HotSpot JVM Compiler Oracle Corp.
  • JVM JIT optimization techniques - part 2
  • The Java platform - WikiBook
  • R 大的知乎百科

一点补充

可能部分读者大佬们会认为是 sync 导致的问题,下面是稍加改造后的 sync 例子,结果是仍然无法退出死循环……Hjq28资讯网——每日最新资讯28at.com

public class HoistingTest {	static boolean stopRequested = false;	public static void main(String[] args) throws InterruptedException {		Thread backgroundThread = new Thread(() -> {			int i = 0;			while (!stopRequested) {				// 加上一行打印,循环就能退出了!//				System.out.println(i++);				new HoistingTest().test();			}		}) ;		backgroundThread.start();		TimeUnit.SECONDS.sleep(5);		stopRequested = true ;	}	Object lock = new Object();	private  void test(){		synchronized (lock){}	}}

再升级下,把 test 方法,也加上 sync,结果还是无法退出死循环……Hjq28资讯网——每日最新资讯28at.com

Object lock = new Object();private synchronized void test(){        synchronized (lock){}}

但我只是想说,这个问题的关键是 jit 的优化导致的问题。jmm 只是规范,而 jit 的优化机制,也会遵循 jmm 的规范。Hjq28资讯网——每日最新资讯28at.com

不过 jmm 并没有说 sync 会影响 jit 之类的,可就算 sync 会影响那又怎么样呢……并不是关键点Hjq28资讯网——每日最新资讯28at.com

结合 R大 的解释,编译器对静态变量更敏感,如果把上面的 lock 对象修改成 static 的,循环又可以退出了……Hjq28资讯网——每日最新资讯28at.com

那如果不加 static ,把 sync 换成 unsafe.pageSize()呢?结果是循环还是可以退出……Hjq28资讯网——每日最新资讯28at.com

所以,本文的重点是描述 jit 的影响,而不是各种会影响 jit 的动作。影响 jit 的可能性会非常多,而且不同的vm甚至不同的版本表现都会有所不同,我们并不需要去摸清这个机制,也没法摸清(毕竟不是做编译器的,就是是做编译器,也不一定是 HotSpot……)Hjq28资讯网——每日最新资讯28at.com

作者:京东保险 蒋信Hjq28资讯网——每日最新资讯28at.com

来源:京东云开发者社区 Hjq28资讯网——每日最新资讯28at.com

本文链接:http://www.28at.com/showinfo-26-12432-0.html一个 println 竟然比 volatile 还好使?

声明:本网页内容旨在传播知识,若有侵权等问题请及时与本网联系,我们将在第一时间删除处理。邮件:2376512515@qq.com

上一篇: 图形编辑器开发:快捷键的管理

下一篇: 如何实现并部署自己的Npm解析服务

标签:
  • 热门焦点
Top