3

死磕一道面试题引发的对Java内存模型的一点疑问,第四部。

 2 years ago
source link: https://www.heapdump.cn/article/2645436
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
死磕一道面试题引发的对Java内存模型的一点疑问,第四部。 | HeapDump性能社区

死磕一道面试题引发的对Java内存模型的一点疑问,第四部。

四千岁
volatile
1周前
作者关联了问题:
第一部在这里[一道面试题引发的对Java内存模型的一点疑问?](https://www.heapdump.cn/question/267086)。第二步在这里[一道面试题引发的对Java内存模型的一点

第一部在这里一道面试题引发的对Java内存模型的一点疑问?
第二部在这里一道面试题引发的对Java内存模型的一点疑问,第二部。
第三部在这里一道面试题引发的对Java内存模型的一点疑问,第三部。
网友的讨论:
R大的回答:下面的代码 Java 线程结束原因是什么?
空无大神的回答:一个 println 竟然比 volatile 还好使?
知乎大湿的回答:java中volatile关键字的疑惑?
经过我长时间的思考,关于下面这个问题
2610778.png

现在说一下我的理解,不一定对啊。说实话我到现在都不理解优化这行代码有什么意义,哈哈。我只是尝试理解R大的回答。

首先说结论:优化这行代码没有什么意义,JIT在将热点代码编译成机器码的时候就是单纯的不喜欢成员字段或者静态字段。或者说有意义,意义肯定就是为了提高代码执行效率呗。只是这种意义一般人看不出来,你需要从CPU执行指令的角度才能明白。虽然优化前和优化后这俩个变量hoistedStopRequested和stopRequested的读取都还是遵守JMM规范的,都是在线程的工作内存中读取的,不是直接去主存里面读取的。即使这样读取共享变量和读取局部变量在CPU看来肯定还是有区别的,所以JIT才优化这行代码。JMM规定普通的共享变量存在于主内存当中,然后每个线程都有自己的工作内存,每个线程用到变量的时候会先从主存中复制一份到自己的工作内存。 还有一点要注意,就是共享变量不能阻止JIT对热点代码的编译。

成员字段或者静态字段不会也不能阻止JIT对一段热点代码进行编译。JIT决定是否要编译一段代码就是看这段代码是否属于热点代码。这一点可以从R大的回答看出来。 来看一下R大回答的这段话就知道了:
微信截图_20210825231949.png

如果run()方法被HotSpot Server Compiler编译了:这个多加的System.out.println()调用干扰了编译器的优化,导致hoisting没有成功。
然而也有可能这个run()方法压根还没来得及被编译。

从R大的这句话中可以看出,只要这个while()循环有机会运行,并且运行多次之后变成热点代码,这个run方法最终肯定会被JIT直接编译成机器码。无论你加不加System.out.println()这行代码,无论这个循环里面有没有共享变量,这个run方法运行的次数多了最终都会被编译成机器码。加了System.out.println()这行代码之后,只会影响JIT在将这个run方法编译成机器码的同时不能做优化了。

具体如下:

  1. 我们都知道JAVA是一种半编译半解释的语言,半编译就是我们写的.java文件被javac这个命令编译成一种抽象的.class字节码文件。抽象这个词我到现在才理解它是什么意思,抽象的意思就是一个东西他只告诉你它能干什么,但是这个东西不会告诉你它具体是怎么干的。对于你这个使用者而言,你只需要知道这个东西能干什么就行了,至于具体怎么干的你不必关心也不用关心,更不应该关心。.class字节码就是抽象的,因为javac并不会把.java文件编译成具体的某一种CPU能识别的指令(机器码),一旦编译成具体的指令(机器码)就意味着JAVA不再跨平台了。你可以这么理解.class字节码文件,.class字节码文件里面只声明了java程序要干什么。至于具体要怎么干,就交给JVM去实现。当你在x86架构的电脑上面运行JAVA程序的时候,JVM就会把.class文件解释成具体的x86架构能识别的指令(机器码)。x86架构和arm架构的CPU指令是不一样的。对于抽象的.class文件,这个时候JVM会把.class文件里面抽象的指令变成一种具体的实现。
    半解释就是JAVA程序在运行的时候会把抽象的.class字节码文件交给JVM,JVM在运行的过程中会一点一点将.class字节码文件解释成机器码交给操作系统,操作系统再交给CPU去执行。
  2. 正常来说我们的代码每运行一次,JVM都要解释一次。一边解释一边运行,效率非常的低。但是你需要注意:javac是不能直接将.java文件编译成机器码的,如果直接编译成具体的机器码就失去了跨平台这一特性了。为了提高效率JIT(Just In Time Compile即时编译)就出现了,JIT会将热点代码编译成机器码并保留(缓存)下来,下次JVM在解释.class文件的时候,一看这段代码是热点代码就不逐行解释了,直接交给JIT,JIT会直接运行之前已经编译好的机器码,效率大大提升。JIT将热点代码编译成机器码的同时为了进一步提高效率还会对.class文件做一些优化,当然有些优化比较激进,一旦激进就会出问题。
    不过你不用担心这种激进优化,JIT敢激进优化,就是因为你写的代码太傻了或者你没有遵守JAVA的语法。就像这道面试题,正确的写法肯定要给变量加上volatile这个关键字的,你不写就别怪JIT激进优化了。JIT也是为你好,JIT想在你写的代码基础之上,让你写的代码跑得更快一点。
  3. 这道面试题里面的这个while(!stopRequested)循环,毫无疑问肯定属于热点代码,JIT肯定要编译丫的。JIT在将这个方法编译为机器码的时候,这个while(!stopRequested)循环已经执行很多次了,所以JIT对这个循环是非常了解的。JIT一看骂道的,你循环了这么多次,这个共享变量stopRequested的值还没有发生变化,并且这个变量你也没有加volatile关键字,你不加volatile关键字就代表你可能不在乎估计也不想及时看到stopRequested这个变量的变化嘛,并且我(JIT)看八成是不会发生变化了因为你这个while循环体里面只有一行代码i++,循环体里面肯定不会改变共享变量stopRequested的值,那我JIT干脆他妈的激进一下子,把这个共享变量给你hoisting(提升)丢到循环外面去,省的影响我循环的效率。还有最重要的一点就是 JIT做优化的时候本身就不太待见共享变量。所以此时JIT激进了,为了彻底的优化,为了极致的性能,干脆直接使出 表达式提升(expression hoisting) 这个优化**。所以你这个while循环彻底变成死循环了,变成死循环你不能怪JIT,怪你自己代码写的足够烂,怪你JAVA基础知识没学全,怪你老板不给你涨工资。怪天怪地就是不怪JIT。

我在第三部一道面试题引发的对Java内存模型的一点疑问,第三部,里面提出的第二个问题,在我将R大的回答读了几十遍之后,在我问遍群友之后,我终于明白了。那是一个晴朗的下午,阳光洒在窗外的绿树叶上,俩只黄鹂在鸣着翠柳,一行白鹭在上着青天,同事们都在安详地的敲着代码,我拍了一下桌子,啪就站起来了,喊了一句:“还有谁?”。那一刻,我终于明白了,JVM它是听话的,它是遵守JMM规范的。
微信截图_20210825235104.png

先说结论,肯定对。结论就是:JVM肯定是遵守JMM规范的。无论加了-Xint参数之后还是加了System.out.println()这行代码之后,JVM都还是遵守JMM规范的。JMM规定普通的共享变量存在于主内存当中,然后每个线程都有自己的工作内存,每个线程用到变量的时候会先从主存中复制一份到自己的工作内存。
按照JMM的规范可能会出现下面这种情况:
微信截图_20210825235756.png

那为什么加了-Xint参数之后和加了System.out.println()这行代码之后,就不会死循环了呢?原因请看R的这段回答:
微信截图_20210826000121.png

再看PerfMa公司可以比肩R大的公与大佬的回答:
微信截图_20210826000539.png

这俩位大神的意思就是,在x86架构上面由于MESI(缓存一致性)协议的存在,CPU在硬件层面会让另一个线程里面的缓存(线程工作副本里面的变量)失效,while循环只要一直读取这个变量,就总能到最新的值,即使你不加volatile关键字,x86架构的CPU也总是会让你看到变量的最新值。
第一:加了-Xint参数之后JVM就会禁用JIT,禁用JIT之后,JIT肯定就不会进行优化,自然也不会把变量stopRequested提升到循环之外。
第二:加了System.out.println()这行代码之后,会影响JIT进行激进优化,也不会把变量stopRequested提升到循环之外。
只要这个变量stopRequested在循环里面,每次循环都读取一遍,注意这个时候每次循环的读取都是读取的线程工作副本里面的变量,并不会去读主内存里面的变量,是遵守JMM的规范的。但是x86架构上面由于MESI(缓存一致性)协议的存在,会让线程工作副本里面的变量在某个时候失效,线程一旦发现自己工作内存里面的变量失效了,就会去主内存里面重新读取一下,这一读就读到变量的最新值了,就结束循环了。综上所述,JVM还是遵守JMM规范的。

从这里又能得出一个结论,就是volatile这个关键字跟MESI(缓存一致性)协议之间没有关系,你加不加volatile这个关键字,在x86架构上面MESI(缓存一致性)协议都会生效。在arm架构的CPU上面就不一定了。所以,写代码还是要规范一点,加上volatile关键字最好。深入汇编指令理解Java关键字volatile

再加一个证据,来自廖雪峰大神的教程中断线程
微信截图_20210826004019.png

MESI(缓存一致性)教程:玩转Java面试.08.缓存一致性协议MESI

MESI(缓存一致性):M(modified,修改)、E(exclusive,独占)、S(shared,共享)、I(invalid,失效)。

还有一点,我听说JIT在将热点代码编译为机器码的时候,总是以method为编译单位的。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK