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

死磕一道面试题引发的对Java内存模型的一点疑问,第四部。
第一部在这里 一道面试题引发的对Java内存模型的一点疑问?
第二部在这里一道面试题引发的对Java内存模型的一点疑问,第二部
第三部在这里一道面试题引发的对Java内存模型的一点疑问,第三部。
网友的讨论:
R大的回答:下面的代码 Java 线程结束原因是什么?
空无大神的回答:一个 println 竟然比 volatile 还好使?
知乎大湿的回答:java中volatile关键字的疑惑?
【死磕一道面试题引发的对Java内存模型的一点疑问,第四部。】下面这个我在第三部里面提出的第一个问题:
死磕一道面试题引发的对Java内存模型的一点疑问,第四部。
文章图片

经过我长时间的思考,我对这个问题有了我自己的理解,不一定对啊。说实话我到现在都不理解优化这行代码有什么意义,哈哈。我只是在尝试理解R大的回答。
注意,本篇文章的内容大部分都是基于R大在知乎的回答写的,所以阅读本篇文章之前,你必须先把R大在知乎的回答:下面的代码 Java 线程结束原因是什么? 看完。
首先说结论:优化这行代码没有什么意义,JIT在将热点代码编译成机器码的时候就是单纯的不喜欢成员字段或者静态字段。或者说有意义,意义肯定就是为了提高代码执行效率呗。只是这种意义一般人看不出来,你需要从CPU执行指令的角度才能明白。虽然优化前和优化后这俩个变量hoistedStopRequested和stopRequested的读取都还是遵守JMM规范的,都是在线程的工作内存中读取的,不是直接去主存里面读取的。即使这样读取共享变量和读取局部变量在CPU看来肯定还是有区别的,所以JIT才优化这行代码。JMM规定普通的共享变量存在于主内存当中,然后每个线程都有自己的工作内存,每个线程用到变量的时候会先从主存中复制一份到自己的工作内存。
还有一点要注意,就是共享变量不能阻止JIT对热点代码的编译。
成员字段或者静态字段不会也不能阻止JIT对一段热点代码进行编译。JIT决定是否要编译一段代码就是看这段代码是否属于热点代码。这一点可以从R大的回答看出来。 来看一下R大回答的这段话就知道了:
死磕一道面试题引发的对Java内存模型的一点疑问,第四部。
文章图片

如果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一看骂道TM的,你循环了这么多次,这个共享变量stopRequested的值还没有发生变化,并且这个变量你也没有加volatile关键字,你不加volatile关键字就代表你可能不在乎估计也不想及时看到stopRequested这个变量的变化嘛,并且我(JIT)看八成是不会发生变化了因为你这个while循环体里面只有一行代码i++,循环体里面肯定不会改变共享变量stopRequested的值,那我JIT干脆他妈的激进一下子,把这个共享变量给你hoisting(提升)丢到循环外面去,省的影响我循环的效率。 还有最重要的一点就是 JIT做优化的时候本身就不太待见共享变量。所以此时JIT激进了,为了彻底的优化,为了极致的性能,干脆直接使出 表达式提升(expression hoisting) 这个优化。所以你这个while循环彻底变成死循环了,变成死循环你不能怪JIT,怪你自己代码写的足够烂,怪你JAVA基础知识没学全,怪你老板不给你涨工资。怪天怪地就是不怪JIT。
我在第三部里面提出的第二个问题,
死磕一道面试题引发的对Java内存模型的一点疑问,第四部。
文章图片

JMM规定普通的共享变量存在于主内存当中,然后每个线程都有自己的工作内存,每个线程用到变量的时候会先从主存中复制一份到自己的工作内存。
正常来说按照JMM的规范可能会出现下面这种情况(下图这个猜想基于第一部里面的代码):
死磕一道面试题引发的对Java内存模型的一点疑问,第四部。
文章图片

在我将R大的回答读了几十遍之后,在我问遍群友之后,我终于明白了。那是一个晴朗的下午,阳光洒在窗外的绿树叶上,俩只黄鹂在鸣着翠柳,一行白鹭在上着青天,同事们都在安详地的敲着代码,我啪一下子,就站起来了,喊了一句:“还有谁?”。那一刻,我终于明白了,JVM它是听话的,它是遵守JMM规范的。
先说结论,肯定对。结论就是:JVM肯定是遵守JMM规范的。无论加了-Xint参数之后还是加了System.out.println()这行代码之后,JVM都还是遵守JMM规范的。
那为什么加了-Xint参数之后和加了System.out.println()这行代码之后,就不会死循环了呢?按照我上面的猜想他肯定是要死循环的,原因请看下面截图里面R大的这段回答:
死磕一道面试题引发的对Java内存模型的一点疑问,第四部。
文章图片

