首页 首页 资讯 查看内容

没有二十年功力,写不出sleep(0)这一行“看似无用”的代码

2022-10-26| 发布者: 浦江新媒体| 查看: 135| 评论: 1|文章来源: 互联网

摘要: 你好呀,我是喜提七天居家隔离得歪歪。这篇文章要从一个奇怪的注释说起,就是下面这张图:我们可以不用管具......
北京心理咨询

你好呀,我是喜提七天居家隔离得歪歪。

这篇文章要从一个奇怪的注释说起,就是下面这张图:

我们可以不用管具体的代码逻辑,只是单单看这个for循环。

在循环里面,专门有个变量j,来记录当前循环次数。

第一次循环以及往后每1000次循环之后,进入一个if逻辑。

在这个if逻辑之上,标注了一个注释:preventgc.

prevent,这个单词如果不认识的同学记一下,考试肯定要考的:

这个注释翻译一下就是:防止GC线程进行垃圾回收。

具体的实现逻辑是这样的:

核心逻辑其实就是这样一行代码:

Thread.sleep(0);

这样就能实现preventgc了?

懵逼吗?

懵逼就对了,懵逼就说明值得把玩把玩。

这个代码片段,其实是出自RocketMQ的源码:

org.apache.rocketmq.store.logFile.DefaultMappedFile#warmMappedFile

事先需要说明的是,我并没有找到写这个代码的人问他的意图是什么,所以我只有基于自己的理解去推测他的意图。如果推测得不对,还请多多指教。

虽然这是RocketMQ的源码,但是基于我的理解,这个小技巧和RocketMQ框架没有任何关系,完全可以脱离于框架存在。

我给出的修改意见是这样的:

把int修改为long,然后就可以直接把for循环里面的if逻辑删除掉了。

这样一看是不是更加懵逼了?

不要慌,接下来,我给你抽丝剥个茧。

另外,在“剥茧”之前,我先说一下结论:

提出这个修改方案的理论立足点是Java的安全点相关的知识,也就是safepoint。官方最后没有采纳这个修改方案。官方采没采纳不重要,重要的是我高低得给你“剥个茧”。

探索

当我知道这个代码片段是属于RocketMQ的时候,我想到的第一个点就是从代码提交记录中寻找答案。

看提交者是否在提交代码的时候说明了自己的意图。

于是我把代码拉了下来,一看提交记录是这样的:

我就知道这里不会有答案了。

因为这个类第一次提交的时候就已经包含了这个逻辑,而且对应这次提交的代码也非常多,并没有特别说明对应的功能。

从提交记录上没有获得什么有用的信息。

于是我把目光转向了github的issue,拿着关键词preventgc搜索了一番。

除了第一个链接之外,没有找到什么有用的信息:

而第一个链接对应的issues是这个:

https://github.com/apache/rocketmq/issues/4902

这个issues其实就是我们在讨论这个问题的过程中提出来的,也就是前面出现的修改方案:

也就是说,我想通过源码或者github找到这个问题权威的回答,是找不到了。

于是我又去了这个神奇的网站,在里面找到了这个2018年提出的问题:

https://stackoverflow.com/questions/53284031/why-thread-sleep0-can-prevent-gc-in-rocketmq

问题和我们的问题一模一样,但是这个问题下面就这一个回答:

这个回答并不好,因为我觉得没答到点上,但是没关系,我刚好可以把这个回答作为抓手,把差的这一点拉通对齐一下,给它赋能。

先看这个回答的第一句话:Itdoesnot(它没有)。

问题就来了:“它”是谁?“没有”什么?

“它”,指的就是我们前面出现的代码。

“没有”,是说没有防止GC线程进行垃圾回收。

这个的回答说:通过调用Thread.sleep(0)的目的是为了让GC线程有机会被操作系统选中,从而进行垃圾清理的工作。它的副作用是,可能会更频繁地运行GC,毕竟你每1000次迭代就有一次运行GC的机会,但是好处是可以防止长时间的垃圾收集。

