大家好,我是13天才出过一次小区大门的歪歪。本文旨在填补空白。之前写文章的时候,也会把之前的一些坑填一下。但是,由于拖延,他们中的大多数人都会相隔几个月。这次这个坑比较新鲜,就是之前发表的文章《没有二十年功力,写不出这一行“看似无用”的代码!》。太多朋友看完后问同一个问题:首先非常感谢阅读我文章的朋友,也非常感谢阅读过程中带来自己思考并提出有价值问题的朋友过程,这对我来说是一种正反馈。写的时候真的没想到这个问题,突然问出来大概就知道原因了。由于没有验证,不敢贸然回答。于是就去找了这个问题的答案,所以先说结论:跟JIT编译器有关。由于循环体中的代码被判断为热代码,优化掉了JIT编译后getAndAdd方法进入安全点的机会,所以线程无法进入循环体中的安全点。是的,优化了,打这个字感觉很虐。接下来,我准备写一篇“第二部分”,告诉大家我是如何得出这个结论的。不过为了让大家顺利入戏,小编带大家简单回顾一下《上集》。另外,先说一下吧,这个知识点,属于可能一辈子都不会遇到的那种。所以我把它分成了我写的“不使用鸡蛋系列”,我只是看得开心。嗯,在上一篇文章中,我给出了这样一个测试用例:publicclassMainTest{publicstaticAtomicIntegernum=newAtomicInteger(0);publicstaticvoidmain(String[]args)throwsInterruptedException{Runnablerunnable=()->{for(inti=0;i<1000000000;i++){num.getAndAdd(1);}System.out.println(Thread.currentThread().getName()+"执行结束!");};线程t1=新线程(可运行);线程t2=新线程(可运行);t1.开始();t2.开始();线程.睡眠(1000);System.out.println("num="+num);}}根据代码,主线程休眠1000ms后才会输出结果,但实际情况是主线程一直在等待t1和t2执行完毕才继续执行。运行结果是这样的:其实我在这里埋下了一个“彩蛋”。虽然你可以直接粘贴这段代码运行,但是如果你的JDK版本高于10,运行结果会和我前面说的不一样。从结果来看,还是有不少人发现了这个“彩蛋”:所以大家在阅读文章的时候,如果有机会自己去验证一下,说不定会有意想不到的收获呢。对于程序性能与预期不符的问题,第一个解决办法是这样的:把int改成long就搞定了。至于为什么,在之前的文章中已经解释过了,这里就不赘述了。重要的是以下解决方案,所有争议都围绕着它展开。受RocketMQ源码的启发,我修改了代码如下:从运行结果来看,即使for循环的对象是int类型,也能按预期执行。为什么?因为在上一期sleep的时候,我通过查阅资料得出了两个结论:1、正在执行na??tivefunction的线程可以认为是“进入了安全点”。2、由于sleep方法是native的,调用sleep方法的线程会进入Safepoint。论证清楚,论证有理,说的很到位,事实也很清楚,所以上一集就到这里结束了……直到,很多朋友问了这个问题:但是num.getAndAdd的底层也是原生的方法调用?没错,和sleep方法一样,这也是一个native方法调用,完全符合前面的结论。为什么不进入安全点,为什么要区别对待?大胆假设,当我看到一个问题时,我的第一反应是先把责任推给JIT。毕竟除此之外,我真的觉得(不)不能(get)到(solve)。为什么我会直接想到JIT呢?因为循环中的这行代码是典型的热点代码:num.getAndAdd(1);参考《深入理解JVM虚拟机》中的描述,热点代码主要分为两类:被多次调用的方法。多次执行的循环体。前者很容易理解。如果一个方法被多次调用,那么方法体中的代码就会被执行更多次。成为“热码”是理所当然的事情。后者是解决当一个方法只被调用一次或者调用次数很少,但是方法体内有一个循环体很多,所以循环体内的代码也重复执行了很多次,所以这些代码也应该被认为是“热代码”。显然,我们的示例代码就是这种情况。在我们的示例代码中,循环体触发了热点代码的编译动作,循环体只是方法的一部分,但编译器还是要把整个方法作为编译对象。因为编译的目标对象是整个方法体,而不是单独的循环体。由于这两种类型都是“整个方法体”,有什么区别呢?不同的是执行入口(从方法的字节码指令开始)会略有不同,编译时会传入执行入口点的字节码序号(ByteCodeIndex,BCI)。这种编译方式被称为“OnStackReplacement”(OSR),因为编译发生在方法执行过程中,即方法的栈帧还在栈上,方法被替换。说到OSR听起来有点耳熟,不是吗?毕竟面试环节也偶尔会出现,因为有些高(假)级(逼)的面试题是存在的。事实上,正是如此。好吧,让我们先谈谈概念。其余的如果想了解更多,可以阅读书中的“编译对象和触发条件”部分。主要是想引出虚拟机对热点代码做了一些优化。根据前面的铺垫,我可以假设有以下两点:1、由于num.getAndAdd底层也是native方法调用,所以肯定有安全点。2.由于虚拟机判断num.getAndAdd是热点代码,一波优化来了。优化后,本应存在的安全点没有了。仔细验证一下,其实验证起来很简单。你之前不是说过吗?它是JIT优化的幽灵。然后我只是关闭JIT功能并再次运行它。你不知道结论吗?关闭JIT功能后,主线程休眠1000ms后继续执行是什么意思?说明循环体内可以进入安全点,程序执行结果符合预期。那么结果如何呢?我可以通过以下参数关闭JIT:-Djava.compiler=NONE,然后再次运行程序:关闭JIT后,主线程不等待子线程运行完毕才输出num。效果相当于上面说的把int改成long,或者加上Thread.sleep(0)这样的代码。那么我之前所做的两个假设是否有效?好了,那么问题来了。好的是仔细验证,不过我这里只是用了一个参数关闭了JIT。虽然看到了效果,但总觉得中间还是有些不足。缺什么?验证了之前的程序:经过JIT优化后,本应存在的安全点没有了。但这句话其实太笼统了。JIT优化前后是什么样子的?你能从哪里看出安全点确实不见了吗?我不能说如果它消失了,它就消失了。眼见为实。嘿,真是巧合。我只是碰巧知道有一些东西如何看待这个“优化前后”。有一个名为JITWatch的工具可以做到这一点。https://github.com/AdoptOpenJ...如果你以前没有使用过这个工具,你可以查看教程。不是本文的重点,就不教了,只是一个工具,并不复杂。我把代码粘贴到JITWatch:的沙盒中,然后点击运行,终于可以得到这样的界面了。左边是Java源码,中间是Java字节码,右边是JIT后的汇编指令:我框出来的部分是JIT分层编译后的不同汇编指令。其中,C2编译是完全编译的高性能指令,与C1编译的汇编代码有很多地方不同。如果你之前没有接触过这部分,看不懂也没关系,也很正常,毕竟面试不会考的。我给你截图的意思是,你只要知道我现在可以得到优化前后的汇编指令,但是它们之间有很多区别,那么有哪些区别需要注意呢?这就像给你两篇课文,让你找出不同之处,很简单。但是,我们关心的是众多差异中的哪一个?这是关键问题。我也不知道,但我找到了下面的文章,它让我了解了真相。重点文章还行,前面有些无伤大雅的东西,这里这篇文章是重点:http://psy-lob-saw.blogspot.c...因为我在这篇文章中发现了经过JIT优化,应该注意哪些“差异点”。这篇文章的标题是《安全点的意义、副作用以及开销》:作者是一个叫nitsanw的大佬。从他博客里的文章来看,他对JVM和性能优化有很深的造诣。上述文章发表在他的博客上。这是他的github地址:https://github.com/nitsanw使用的头像是牦牛,所以我就叫他牛哥吧,毕竟是真牛。同时,牛哥在Azul公司工作,是R大的同事:他的文章把安全点清理干净了,但是内容太多了,我不能面面俱到,只能挑地方了与本文非常相关的内容进行简要描述,但我真的强烈建议您阅读原文。文章也分为两集。这是下集的地址:http://psy-lob-saw.blogspot.c...看完你就知道什么是透彻什么是:在牛哥的文章中分为以下几个部分:什么是安全点?(什么是安全点?)我的线程什么时候处于安全点?(线程何时处于安全点?)将Java线程带到安全点。(将Java线程带到安全点)现在全部在一起。(举几个例子跑来跑去)最后的总结和遗嘱。(总结和说明)与本文的重点相关的是“将Java线程带到安全点”的部分。我给你分析一下:这段主要说Java线程需要每隔一个时间间隔轮询一个“安全点标志”。如果这个标志告诉线程“请到安全点去”,那么它就进入安全点状态。但是这个轮询是有一定消耗的,所以要尽量减少safepoint的轮询,也就是减少safepoint的轮询。因此,安全点轮询的触发时机是很有讲究的。既然说到了轮询,那么说说我们示例代码中的休眠时间:有读者把时间缩短了,比如500ms、700ms等,发现程序正常结束了?为什么?因为轮询时间是由-XX:GuaranteedSafepointInterval选项控制的,默认是1000ms:所以,当你的睡眠时间远小于1000ms时,安全点的轮询还没有开始,你的睡眠当然结束了不再观察到等待主线程的现象。好吧,这只是随便提一下。回到牛哥的文章,他说基于各种因素,安全点的轮询可以在以下几个地方进行:第一个地方:在解释器中运行时的任意2个字节之间(有效)在解释器模式下运行时,可以在任何2个字节码之间轮询安全点。理解这句话,需要理解解释器模式,最后一张图:从图中我们可以知道,解释器和编译器之间是一种互补的关系。另外,可以使用-Xint启动参数强制虚拟机以“解释模式”运行:我们可以试试这个参数:程序正常停止,为什么?刚才说:在解释器模式下运行时,可以在任意2个字节码之间进行安全点的轮询。第二名:On‘non-counted’loopbackedgeinC1/C2compiledcode在C1/C2编译代码中“non-counted”循环的每个循环体结束后。关于这个“计数循环”和“非计算循环”,我在上一集已经说过和演示过,就是把int改成long,让“计数循环”变成“非计算循环”,所以我不会详细介绍。反正我们知道这里说的没有错。第三名:这是前半句:Methodentry/exit(Zing为entry,OpenJDK为exit)在C1/C2编译代码中。C1/C2编译代码中的方法入口或出口处(Zing为入口,OpenJDK为出口)。前半句很容易理解。对于我们常用的OpenJDK来说,即使经过了JIT优化,在方法的入口处仍然有一个地方可以进行安全点轮询。主要关注后半句:注意编译器会在方法被内联时移除这些safepointpolls。当方法被内联时,编译器将删除这些安全点轮询。我们的示例代码不就是这样吗?本来有个安全点,后来优化掉了。表明这种情况是真实的。然后我们继续往下看,可以看到我一直在寻找的“差异点”:牛哥说,如果有人想看安全点轮询,可以加上这个启动参数:-XX:+PrintAssembly然后在输出中寻找以下关键字:如果是OpenJDK,则寻找{poll}或{pollreturn},这是对应的安全点指令。如果是Zing,请查找tls.pls_self_suspend命令并尝试一下。是这样的:确实找到了相似的关键词,但是控制台输出的编译太多了,根本分析不出来。没关系,没关系,重要的是我得到了这个关键指令:{poll}也就是说,如果在初始汇编中有{poll}指令,但是JIT之后的代码是完全优化,也就是上述C2阶段的汇编指令中,找不到{poll}指令,也就是说安全点确实被杀掉了。因此,在JITWatch中,当我选择查看C1阶段的for循环(热点代码)的编译结果时,可以看到有一个{poll}指令:但是,当我选择C2阶段的编译结果时,{poll}指令确实少了:那么,如果我把代码修改成这样,就是上面说的会正常结束的代码:正常结束,说明循环体可以进入安全点,也就是说有一个{poll}指令。于是,再通过JITWarch看C2的汇编,果然看到了:why?从最终输出的汇编来看,Thread.sleep(0)这行代码的存在避免了JIT做过于激进的优化。那么为什么睡眠会阻止JIT进行过于激进的优化呢?行了,别问了,就到这里吧,再问就不礼貌了。牛哥的案例在牛哥的文章中,给出了以下五个案例,每个案例都有对应的代码:示例0:长TTSP挂起应用示例1:MoreRunningThreads->LongerTTSP,HigherPauseTimes示例2:LongTTSPhasUnfairImpact示例3:SafepointOperationCostScaleExample4:AddingSafepointpollsstopsOptimization我主要给大家看0th和4th,很有意思。第0种情况的代码是这样的:publicclassWhenWillItExit{publicstaticvoidmain(String[]argc)throwsInterruptedException{Threadt=newThread(()->{longl=0;for(inti=0;i
