一个 println 竟然比 volatile 还好使?

这篇具有很好参考价值的文章主要介绍了一个 println 竟然比 volatile 还好使?。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

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

小伙伴: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……)

作者:京东保险 蒋信

来源:京东云开发者社区 转载请注明来源文章来源地址https://www.toymoban.com/news/detail-712123.html

到了这里,关于一个 println 竟然比 volatile 还好使?的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • Android Studio System.out.println()中文乱码

    第一步: -Dfile.encoding=UTF-8  第二步: File-Settings-Editor-File Encodings 把所有的编码格式改为UTF-8  尝试跑一下代码,如果还不行,重启IDE 再试试。

    2024年02月13日
    浏览(42)
  • C# 实现 Websocket通讯聊天 (管用、超好使,点个赞)

    WebSocket出现之前,Web端为了实现即时通讯,所用的技术都是Ajax轮询(polling)。轮询是在特定的的时间间隔(如每1秒),由浏览器对服务器发出HTTP request,然后由服务器返回最新的数据给客服端的浏览器。这种传统的HTTP request 的模式带来很明显的缺点 – 浏览器需要不断的向服

    2024年02月16日
    浏览(25)
  • chrome浏览器必备5款插件--各个都好使--chrome基础

    办公电脑在使用固定期限后,又到了新换电脑的时候,本次新电脑到手后,在浏览器方面依然选择了已经熟悉的chrome浏览器,有一句话说的对,没有插件的chrome浏览器是没有灵魂的。所以在沿用chrome浏览器后,这次也对前期使用的chrome浏览器插件进行了重新的审视和用途的重

    2024年02月09日
    浏览(28)
  • 解决Java中文乱码问题:使用System.out.println输出中文字符串

    解决Java中文乱码问题:使用System.out.println输出中文字符串 在Java编程中,当我们想要在控制台输出中文字符串时,有时会遇到乱码的问题。本文将介绍如何解决这个问题,并提供相应的源代码示例。 在Java中,我们通常使用System.out.println语句来在控制台输出字符串。然而,当

    2024年02月06日
    浏览(29)
  • 比ping还好用的排障命令

    遇到网络故障的时候,你一般会最先使用哪条命令进行排障? 除了Ping,还有Traceroute、Show、Telnet又或是Clear、Debug等等。 今天安排的,是 Traceroute 排障命令详解,给你分享2个经典排障案例哈 。 Traceroute是为了探测源节点到目的节点之间数据报文所经过的路径。 利用IP报文的

    2024年02月12日
    浏览(25)
  • 微信突然限制,你的小程序链接还好吗?

    2020年12月,微信开放了小程序外部跳转能力,开发者可利用URL Scheme和URL Link实现小程序跳转,此功能对品牌私域流量运营至关重要。意味着微信生态逐步走向开放,除原有生态内的引流外,企业还可通过短信、邮件及其他外部应用程序快速完成小程序的跳转。 然而,大量小程

    2024年02月10日
    浏览(32)
  • 4月7日,更新一下作业预告:百分制成绩转换为五分制成绩;平均成绩、最高分和最低分;累加实验;所有素数正常算法和布尔值加入的算法;还好还好,

        输入测试数据:34 56 87 90 87 65 78 87 96 69     程序运行结果:max=96 min=34 avg=74.90 输入测试数据:20 程序的运行结果为 6.506583  解释: 首先,我们使用了 #include stdio.h 和 #include math.h 来包含标准输入输出和数学函数库。 main() 函数是程序的入口。在这个函数中,我们使用了一

    2024年04月08日
    浏览(26)
  • 世界上真的存在比苹果品质还好的笔记本电脑吗?

     苹果笔记本电脑的品质一直是业界标杆,但它真的是最好的吗?答案是肯定的,但也要看你怎么定义“好”。 为什么mac那么贵,还有很多人买? 探索高性能的Mac系列,包括 MacBook Pro、Mac Studio和Mac Pro,满足专业创意领域的需求。苹果的显示器设计精致,支持高质量的视频会

    2024年02月21日
    浏览(35)
  • 还好我入软件测试这行早,不然真就废了

    17岁那年高中毕业后的我来到大城市,没有学历,也不会技术,于是干起了外卖员的工作,一天基本4、50单吧,因为商家和平台双层的制度我只能拼命跑,快的就像要跟时间抢时间,记得有一次有个用户定了100杯奶茶,我用车子给他送了6趟,用户因为朋友间的不愉快,直接将

    2023年04月15日
    浏览(21)
  • 十几个冷门还好用的PHP写法(冷门的让人震惊)

    环境:PHP8.1 1. else foreach/else for 2. 匿名函数简写 3. 匿名函数叠加 4. 消失的分号 5. 空数组合并运算符 6. 利用内置类,创建空对象 7. 利用?-安全导航运算符,避免调用不存在的方法或属性时报错 8. 单独调用某个命名空间下的方法 9. 移位运算符 / 10. 位运算符 |/ 11. 异或运算符

    2024年02月04日
    浏览(28)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包