再看PerfMa公司大神公与的回答可以比肩R大的公与大佬的回答:
死磕一道面试题引发的对Java内存模型的一点疑问,第四部。
文章图片

这俩位大神的意思就是,在x86架构的CPU上面由于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
再加一个证据,来自廖雪峰大神的教程中断线程
死磕一道面试题引发的对Java内存模型的一点疑问,第四部。
文章图片

MESI(缓存一致性)教程:玩转Java面试.08.缓存一致性协议MESI
MESI(缓存一致性):M(modified,修改)、E(exclusive,独占)、S(shared,共享)、I(invalid,失效)。
还有一点,我听说JIT在将热点代码编译为机器码的时候,总是以method为编译单位的。
x86架构的CPU对多核cacheline的同步已经做得很好了,x86为了实现强内存模型(主存和高速缓存的一致性问题),做了非常多的工作,引入了各种机制,不光有MESI协议,还有snoop这种机制,还有SMP和numa这种东西,还有store buffer。还有就是MESI协议的实现细节,英特尔其实并没有公布太多,对于很多人来说其实是一个盲盒。因为CPU是硬件,英特尔主要就是靠卖CPU赚钱的,属于英特尔公司的商业机密,所以技术壁垒更强,没有软件这么开放。arm架构和mips架构和riscv架构上没有mesi协议,他们都有自己的实现方案,mesi是intel x86提出来的概念。各种不同类型的CPU解决主存和高速缓存的一致性的方法不一样,所以JVM在JMM的层面屏蔽了这些不一致,我们JAVA开发人员针对JMM去开发就行,JMM会帮我们去跟各种不同类型的CPU打交道。AMD后来还出了一个moesi协议,缓存一致性协议MESI和MOESI,英特尔后来又出了一个mesif协议,浅谈Intel QPI的MESIF协议和Home,Source Snoop,然后arm架构又有自己的一套实现方案。
说到这里,其实我上面的说法也不太正确,在这里更正一下:我文章里面的这句话是不对的,“在x86架构上面由于MESI(缓存一致性)协议的存在,CPU在硬件层面会让另一个线程里面的缓存(线程工作副本里面的变量)失效”。
我们再来解读一下大神的回答:
死磕一道面试题引发的对Java内存模型的一点疑问,第四部。
文章图片

