当前位置: 首页 > 后端技术 > Java

一道面试题引发的关于Java内存模型的小疑问,第四部分

时间:2023-04-02 01:04:28 Java

一道面试题引起的关于Java内存模型的小疑问,第四部分。第一部分在这里一道面试题引发的关于Java内存模型的小疑问?第二部分是这里一道面试题引起的关于Java内存模型的一点疑问,第二部分和第三部分是这里一道面试题引起的关于Java内存模型的一点疑问,第三部分。网友讨论:大R的回答:下面代码中Java线程结束的原因是什么?孔武大神的回答:一个println比volatile好?知乎的回答:java中volatile关键字的疑惑?下面是我在第三部分提出的第一个问题:想了很久,我对这个问题有自己的理解,不一定是正确的。说实话,我还是不明白优化这行代码的意义,哈哈。我只是想了解Rbig的回答。注意,本文的大部分内容都是基于R太宰知乎的回答,所以在阅读本文之前,你必须先写下R太宰知乎的回答:下面代码中Java线程结束的原因是什么?阅读。首先得出结论:优化这行代码没有意义。JIT在将热点代码编译成机器码时,根本不喜欢成员字段或静态字段。也就是说,它是有意义的,而且这个意义一定是为了提高代码执行的效率。只是一般人看不到这个意思,需要从CPU执行指令的角度去理解。优化前后的hoistedStopRequested和stopRequested这两个变量的读取虽然仍然符合JMM规范,但是都是在线程的工作内存中读取,而不是直接在主内存中读取。即便如此,在CPU眼中读取共享变量和读取局部变量肯定是有区别的,所以JIT对这行代码进行了优化。JMM规定普通共享变量存在于主存中,然后每个线程都有自己的工作内存。每个线程在使用一个变量时,都会先从主存中拷贝一份到自己的工作内存中。还有一点要注意,共享变量不能阻止JIT编译热代码。成员字段或静态字段不会也不能阻止JIT编译一段热代??码。JIT决定是否编译一段代码,看这段代码是不是热代码。从R的回答中可以看出这一点。看看这段R的回答:如果run()方法是由HotSpotServerCompiler编译的:额外的System.out.println()调用干扰了编译器的优化,导致提升不成功。但是,也有可能这个run()方法根本没有来得及编译。从R大的那句话可以看出,只要这个while()循环有机会运行,并且运行多次后成为热点代码,这个run方法肯定会被JITin直接编译成机器码结束。不管你加不加System.out.println()这行代码,不管循环里有没有共享变量,run方法运行多了最终都会编译成机器码。添加System.out.println()这行代码后,只会影响JIT在将run方法编译成机器码时无法优化。具体如下:我们都知道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执行。通常情况下,我们的代码每次运行起来,JVM都要解释一次。边解释边跑,效率很低。但需要注意:javac不能直接将.java文件编译成机器码。如果直接编译成特定的机器码,就会失去跨平台的特性。为了提高效率,出现了JIT(JustInTimeCompile)。JIT会将热点代码编译成机器码并保存(缓存)。下次JVM解释.class文件的时候,可以看到这段代码很火代码没有逐行解释,直接交给JIT,JIT会直接运行已经编译好的机器码之前,大大提高了效率。JIT在将热点代码编译成机器码的同时,还对.class文件进行了优化,以进一步提高效率。当然,有些优化比较激进,一旦激进,就会出现问题。但是您不必担心这种激进的优化。JIT敢于积极优化,是因为你写的代码太笨或者你不遵循JAVA的语法。就像这道面试题,正确的写法一定是给变量加上关键字volatile。如果你不写它,不要责怪JIT进行积极的优化。JIT也是为您好。JIT想根据你写的代码让你写的代码运行得更快。这道面试题中的while(!stopRequested)循环无疑是一个热点代码,JIT必须对其进行编译。JIT在将这个方法编译成机器码的时候,这个while(!stopRequested)循环已经执行了很多次,所以JIT很了解这个循环。JIT一看就骂TM了,你循环了这么多次,共享变量stopRequested的值没有变,而且你还没有给这个变量加上volatile关键字,如果不加volatile关键字,说明你可能不关心估计。我不想及时看到stopRequested变量的变化,我(JIT)认为它不会变化,因为你的while循环体中只有一行代码i++,而共享变量stopRequested的值肯定不会在循环体内改,那我的JIT简直是他妈的激进,把这个给你提升(hoisting)的共享变量扔出循环,以免影响我循环的效率。而且最重要的一点是,JIT在优化的时候,是不喜欢看到共享变量的。所以这个时候,JIT就激进了。为了彻底优化和极致性能,它只是求助于表达式提升的优化。所以你的while循环彻底变成死循环了,不能怪JIT变成死循环,怪自己代码写的够烂,怪你没学好JAVA的基础,怪你老板因为不给你加薪。奇怪的是,JIT并不是罪魁祸首。我在第三部分提出的第二个问题,JMM规定普通共享变量存在于主内存中,然后每个线程都有自己的工作内存。将副本复制到您的工作记忆中。通常按照JMM规范,可能会出现如下情况(下图的猜想是根据第一部分的代码推测出来的):R专业的答案我看了几十遍,问了所有的朋友之后,终于,我明白了。那是一个阳光明媚的午后,阳光洒在窗外的绿叶上,两只金莺在叫绿柳,一群白鹭在仰望蓝天,同事们在安静地敲代码。起身大喊:“还有谁?”。那一刻,我终于明白了,JVM是听话的,它遵守JMM规范。先说结论,绝对没错。结论是:JVM必须遵守JMM规范。无论是添加了-Xint参数还是添加了System.out.println()这行代码,JVM还是符合JMM规范的。那为什么加了-Xint参数,加上System.out.println()这行代码,就不会死循环了呢?根据我上面的猜测,他一定是在死循环中。原因请看下面截图中R大学的回答:再看PerfMa的大神公鱼的回答,可以类比R大公和大佬的回答:这两个高手的意思是,由于x86架构的CPU上存在MESI(缓存一致性)协议,CPU会在硬件层面使另一个线程中的缓存(线程工作副本中的变量)失效,while循环只需要读取这个变量,你总能得到最新的值,即使你不加volatile关键字,x86架构的CPU也会一直让你看到变量的最新值。第一:加入-Xint参数后,JVM会禁用JIT。禁用JIT后,JIT肯定不会优化,自然不会在循环外引发变量stopRequested。第二:添加System.out.println()这行代码后,会影响JIT进行激进的优化,不会将变量stopRequested抛出循环。只要变量stopRequested在循环中,每次循环都会读取一次。注意,此时每次循环读取的都是读取线程工作副本中的变量,不会读取主存中的变量。JMM的规范。但是由于x86架构上MESI(cacheconsistency)协议的存在,线程的工作副本中的变量在某个时候会失效。一旦线程发现自己工作内存中的变量无效,就会去主内存重新读取。一段时间后,这个读取会读取到变量的最新值,循环结束。综上所述,JVM还是符合JMM规范的。从这里可以得出另一个结论,那就是关键字volatile与MESI(缓存一致性)协议无关。无论是否加上关键字volatile,MESI(缓存一致性)协议都会在x86架构上生效。不一定在arm架构的CPU上。所以还是要规范写代码,最好加上volatile关键字。深入汇编指令理解Java关键字volatile再补充证明,出自廖雪峰教程中断线程MESI(缓存一致性)教程:玩转Java面试。08.缓存一致性协议MESIMESI(缓存一致性):M(修改,修改),E(独占,独占),S(共享,共享),I(无效,无效)。还有一点,听说JIT将热点代码编译成机器码时,总是以method为编译单元。x86架构的CPU在同步多核cacheline方面做得很好。为了实现强内存模型(主存和缓存一致性问题),x86做了很多工作,引入了各种机制,不仅有MESIProtocol,还有snoop之类的机制,SMP和numa之类的东西,storebuffers。还有MESI协议的实现细节。Intel其实并没有过多的公布,对于很多人来说其实是一个盲盒。因为CPU是硬件,Intel主要靠卖CPU赚钱,这是Intel的商业机密,所以技术壁垒比较强,不像软件那么开放。arm架构、mips架构、riscv架构上都没有mesi协议。他们都有自己的实施方案。Mesi是intelx86提出的概念。各种类型的CPU解决主存和缓存一致性的方法不同,所以JVM在JMM层面屏蔽了这些不一致。我们的JAVA开发人员可以针对JMM进行开发,JMM会帮助我们与各种类型的CPU进行通信来处理。AMD后来发布了一个moesi协议,缓存一致性协议MESI和MOESI,Intel后来发布了一个mesif协议,说说IntelQPI的MESIF协议和Home,SourceSnoop,然后arm架构有自己的一套实现方案。说到这里,其实我上面的说法不太正确,所以我在这里更正一下:我文章中的那句话是不正确的,“由于x86架构上存在MESI(缓存一致性)协议,CPU在硬件级别将使另一个线程中的缓存(线程工作副本中的变量)无效”。再解读一下大神的回答:我个人对这句话意思的理解是,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的共享状态。当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一点关系都没有。根据JMM规范,JVM还是会优化一些指令,增加一些内存屏障(memorybarriers)。应该说MESI和JMM是相互依存的。这些具体的实现细节,软件+硬件,太复杂了,无法完全解释清楚,非计算机专业的人是不可能完全解释清楚的。正是因为具体实现过于复杂,JAVA才创造了一个抽象的JMM。目的不是让我们JAVA开发人员去深究这些细节。你只需要知道JMM能保证什么。抽象的目的是为了屏蔽具体的实现。回到MESI协议,当一个CPU修改共享变量时,CPU必须通知其他CPU将各自缓存中的共享变量设置为无效状态,并且CPU必须在修改值写入之前收到其他CPU的响应到主存储器。注意这里涉及到同步问题,比较麻烦。首先这个CPU要等待其他几个CPU的响应,等待就意味着CPU性能的浪费。第二,这个变量是共享的,可能涉及两个CPU同时修改这个变量,同时向其他CPU发送通知,通知其他CPU将各自缓存中的共享变量置为无效状态。为了解决这个问题,CPU引入了StoreBuffer。当一个CPU修改共享变量时,它会将修改后的值写入StoreBuffer并向其他CPU发送失效通知,然后忽略它,CPU会去做其他事情。当其他CPU知道共享变量发生变化,将自己的缓存设置为无效时,会将修改后的值写入主存。此时JMM已出厂。如果你的变量被volatile关键字修饰,JVM会按照JMM规范在生成机器码的时候加锁(memorybarrier)指令。该指令会使修改后的值从StoreBuffer立即写回主存,然后协调各个CPU设置无效状态。参考:既然CPU有缓存一致性协议(MESI),为什么JMM需要volatile关键字?参考资料2:有文档支持Java的“volatileinti是执行i++底层的非原子三步过程”的说法吗?参考3:为什么volatile不能保证原子性?参考4:64位JVM的long和double读写不是原子操作吗?其实很多CPU的指令,Intel官方并没有说的很详细,具体的实现可能只有Intel自己才知道。对于我们的用户来说,它是抽象的,我们只需要知道这条命令可以实现什么功能即可。其实线程也是一个抽象的概念。对于CPU来说,根本就没有线程。CPU是个傻瓜。他会执行别人要求他执行的任何指令。CPU不关心线程是什么。线程是一种编程语言的抽象。结果呢,线程的底层实现是编程语言会指定一个CPU,让CPU去执行线程中的指令。最后,你只需要记住关于关键字volatile的一句话。volatile只能用在一次写入多次读取的情况下。只有一个线程会改变一个共享变量,其他线程只能读取这个变量。