关于后端:写个续集填坑来了关于Threadsleep0这一行‘看似无用的代码里面留下的坑

34次阅读

共计 7335 个字符,预计需要花费 19 分钟才能阅读完成。

你好呀,我是居家十三天只出了一次小区门的歪歪。

这篇文章是来填坑的,我以前写文章的时候也会去填之前的一些坑,然而因为迁延症,大多都会隔上上几个月。

这次这个坑比拟陈腐,就是之前公布的《没有二十年功力,写不出这一行“看似无用”的代码!》这篇文章,太多太多的敌人看完之后问出了一个雷同的问题:

首先非常感谢浏览我文章的敌人,同时也特别感谢浏览的过程中带着本人的思考,提出有价值的问题的敌人,这对我而言是一种正反馈。

我过后写的时候的确没有想到这个问题,所以当忽然问起的时候我大略晓得起因,因为未做验证,所以也不敢贸然答复。

于是我寻找了这个问题的答案,所以先说论断:

就是和 JIT 编译器无关。因为循环体中的代码被断定为热点代码,所以通过 JIT 编译后 getAndAdd 办法的进入平安点的机会被优化掉了,所以线程不能在循环体能进入平安点。

是的,被优化了,我打这个词都感觉很仁慈。

接下来我筹备写个“下集”,通知你我是怎么失去这个论断的。然而为了让你丝滑入戏,我先带你简略的回顾一下“上集”。

另外,先把话说在后面,这知识点吧,属于可能一辈子都遇不到的那种。 因而我把它划分到我写的“没有卵用系列”,看着也就图一乐。

好了,在之前的那篇文章中,我给出了这样的一个测试用例:

public class MainTest {public static AtomicInteger num = new AtomicInteger(0);

    public static void main(String[] args) throws InterruptedException {Runnable runnable=()->{for (int i = 0; i < 1000000000; i++) {num.getAndAdd(1);
            }
            System.out.println(Thread.currentThread().getName()+"执行完结!");
        };

        Thread t1 = new Thread(runnable);
        Thread t2 = new Thread(runnable);
        t1.start();
        t2.start();
        Thread.sleep(1000);
        System.out.println("num =" + num);
    }
}

依照代码来看,主线程休眠 1000ms 后就会输入后果,然而理论状况却是主线程始终在期待 t1,t2 执行完结才继续执行。

运行后果是这样的:

其实我在这里埋了“彩蛋”,这个代码尽管你间接粘贴过来就能跑,然而如果你的 JDK 版本高于 10,那么运行后果就和我后面说的不一样了。

从后果来看,还是有不少人开掘到了这个“彩蛋”:

所以看文章的时候,有机会本人亲自验证一下,说不定会有意外播种的。

针对程序体现和预期不统一的问题,第一个解决方案是这样的:

把 int 批改为 long 就搞定了。至于为什么,之前的文章中曾经阐明了,这里就不赘述了。

要害的是上面这个解决方案,所有的争议都围绕着它开展。

受到 RocketMQ 源码的启发,我把代码批改为了这样:

从运行后果上来看,即便 for 循环的对象是 int 类型,也能够依照预期执行。

为什么呢?

因为在上集中对于 sleep 我通过查阅材料得出了这样的两个论断:

  • 1. 正在执行 native 函数的线程能够看作“曾经进入了 safepoint”。
  • 2. 因为 sleep 办法是 native 的,所以调用 sleep 办法的线程会进入 Safepoint。

论点清晰、论据正当、推理完满、事实清楚,所以上集演到这里就完结了 …

直到,有很多敌人问出了这个问题:

可是 num.getAndAdd 底层也是 native 办法调用啊?

对啊,和 sleep 办法一样,这也是 native 办法调用啊,完全符合后面的论断啊,它为什么不进入平安点呢,为什么要搞差异看待呢?

大胆假如

看到问题的时候,我的第一反馈就是先把锅甩给 JIT 吧,毕竟除了它,其余的我也切实想(不)不(了)到(解)。

为什么我会间接想到 JIT 呢?

因为循环中的这一行的代码属于典型的热点代码:

num.getAndAdd(1);

援用《深刻了解 JVM 虚拟机》外面的形容,热点代码,次要是分为两类:

  • 被屡次调用的办法。
  • 被屡次执行的循环体。

前者很好了解,一个办法被调用得多了,办法体内代码执行的次数天然就多,它成为“热点代码”是天经地义的。

而后者则是为了解决当一个办法只被调用过一次或大量的几次,然而办法体外部存在循环次数较多的循环体,这样循环体的代码也被反复执行屡次,因而这些代码也应该认为是“热点代码”。很显著,咱们的示例代码就属于这种状况。

在咱们的示例代码中,循环体触发了热点代码的编译动作,而循环体只是办法的一部分,但编译器仍然必须以整个办法作为编译对象。

因为编译的指标对象都是整个办法体,不会是独自的循环体。

既然两种类型都是“整个办法体”,那么区别在于什么中央?

区别就在于执行入口(从办法第几条字节码指令开始执行)会稍有不同,编译时会传入执行入口点字节码序号(Byte Code Index,BCI)。