PerfMa公司可以比肩R大的公与大佬这句话的意思我个人的理解是这样的:我们平时写的代码其实最终都会被编译器编译成机器码,机器码应该就是由opcode(操作码)+输入数据组成的。对于CPU来说就是指令(指示命令:就是命令CPU要干什么活)。话说回来,线程里面的这个while(!stopRequested)循环最终也会被编译成机器码,注意无论你的代码属不属于热点代码,最终都会被JVM解释成机器码,只不过热点代码会交给JIT去编译并优化而已,殊途同归。代码被编译成机器码之后,操作系统就会把机器码交给CPU去执行,CPU看到这些机器码之后,就会把机器码里面的输入数据加载到CPU自己的硬件缓存里面,注意机器码是由操作码+输入数据组成的。一台电脑有多个CPU, 每个CPU都有自己的硬件缓存 ,每一个CPU在同一时刻只能执行一个线程里面的代码(代码就是机器码)。我们这个线程里面的while(!stopRequested)循环被编译成机器码之后,交给CPU去执行,CPU把数据加载到CPU自己的缓存里面,然后这个CPU一直在做循环动作,其实就是死循环,每循环一次CPU都会从自己的缓存中读取一下stopRequested这个变量,注意这个时候其实跟线程就没有关系了,需要把注意力集中到CPU这个硬件层面上,CPU在硬件层面一直在做死循环。然后,主(main)线程的代码也被编译成机器码交给另外一个CPU去执行,CPU同样也会把机器码里面的输入数据加载到CPU自己的缓存里面,其实就是把stopRequested这个变量加载到CPU自己的缓存里面,然后CPU把stopRequested这个变量的值改成true并且将true写回到CPU自己缓存里面,注意stopRequested这个变量对CPU来说处于Shared共享状态,当这个CPU把自己缓存里面的值改掉之后,这个变量在当前这个CPU里面就变成Modified修改状态了,此时根据MESI协议,这个CPU要通知其他CPU这个变量被改了,希望其他CPU都将自己缓存里面的这个变量设置为Invalid失效状态。注意另外一个CPU还在做死循环动作,每次循环都读取一下自己缓存里面的stopRequested变量,当他收到另外一个CPU的通知说这个stopRequested变量被改掉了,这个CPU就会把自己缓存里面stopRequested变量设置为Invalid失效状态,然后重新去主存里面读取一份最新的值到自己的CPU缓存里面,然后这个时候CPU继续执行循环,当这次循环读取自己缓存里面的stopRequested变量时,发现stopRequested变量变成true了,CPU就结束循环了,线程也就执行完了,该继续执行别的代码了。注意,这一切都是CPU的在处理的,属于硬件层面的,跟JMM就没有关系了。所以公与大佬才说跟线程关系不大了。JMM和JVM唯一的作用就是JVM按照JMM的规范,把我们写的代码编译成最终的机器码,然后这些机器码再命令CPU该怎么干活,就这么简单。其实,也不能说完全跟JMM没有关系了,JVM按照JMM的规范,还是会做一些指令上面的优化,加入一些memory barriers(内存屏障)这些东西。应该这么说MESI跟JMM是相互依赖的,这些具体的实现细节,软件+硬件如果要完全说清楚就太复杂了,不是计算机科班出身的不太可能完全说明白。正是因为具体实现太复杂了,所以JAVA才搞了一个抽象的JMM出来,目的就是不要让我们这些JAVA开发人员深究这些细节,你只需要知道JMM能保证什么东西就行了。抽象的目的就是屏蔽具体实现。
再说回MESI协议,当一个CPU把共享变量修改了的时候,CPU要通知其他CPU把各自缓存里面的这个共享变量设置为无效状态,并且CPU要得到其他几个CPU的响应之后,才能将修改的值写到主存里面。注意,此时就涉及到同步问题了,比较麻烦。首先这个CPU要等待另外几个CPU的响应,等待就意味着比较浪费CPU的性能。第二,这个变量是共享的可能涉及到俩个CPU同时修改了变量,同时向别的CPU发出通知,通知其他CPU把各自缓存里面的这个共享变量设置为无效状态。要解决这个问题,CPU又引入了Store Buffer这个东西。一个CPU把共享变量修改了,会把修改后的值写入到Store Buffer同时向其他CPU发出失效通知,然后就不管了,CPU就去干别的事情了。等他其他的CPU都知道共享变量被改了将自己的缓存设置为失效了,这个修改后的值才会被写入到主存里面去。这个时候JMM就出厂了,如果你的变量使用了volatile关键字修饰,JVM根据JMM的规范就会在生成机器码的时候增加一个lock(内存屏障)指令,这个指令会让修改后的值从Store Buffer立即写回到主存里面去,然后再去协调各个CPU进行无效状态设置。
参考资料:既然CPU有缓存一致性协议(MESI),为什么JMM还需要volatile关键字?
参考资料2: 是否有文档支持Java『volatile int i 在执行 i++ 的底层是非原子性的三步』的说法?
参考资料3:volatile为什么不能保证原子性?
参考资料4:64位JVM的long和double读写也不是原子操作么?
其实很多CPU的指令,英特尔官方也并没有说的很详细,具体的实现可能只有英特尔公司自己知道了。对我们使用者而言是抽象的,我们只需要知道这个指令能实现什么功能就行了。
其实线程也是一个抽象的概念,对CPU来说根本就没有什么线程,CPU就是个傻子,别人让他执行什么指令,他就执行什么指令,CPU才不管什么线程不线程的,线程是编程语言抽象出来的东西,线程的底层实现就是编程语言会指定一个CPU,让这个CPU去执行这个线程里面的指令而已。
最后,关于volatile这个关键字大家只需要记住一句话就行了。volatile只能用在一写多读的情况下,一个共享变量只有一个线程会改变,别的线程都只能读取这个变量。

    推荐阅读