一个 println 竟然比 volatile 还好使?

一个,println,竟然,volatile,还好 · 浏览次数 : 161

小编点评

**JIT 的影响** JIT 的优化机制会对代码的性能产生影响。一些情况下,JIT 可以优化代码,使代码更快。但是,在某些情况下,JIT 可以优化代码,使代码更慢。 JIT 的优化机制主要包括以下几个方面: * **同步**:JIT 使用同步来优化代码。当多个线程想要访问一个静态变量时,需要使用同步来避免冲突。 * ****静态变量**:JIT 使用静态变量来优化代码。静态变量是在编译时被分配到代码中的变量。 * ****内存优化**:JIT 使用内存优化来优化代码。内存优化包括在代码中分配内存给静态变量。 **JIT 的优化问题** JIT 的优化问题主要包括以下几个方面: * ****代码优化**:JIT 使用代码优化来优化代码。代码优化包括在代码中使用同步和静态变量来优化代码。 * ****内存优化**:JIT 使用内存优化来优化代码。内存优化包括在代码中分配内存给静态变量。 * ****性能测试**:JIT 使用性能测试来优化代码。性能测试包括在代码中使用同步和静态变量来优化代码。 **JIT 的优化方法** JIT 的优化方法主要包括以下几个方面: * ****使用同步和静态变量**:使用同步和静态变量可以优化代码性能。 * ****使用内存优化**:使用内存优化可以优化代码性能。 * ****使用性能测试**:使用性能测试可以优化代码性能。 **JIT 的优化结果** JIT 的优化结果主要包括以下几个方面: * ****代码更快**:JIT 可以优化代码,使代码更快。 * ****代码更慢**:JIT 可以优化代码,使代码更慢。 * ****性能更高**:JIT 可以优化代码,使代码更高性能。 **JIT 的重要性** JIT 是 Java编译器的重要组件。JIT 的优化结果可以显著提高 Java代码的性能。

正文

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

小伙伴: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 是个啥啊?还能有这种功效?

image.png

JIT(Just-in-Time) 的优化

众所周知,JAVA 为了实现跨平台,增加了一层 JVM,不同平台的 JVM 负责解释执行字节码文件。虽然有一层解释会影响效率,但好处是跨平台,字节码文件是平台无关的。
image.png
在 JAVA 1.2 之后,增加了即时编译(Just-in-Time Compilation,简称 JIT)的机制,在运行时可以将执行次数较多的热点代码编译为机器码,这样就不需要 JVM 再解释一遍了,可以直接执行,增加运行效率。
image.png

但 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 的各种优化机制,相当多……
image.png

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

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

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

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

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

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

总结

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

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

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

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

参考

一点补充

可能部分读者大佬们会认为是 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……)

作者:京东保险 蒋信

来源:京东云开发者社区 转载请注明来源

与一个 println 竟然比 volatile 还好使?相似的内容:

一个 println 竟然比 volatile 还好使?

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

Java 获取当前天是一年中的第几天

Java 获取当前天是一年中的第几天 ```java @Test void dayofweed() throws Exception { System.out.println("2023-01-01 第 " + getWeekYearISO("2023-01-01")); System.out.pr

安卓架构组件-依赖注入

安卓依赖注入 什么是依赖注入 依赖注入(DI,Dependency Injection)是一种广泛的编程技术。把依赖(所需对象)传递给其它对象创建,好处是类的耦合更加松散,遵循依赖倒置的原则。 类获取所需对象 class Engine { fun start() { println("engine

一个难忘的json反序列化问题

前言 最近我在做知识星球中的商品秒杀系统,昨天遇到了一个诡异的json反序列化问题,感觉挺有意思的,现在拿出来跟大家一起分享一下,希望对你会有所帮助。 案发现场 我最近在做知识星球中的商品秒杀系统,写了一个filter,获取用户请求的header中获取JWT的token信息。 然后根据token信息

一个用来画拉氏图的简单Python脚本

这里我提供了一个用于画拉氏图的Python脚本源代码,供大家免费使用。虽然现在也有很多免费的平台和工具可以用,但很多都是黑箱,有需要的开发者可以直接在这个脚本基础上二次开发,定制自己的拉氏图绘制方法。

Simple WPF: WPF 透明窗体和鼠标事件穿透

一个自定义WPF窗体的解决方案,借鉴了吕毅老师的WPF制作高性能的透明背景的异形窗口一文,并在此基础上增加了鼠标穿透的功能。可以使得透明窗体的鼠标事件穿透到下层,在下层窗体中响应。

NetMvc通过亚马逊方式服务器端和客户端上传MinIO顺利解决

前言: 1、由于项目是.NET Framework 4.7 MVC LayUI,所以需要找一个资源站点存放项目中静态资源文件; 2、需要支持服务端和客户端都支持上传文件方式; 3、调用简单,涉及库越少越好。 结果: 调用 AWSSDK.S3 和 AWSSDK.Core 实现文件上传到 MinIO ;

MinIO 图片转文件的分界线RELEASE.2022-05-26T05-48-41Z

前言:本人想用MinIO存储文件,但是不想最新版本Mete文件,于是各种寻找于是终于找到办法了,原来是官方版本更新导致的。需要我们去寻找相应的版本。 1、官网下载网站 https://dl.min.io/server/minio/release/windows-amd64/archive/minio

WIndow Server 2019 服务器 MinIO下载并IIS配置反向代理

1、官网下载并配置 下载MinIO Serve地址(不需要安装,放在目录就行) https://dl.min.io/server/minio/release/windows-amd64/minio.exe 设置账号和密码(cmd) setx MINIO_ROOT_USER admin setx MI

一个开源且全面的C#算法实战教程

前言 算法在计算机科学和程序设计中扮演着至关重要的角色,如在解决问题、优化效率、决策优化、实现计算机程序、提高可靠性以及促进科学融合等方面具有广泛而深远的影响。今天大姚给大家分享一个开源、免费、全面的C#算法实战教程:TheAlgorithms/C-Sharp。 C#经典十大排序算法(完结) 支持C