这种编译形式因为编译产生在办法执行的过程中,因而被很形象地称为“栈上替换”(On Stack Replacement,OSR),即办法的栈帧还在栈上,办法就被替换了。

说到 OSR 你就略微耳熟了一点,是不是?毕竟它也偶现于面试环节中,作为一些高(装)阶(逼)面试题存在。

其实也就这么回事。

好,概念就先说到这里,剩下的如果你想要具体理解,能够去翻阅书外面的“编译对象与触发条件”大节。

我次要是为了引出虚拟机针对热点代码搞了一些优化这个点。

基于后面的铺垫,我齐全能够假如如下两点:

  • 1. 因为 num.getAndAdd 底层也是 native 办法调用,所以必定有平安点的产生。
  • 2. 因为虚拟机断定 num.getAndAdd 是热点代码,就来了一波优化。优化之后,把原本应该存在的平安点给干没了。

小心求证

其实验证起来非常简单,后面不是说了吗,是 JIT 优化搞的鬼,那我间接敞开 JIT 性能,再跑一次,不就晓得论断了吗?

如果敞开 JIT 性能后,主线程在睡眠 1000ms 之后继续执行,阐明什么?

阐明循环体外面能够进入 safepoint,程序执行后果合乎预期。

所以后果是怎么样的呢?

我能够用上面的这个参数敞开 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…

看完之后,你就晓得,什么叫做透彻,什么叫做:

在牛哥的文章中分为了上面这几个大节:

  • What’s a Safepoint?(啥是平安点?)
  • When is my thread at a safepoint?(线程啥时候处于平安点?)
  • Bringing a Java Thread to a Safepoint。(将一个 Java 线程带到一个平安点)
  • All Together Now。(搞几个例子跑跑)
  • Final Summary And Testament。(总结和嘱咐)

和本文重点相干的是“将一个 Java 线程带到一个平安点”这个局部。

我给你解析一下:

这一段次要在说 Java 线程须要每隔一个工夫距离去轮询一个“平安点标识”,如果这个标识通知线程“请返回平安点”,那么它就进入到平安点的状态。

然而这个轮询是有肯定的耗费的,所以须要 keep safepoint polls to a minimum,也就是说要缩小平安点的轮询。因而,对于平安点轮询触发的工夫就很有考究。

既然这里提到轮询了,那么就得说一下咱们示例代码外面的这个 sleep 工夫了:

有的读者把工夫改的短了一点,比方 500ms,700ms 之类的,发现程序失常完结了?

为什么?

因为轮询的工夫由 -XX:GuaranteedSafepointInterval 选项管制,该选项默认为 1000ms:

所以,当你的睡眠工夫比 1000ms 小太多的时候,平安点的轮询还没开始,你就 sleep 完结了,当然察看不到主线程期待的景象了。

好了,这个只是随口提一句,回到牛哥的文章中,他说综合各种因素,对于平安点的轮询,能够在以下中央进行:

第一个中央:

Between any 2 bytecodes while running in the interpreter (effectively)

在解释器模式下运行时,在任何 2 个字节码之间都能够进行平安点的轮询。

要了解这句话,就须要理解解释器模式了,上个图:

从图上能够晓得,解释器和编译器之间是相辅相成的关系。

另外,能够应用 -Xint 启动参数,强制虚拟机运行于“解释模式”:

咱们齐全能够试一试这个参数嘛:

程序失常停下来了,为什么?

刚刚才说了:

在解释器模式下运行时,在任何 2 个字节码之间都能够进行平安点的轮询。

第二个中央:

On ‘non-counted’ loop back edge in C1/C2 compiled code

在 C1/C2 编译代码中的 “ 非计数 “ 循环的每次循环体完结之后。

对于这个“计数循环”和“非计算循环”我在上集外面曾经说过了,也演示过了,就是把 int 批改为 long,让“计数循环”变成“非计算循环”,就不赘述了。

反正咱们晓得这里说的没故障就行。

第三个中央:

这是前半句:Method entry/exit (entry for Zing, exit for OpenJDK) in C1/C2 compiled code.

在 C1/C2 编译代码中的办法入口或者出口处(Zing 为入口,OpenJDK 为进口)。

前半句很好了解,对于咱们罕用的 OpenJDK 来说,即便通过了 JIT 优化,然而在办法的入口处还是设置了一个能够进行平安点轮询的中央。

次要是关注后半句:

Note that the compiler will remove these safepoint polls when methods are inlined.

当办法被内联时编译器会删除这些平安点轮询

这不就是咱们示例代码的状况吗?

原本有平安点,然而被优化没了。阐明这种状况是实在存在的。

而后咱们接着往下看,就能看到我始终在找的“差别点”了:

牛哥说,如果有人想看到平安点轮询,那么能够加上这个启动参数:

-XX:+PrintAssembly

而后在输入外面找上面的关键词:

  • 如果是 OpenJDK,就找 {poll} 或 {poll return},这就是对应的平安点指令。
  • 如果是 Zing,就找 tls.pls_self_suspend 指令

实操一把就是这样的:

的确找到了相似的关键字,然而在控制台输入的汇编太多了,基本没法剖析。

没关系,这不重要,重要的是我到了这个要害的指令:{poll}

也就是说,如果在初始的汇编中有 {poll} 指令,然而在通过 JIT 充沛优化之后的代码,也就是后面说的 C2 阶段的汇编指令外面,找不到 {poll} 这个指令,就阐明平安点的确是被干掉了。

所以,在 JITWatch 外面,当我抉择查看 for 循环(热点代码)在 C1 阶段的编译后果的时候,能够看看有 {poll} 指令:

然而,当我抉择 C2 阶段的编译后果的时候,{poll} 指令的确都找不到了:

接着,如果我把代码批改为这样,也就是后面说的会失常完结的代码:

失常完结,阐明循环体内能够进入平安点,也就是说明有 {poll} 指令。

所以,再次通过 JITWarch 查看 C2 的汇编,果然看到了它:

为什么呢?

从最终输入的汇编上来看,因为 Thread.sleep(0) 这行代码的存在,阻止了 JIT 做过于激进的优化。

那么为什么 sleep 会阻止 JIT 做过于激进的优化呢?

好了,

别问了,

就到这吧,

再问,

就不礼貌了。

牛哥的案例

牛哥的文章中给了上面五个案例,每个案例都有对应的代码:

  • Example 0: Long TTSP Hangs Application
  • Example 1: More Running Threads -> Longer TTSP, Higher Pause Times
  • Example 2: Long TTSP has Unfair Impact
  • Example 3: Safepoint Operation Cost Scale
  • Example 4: Adding Safepoint Polls Stops Optimization

我次要带大家看看第 0 个和第 4 个,老有意思了。

第 0 个案例

它的代码是这样的:

public class WhenWillItExit {public static void main(String[] argc) throws InterruptedException {Thread t = new Thread(() -> {
      long l = 0;
      for (int i = 0; i < Integer.MAX_VALUE; i++) {for (int j = 0; j < Integer.MAX_VALUE; j++) {if ((j & 1) == 1)
            l++;
        }
      }
      System.out.println("How Odd:" + l);
    });
    t.setDaemon(true);
    t.start();
    Thread.sleep(5000);
  }
}

牛哥是这样形容这个代码的:

他说这个代码应该是在 5 秒之后完结,然而实际上它会始终运行上来,除非你用 kill -9 命令强行进行它。

然而当我把代码粘贴到 IDEA 外面运行起来,5 秒之后,程序停了,就略显难堪。

我倡议你也粘进去跑一下。

这里为什么和牛哥说的运行后果不一样呢?

评论区也有人问出了这个问题:

于是牛哥又写了一篇下集,具体的解释了为什么:

http://psy-lob-saw.blogspot.c…

简略来说就是他是在 Eclipse 外面跑的,而 Eclipse 并不是用的 javac 来编译,而是用的本人的编译器。

编译器差别导致字节码的差别,从而导致运行后果的差别:

而后牛哥通过一顿剖析,给出了这样的一段代码,

和之前的代码惟一不一样的中央,就是在子线程外面调用 countOdds 办法之前,在主线程外面先进行了 10w 次的运行调用。

这样革新之后代码运行起来就不会在 5 秒之后进行了,必须要强行 kill 才行。

为什么呢?

别问,问就是答案就在他的下集外面,本人去翻,写的十分具体。

同时在下集中,牛哥还十分贴心的给你贴出了他总结的六种循环的写法,那些算是“counted Loops”,倡议认真分别:

第 4 个案例

这个案例是一个基准测试,牛哥说它是来自 Netty 的一个 issue:

这里怎么忽然提到 Netty 了呢?

牛哥给了一个超链接:

https://github.com/netty/nett…

这个 pr 外面探讨的内容十分的多,其中一个争执的点就是循环到底用 int 还是 long。

这个哥们写了一个基准测试,测试结果显示用 int 和 long 仿佛没啥差异:

须要阐明的是,为了截图不便,我截图的时候把这个老哥的基准测试删除了。如果你想看他的基准测试代码,能够通过后面说的链接去找到。

而后这个看起来头发就很繁茂的老哥间接用召唤术号召了牛哥:

等了一天之后,牛哥写了一个十分十分具体的回复,我还是只截取其中一部分:

他上来就说后面的老哥的基准测试写的有点故障,所以看起来差不多。你看看我写的基准测试跑进去的分,差距就很大了。

牛哥这里提到的基准测试,就是咱们的第四个案例。

所以也能够联合着 Netty 的这个特地长的 pr 去看这个案例,看看什么叫做业余。

最初,再说一次,文中提到的牛哥的两篇文章,倡议仔细阅读。

另外,对于平安点的源码,我之前也分享过这篇文章,倡议一起食用,滋味更佳:《对于平安点的那点破事儿》

我只是给你指个路,剩下的路就要你本人走了,天黑路滑,灯火暗淡,小心脚下,不要深究,及时回头,阿弥陀佛!

最初,感激你浏览我的文章。欢送关注公众号【why 技术】,文章全网首发哦。

正文完
 0