一个 println 竟然比 volatile 还好使?

2023年 10月 8日 76.7k 0

前两天一个小伙伴突然找我求助,说准备换个坑,最近在系统复习多线程知识,但遇到了一个刷新认知的问题……

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

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 还好使啊?这俩也没关系啊

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 张口就来。

我:这个……其实是 JIT 干的好事,导致你的循环无法退出。JMM 只是一个逻辑上的内存模型规范,JIT可以根据JMM的规范来进行优化。

比如你第一个例子里,你用-Xint禁用 JIT,就可以退出死循环了,不信你试试?

小伙伴:WK,真的可以,加上 -Xint 循环就退出了,好神奇!JIT 是个啥啊?还能有这种功效?

JIT(Just-in-Time) 的优化

众所周知,JAVA 为了实现跨平台,增加了一层 JVM,不同平台的 JVM 负责解释执行字节码文件。虽然有一层解释会影响效率,但好处是跨平台,字节码文件是平台无关的。

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

但 JIT 编译器在编译字节码时,可不仅仅是简单的直接将字节码翻译成机器码,它在编译的同时还会做很多优化,比如循环展开、方法内联等等……

这个问题出现的原因,就是因为 JIT 编译器的优化技术之一 - 表达式提升(expression hoisting) 导致的。

表达式提升(expression hoisting)

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

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 分析后会对这段代码进行优化,进行表达式提升的操作:

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

这样一来,result 不用每次计算了,而且也完全不影响执行结果,大大提升了执行效率。

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

编译器在处理静态变量/成员变量时,会比较保守,不会轻易优化。

像你问题里的这个例子中,stopRequested就是个静态变量,编译器本不应该对其进行优化处理;

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)处理:

int i = 0;

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

这样一来,最后将 stopRequested赋值为 true 的操作,影响不了提升的hoistedStopRequested的值,自然就无法影响循环的执行了,最终导致无法退出。

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

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

所以在你增加了 prinltln 之后,JIT 由于要保守处理,重新读取,自然就不能做上面的表达式提升优化了。

以上对表达式提升的解释,总结摘抄自 R大的知乎回答。R大,行走的 JVM Wiki!

我:“这下明白了吧,这都是 JIT 干的好事,你要是禁用 JIT 就没这问题了”

小伙伴:“WK,一个简单的 for 循环也太多机制了,没想到 JIT 这么智能,也没想到 R 大这么”

小伙伴:“那 JIT 一定很多优化机制吧,除了这个表达式提升还有啥?”

我:我也不是搞编译器的……哪了解这么多,就知道一些常用的,简单给你说说吧

表达式下沉(expression sinking)

和表达式提升类似的,还有个表达式下沉的优化,比如下面这段代码:

public void sinking(int i) {
	int result = 543 * i;

	if (i % 2 == 0) {
		// 使用 result 值的一些逻辑代码
	} else {
		// 一些不使用 result 的值的逻辑代码
	}
}

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

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

JIT 还有那些常见优化?

除了上面介绍的表达式提升/表达式下沉以外,还有一些常见的编译器优化机制。

循环展开(Loop unwinding/loop unrolling)

下面这个 for 循环,一共要循环 10w 次,每次都需要检查条件。

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

在编译器的优化后,会删除一定的循环次数,从而降低索引递增和条件检查操作而引起的开销:

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

除了循环展开,循环还有一些优化机制,比如循环剥离、循环交换、循环分裂、循环合并……

内联优化(Inling)

JVM 的方法调用是个栈的模型,每次方法调用都需要一个压栈(push)和出栈(pop)的操作,编译器也会对调用模型进行优化,将一些方法的调用进行内联。

内联就是抽取要调用的方法体代码,到当前方法中直接执行,这样就可以避免一次压栈出栈的操作,提升执行效率。比如下面这个方法:

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 方法中,直接执行,而不用进行方法调用:

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

不过这个内联优化是有一些限制的,比如 native 的方法就不能内联优化

提前置空

来先看一个例子,在这个例子中 was finalized! 会在 done.之前输出,这个也是因为 JIT 的优化导致的。

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@1be6f5c3
A@1be6f5c3 was finalized!//finalize方法输出
done.

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

这就是因为 JIT 认为 a 对象在循环内和循环后都不会在使用,所以提前给它置空了,帮助 GC 回收;如果禁用 JIT,那就不会出现这个问题。

这个提前回收的机制,还是有点风险的,在某些场景下可能会引起 BUG……

HotSpot VM JIT 的各种优化项

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

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

小伙伴:“JIT 这么多优化机制,很容易出问题啊,我平时写代码要怎么避开这些呢”

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

而那个提前置空导致的问题,出现的几率也很低,只要你规范写代码基本不会遇到的。

我:所以,这不是 JIT 的锅,是你的……

小伙伴:“懂了,你这是说我菜,说我代码写的屎啊……”

总结

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

所以,如果不是搞编译器开发的话,JIT 相关的编译知识,作为一个知识储备就好。

也不用去猜测 JIT 到底会怎么优化你的代码,你(可能)猜不准……

本故事纯属瞎编,请勿随意对号入座

参考

  • 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 例子,结果是仍然无法退出死循环……

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,结果还是无法退出死循环……

Object lock = new Object();

private synchronized void test(){

        synchronized (lock){}
}

但我只是想说,这个问题的关键是 jit 的优化导致的问题。jmm 只是规范,而 jit 的优化机制,也会遵循 jmm 的规范。

不过 jmm 并没有说 sync 会影响 jit 之类的,可就算 sync 会影响那又怎么样呢……并不是关键点

结合 R大 的解释,编译器对静态变量更敏感,如果把上面的 lock 对象修改成 static 的,循环又可以退出了……

那如果不加 static ,把 sync 换成 unsafe.pageSize()呢?结果是循环还是可以退出……

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

作者:京东保险 蒋信

来源:京东云开发者社区 

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论