换句话说,这个代码是想要“触发”GC,而不是“避免”GC,或者说是“避免”时间很长的GC。从这个角度来说,程序里面的注释其实是在撒谎或者没写完整。

不是preventgc,而是对gc采取了“打散运行,削峰填谷”的思想,从而preventlongtimegc。

但是你想想,我们自己编程的时候,正常情况下从来也没冒出过“这个地方应该触发一下GC”这样想法吧?

因为我们知道,Java程序员来说,虚拟机有自己的GC机制,我们不需要像写C或者C++那样得自己管理内存,只要关注于业务代码即可,并没有特别注意GC机制。

那么本文中最关键的一个问题就来了:为什么这里要在代码里面特别注意GC,想要尝试“触发”GC呢?

先说答案:safepoint,安全点。

关于安全点的描述,我们可以看看《深入理解JVM虚拟机(第三版)》的3.4.2小节:

注意书里面的描述:

有了安全点的设定,也就决定了用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达安全点后才能够暂停。

换言之:没有到安全点,是不能STW,从而进行GC的。

如果在你的认知里面GC线程是随时都可以运行的。那么就需要刷新一下认知了。

接着,让我们把目光放到书的5.2.8小节:由安全点导致长时间停顿。

里面有这样一段话:

我把划线的部分单独拿出来,你仔细读一遍:

是HotSpot虚拟机为了避免安全点过多带来过重的负担,对循环还有一项优化措施,认为循环次数较少的话,执行时间应该也不会太长,所以使用int类型或范围更小的数据类型作为索引值的循环默认是不会被放置安全点的。这种循环被称为可数循环(CountedLoop),相对应地,使用long或者范围更大的数据类型作为索引值的循环就被称为不可数循环(UncountedLoop),将会被放置安全点。

意思就是在可数循环(CountedLoop)的情况下,HotSpot虚拟机搞了一个优化,就是等循环结束之后,线程才会进入安全点。

反过来说就是:循环如果没有结束,线程不会进入安全点,GC线程就得等着当前的线程循环结束,进入安全点,才能开始工作。

什么是可数循环(CountedLoop)?

书里面的这个案例来自于这个链接:

https://juejin.cn/post/6844903878765314061HBase实战:记一次Safepoint导致长时间STW的踩坑之旅

如果你有时间,我建议你把这个案例完整的看一下,我只截取问题解决的部分:

截图中的while(i

所以,修改方案就是把int修改为long。

原理就是让其变为不可数循环(UncountedLoop),从而不用等循环结束,在循环期间就能进入Safepoint。

接着我们再把目光拉回到这里:

这个循环也是一个可数循环。

Thread.sleep(0)这个代码看起来莫名其妙,但是我是不是可以大胆的猜测一下:故意写这个代码的人,是不是为了在这里放置一个Safepoint呢,以达到避免GC线程长时间等待,从而加长stoptheworld的时间的目的?

所以,我接下来只需要找到sleep会进入Safepoint的证据,就能证明我的猜想。

你猜怎么着?

本来是想去看一下源码,结果啪的一下,在源码的注释里面,直接找到了:

https://hg.openjdk.java.net/jdk8u/jdk8u/hotspot/file/tip/src/share/vm/runtime/safepoint.cpp

注释里面说,在程序进入Safepoint的时候,Java线程可能正处于框起来的五种不同的状态,针对不同的状态有不同的处理方案。

本来我想一个个的翻译的,但是信息量太大,我消化起来有点费劲儿,所以就不乱说了。

主要聚焦于和本文相关的第二点:Runninginnativecode。

Whenreturningfromthenativecode,aJavathreadmustcheckthesafepoint_statetoseeifwemustblock.

第一句话,就是答案,意思就是一个线程在运行native方法后,返回到Java线程后,必须进行一次safepoint的检测。

同时我在知乎看到了R大的这个回答,里面有这样一句,也印证了这个点:

https://www.zhihu.com/question/29268019/answer/43762165

那么接下来,就是见证奇迹的时刻了:

根据R大的说法:正在执行native函数的线程看作“已经进入了safepoint”,或者把这种情况叫做“在safe-region里”。

sleep方法就是一个native方法,你说巧不巧?

所以,到这里我们可以确定的是:调用sleep方法的线程会进入Safepoint。

另外,我还找到了一个2013年的R大关于类似问题讨论的帖子:

https://hllvm-group.iteye.com/group/topic/38232?page=2

这里就直接点名道姓的指出了:Thread.sleep(0).

这让我想起以前有个面试题问:Thread.sleep(0)有什么用。

当时我就想:这题真难(S)啊(B)。现在发现原来是我道行不够,小丑竟是我自己。

还真的是有用。

实践

前面其实说的都是理论。

这一部分我们来拿代码实践跑上一把,就拿我之前分享过的《真是绝了!这段被JVM动了手脚的代码!》文章里面的案例。

publicclassMainTest{publicstaticAtomicIntegernum=newAtomicInteger(0);publicstaticvoidmain(String[]args)throwsInterruptedException{Runnablerunnable=()->{for(inti=0;i<1000000000;i++){num.getAndAdd(1);}System.out.println(Thread.currentThread().getName()+"执行结束!");};Threadt1=newThread(runnable);Threadt2=newThread(runnable);t1.start();t2.start();Thread.sleep(1000);System.out.println("num="+num);}}

这个代码,你直接粘到你的idea里面去就能跑。

按照代码来看,主线程休眠1000ms后就会输出结果,但是实际情况却是主线程一直在等待t1,t2执行结束才继续执行。

这个循环就属于前面说的可数循环(CountedLoop)。

这个程序发生了什么事情呢?

1.启动了两个长的、不间断的循环(内部没有安全点检查)。2.主线程进入睡眠状态1秒钟。3.在1000ms之后,JVM尝试在Safepoint停止,以便Java线程进行定期清理,但是直到可数循环完成后才能执行此操作。4.主线程的Thread.sleep方法从native返回,发现安全点操作正在进行中,于是把自己挂起,直到操作结束。

所以,当我们把int修改为long后,程序就表现正常了:

受到RocketMQ源码的启示,我们还可以直接把它的代码拿过来:

这样,即使for循环的对象是int类型,也可以按照预期执行。因为我们相当于在循环体中插入了Safepoint。

另外,我通过不严谨的方式测试了一下两个方案的耗时:

在我的机器上运行了几次,时间上都差距不大。

但是要论逼格的话,还得是右边的preventgc的写法。没有二十年功力,写不出这一行“看似无用”的代码!

额外提一句

再说一个也是由前面的RocketMQ的源码引起的一个思考:

这个方法是在干啥?

预热文件,按照4K的大小往byteBuffer放0,对文件进行预热。

byteBuffer.put(i,(byte)0);

为什么我会对这个4k的预热比较敏感呢?

去年的天池大赛有这样的一个赛道:

https://tianchi.aliyun.com/competition/entrance/531922/information

其中有两个参赛选大佬都提到了“文件预热”的思路。

我把链接放在下面了,有兴趣的可以去细读一下:

https://tianchi.aliyun.com/forum/postDetail?spm=5176.12586969.0.0.13714154spKjib&postId=300892

https://tianchi.aliyun.com/forum/postDetail?spm=5176.21852664.0.0.4c353a5a06PzVZ&postId=313716

最后,感谢你阅读我的文章,欢迎关注【why技术】。



鲜花

握手

雷人

路过

鸡蛋
| 收藏

最新评论(1)

Powered by 浦江新媒体 X3.2  © 2015-2020 浦江新媒体版权所有