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

我很震惊!CompletableFuture实际上存在性能问题!

时间:2023-04-02 01:42:35 Java

大家好,我是伟伟。国庆期间闲来无事,就为刚才说的比赛写了一点代码。目标是保住前100场比赛的文化衫。现在他们还混在前50名的队伍中,所以是一场稳定的比赛。其实对于弹性负载均衡这个话题,我想大家的想法并没有太大的不同。这取决于谁可以收集和使用关键信息。因为是基于Dubbo的,在调试的过程中,看到了这个地方:org.apache.dubbo.rpc.protocol.AbstractInvoker#waitForResultIfSync首先看我framed的那行代码,在aysncResult里面有一个CompletableFuture,调用了get()方法有超时时间,超时时间为Integer.MAX_VALUE,理论上来说,效果等同于get()方法。以我的直觉来看,这里使用get()方法应该没有什么问题,甚至更好理解。但是为什么没有使用get()方法呢?其实原因在方法的注释里已经写好了。恐怕像我这样的人会有这样的疑惑:这句话映入眼帘:性能下降严重。性能严重下降。可能意味着我们必须调用java.util.concurrent.CompletableFuture#get(long,java.util.concurrent.TimeUnit)而不是get()方法,因为get方法已被证明会导致严重的性能下降。对于Dubbo,waitForResultIfSync方法是主链上的一个方法。个人认为保守的说法是90%以上的请求都会走这个方法,阻塞等待结果。所以如果这个方法出现问题,就会影响到Dubbo的性能。Dubbo作为中间件,可以运行在各种JDK版本中。对于特定的JDK版本,这种优化确实对性能的提升有很大的帮助。即使没有Dubbo,我们在使用CompletableFuture时,get()方法也是我们经常使用的方法。另外,这个方法的调用环节我太熟悉了。因为我两年前写的第一篇公众号文章是关于Dubbo的异步改造的,在当时,这部分代码肯定不是这样的,至少没有这样的提示。因为如果有这样的提示,我肯定是在第一次写的时候就注意到了。果然,我去查了一下。虽然画面已经模糊了,但我还是依稀可以看出之前确实调用了get()方法:我也称之为最“性感”的一行代码。因为这行代码是Dubbo异步转同步的关键代码。以上只是介绍,本文不会写Dubbo相关的知识点。主要写CompletableFuture的get()有什么问题。不用担心,这个点的面试肯定考不上的。只是知道这一点后,恰好你的JDK版本没有修好,写代码的时候注意一下。借鉴Dubbo,在调用方法的地方加上同样的NOTICE,直接填上即可。等到有人问了,再说。或者当你无意中看到别人这样写的时候,淡淡的说一句:这里可能有性能问题,你可以去查一下。什么性能问题?根据Dubbo评论中的这个信息,我不知道是什么问题,但是我知道去哪里找。这种问题肯定记录在openJDK的bug列表里,所以第一站在这里搜索关键词:https://bugs.openjdk.java.net...总的来说是一些老BUG,需要搜索了半天才找到你想要的资料。然而,这次我很幸运,第一个弹出的就是我要找的东西。这就是标题:CompletableFuture的性能改进。其中提到了编号为8227019的BUG。https://bugs.openjdk.java.net...我们来看看这个BUG描述了什么。题目的翻译大概意思是CompletableFuture.waitingGet方法中有一个循环,在这个循环中调用了Runtime.availableProcessors方法。而且这个方法调用的很频繁,不好。在详细描述中,提到了另一个编号为8227006的BUG,这个BUG描述了为什么频繁调用availableProcessors不好,但还是先按这个吧。首先研究他提到的那行代码:spins=(Runtime.getRuntime().availableProcessors()>1)?1<<8:0;//在多处理器上使用briefspin-wait他说是位于waitingGet中,我们去看看是怎么回事。但是我本地的JDK版本是1.8.0_271,它的waitingGet源码如下:java.util.concurrent.CompletableFuture#waitingGet这几行代码的意思先不管吧,反正我发现没看到bug提到的代码中,我只看到了spins=SPINS。SPINS虽然调用了Runtime.getRuntime().availableProcessors()方法,但是这个字段被static和final修饰,所以不存在BUG中描述的“频繁调用”。于是我意识到我的版本不对,这应该是修复后的代码,所以我下载了几个以前的版本。最后在JDK1.8.0_202版本中找到了这段代码:与之前截图源码的区别在于前者多了一个SPINS字段,缓存了Runtime.getRuntime().availableProcessors()方法的返回.之所以要找到这行代码,是为了证明在某些JDK版本中确实出现了这样的代码。好了,现在让我们看看waitingGet方法做了什么。首先调用get()方法的时候,如果结果还是null,说明异步线程执行的结果还没有准备好,那么就会调用waitingGet方法:而当我们来到waitingGet方法,我们只关注与BUG相关的两个分支:首先,spins的值被初始化为-1。然后当结果为空时,while循环继续。所以,如果进入循环,肯定会在第一时间调用availableProcessors方法。然后发现是多处理器运行环境,自旋设置为1<<8,即256。然后再次循环,走到spins>0的分支判断,再做一次随机操作。如果随机值大于或等于0,则自旋将减一。只有当自旋降为0时,才会进入我框定的如下逻辑:也就是说,这里是将自旋从256降为0,由于随机函数的存在,循环次数必然是大于256倍。但是还有一个大前提,就是每次循环的时候都会判断循环条件是否仍然为真。也就是判断结果是否还是null。如果为空,它将继续减少。那么,你说这段代码是做什么的呢?其实注释已经写的很清楚了:Usebriefspin-waitonmultiprocessors。简而言之,这是一个四级词汇,你要记住它,你要考试。它的意思是“短暂的”,是一个不规则动词,其最高级是最简短的。顺便说一句,每个人都应该知道自旋这个词。之前忘记教大家单词了,一起说说吧。看小黑板:所以评论说:如果是多处理器,就用shortspin等待。从256减少到0的过程就是这个“短暂的自旋等待”。但是仔细想想,在自旋等待的过程中,availableProcessors方法只在第一次进入循环时调用了一次。那为什么说它很耗性能呢?是的,确实只调用了一次get()方法,但是你不能忍受get()方法在这么多地方被调用。以Dubbo为例。大多数情况下,大家都使用默认的同步调用方式。所以每次调用都会去异步到同步去阻塞等待结果,也就是说每次都会调用get()方法,也就是调用一次availableProcessors方法。那么解决方案是什么?之前给大家介绍过,就是找一个字段来缓存availableProcessors方法的返回值:但是后面跟着一个“问题”。这个“问题”的意思是,如果我们缓存多处理器的值,假设在程序运行过程中运行环境从多处理器变为单处理器,那么这个值是不准确的,尽管这是不太可能发生的变化。但是这个“问题”是否确实发生并不重要,它只会导致很小的性能损失。于是就有了大家前面看到的这样一段代码,就是“我们可以把这个值缓存在一个字段中”:而具体的代码改动如下:http://cr.openjdk.java.net/~s...所以,当你看这部分源码的时候,你会看到SPINS字段里面其实有很长一段,是这样的:为大家翻译一下:1.在waitingGet方法中,在旋转前进行阻塞操作。2.无需在单处理器上自旋。3、调用Runtime.availableProcessors方法的开销很大,所以这里缓存值。但是这个值是第一次初始化时可用的CPU数量。如果一个系统在启动时只有一个CPU可用,SPINS的值会被初始化为0,即使后面有更多的CPU上线,它也不会改变。当你有了之前BUG描述中的伏笔,你就会明白为什么这里要写这么大一段话了。有的同学真的把代码过一遍,可能你看到的是这样的:什么情况?根本看不到SPINS相关的代码。这不是在欺骗老实人吗?别慌,着急,我不是说完了吗?我们再关注一下图中的那句话:这个修复只需要在JDK8中进行,因为JDK9及以后版本的代码不是这样写的。比如在JDK9中,直接去掉了整个SPINS的逻辑,所以不用等这个shortspin:http://hg.openjdk.java.net/jd...虽然,去掉了这个shortspin等等,其实也算是学了个秀操作。Q:如何在不引入时间的情况下做出旋转等待的效果?答案是被删除的代码。但是有一点要说,当我第一次看到这段代码的时候,我觉得很别扭。这个短旋转可以延长多少时间?加入这个自旋是为了在后面的后续逻辑中执行park代码,属于稍微重一点的操作。但我认为这种“短暂的旋转等待”的好处实际上是微乎其微的。所以我也明白为什么后面直接把这一整堆代码去掉了。在去掉这堆代码的时候,作者并没有意识到这里有一个bug。这里提到的作者其实就是DougLea先生。我为什么这么说?根据这个BUG链接中提到的编号为8227018的BUG,他们实际上描述的是同一件事:有这样一段对话,其中出现了DavidHolmes和DougLea:Holmes提到“将这个值缓存在一个字段中”并同意Doug的观点。Doug说:JDK9不再使用自旋。所以,我个人的理解是Doug在不知道这个地方有bug的情况下去掉了SPIN的逻辑。至于考虑,我估计好处真的很小,而且代码有些混乱。不如去掉它,更直观地理解它。每个人都知道DougLea,但DavidHolmes是谁?.png)《Java 并发编程实战》的作者之一,喝茶结束。而如果你对我之前的文章印象深刻,那么你会发现,早在这篇文章《Doug Lea在J.U.C包里面写的BUG又被网友发现了。》中,他就已经出现了:老朋??友又出现了,建议铁汁们把梦幻联动放在公共屏幕优越。是什么原因?前面噼里啪啦说了这么长一段,核心思想其实就是Runtime.availableProcessors方法的调用成本高,所以在CompletableFuture.waitingGet方法中不应该频繁调用这个方法。但是为什么调用availableProcessors的成本高,有什么依据,你得拿出来看看!在本节中,我们将向您展示什么是基础。依据在这个BUG描述:https://bugs.openjdk.java.net...标题说:在linux环境下,Runtime.availableProcessors的执行时间增加了100倍。增加100倍,肯定有两个不同版本的对比,那么是哪两个版本呢?在1.8b191之前的JDK版本上,下面的示例程序可以实现每秒调用Runtime.availableProcessors超过400万次。但是在JDKbuild1.8b191和所有后续的主要和次要版本(包括11)上,它可以达到的最大值是每秒40,000次调用,性能下降了100倍。这导致了CompletableFuture.waitingGet的性能问题,它在循环中调用了Runtime.availableProcessors。由于我们的应用程序在异步代码方面表现出严重的性能问题,waitingGet是我们最初发现问题的地方。测试代码如下所示:publicstaticvoidmain(String[]args)throwsException{AtomicBooleanstop=newAtomicBoolean();AtomicIntegercount=newAtomicInteger();newThread(()->{while(!stop.get()){Runtime.getRuntime().availableProcessors();count.incrementAndGet();}}).start();尝试{intlastCount=0;while(true){Thread.sleep(1000);intthisCount=计数.get();System.out.printf("%scalls/sec%n",thisCount-lastCount);最后一个计数=这个计数;}}最后{stop.set(true);}}根据BUG提交者的描述,如果你在64位Linux上分别运行JDK1.8b182和1.8b191,你会发现相差近100倍。至于为什么会有100倍的性能差异,一位叫FairozMatte的师兄说他是在调用“OSContainer::is_containerized()”方法时调试定位到问题的:并且他也定位到问题最严重的地方初始版本号是8u191b02,这个版本之后的代码会有这个问题。导致问题的版本升级是为了改进docker容器检测和资源分配的使用。所以,如果你的JDK8是8u191b02之前的版本,系统调用并发度很高,那么恭喜你,你有机会踩这个坑了。然后下面的大佬们基于这个问题给出了很多解决方案,讨论了各种解决方案。有些解决方案听起来很麻烦,需要大量编码。最后,简单的方法就是选择实现起来比较简单的缓存方案。虽然这个方案有一些缺陷,但是发生的概率很低,可以接受。再看get方法。知道了这个无用的知识点,我们再来看看为什么调用超时的get()方法。不存在这样的问题。java.util.concurrent.CompletableFuture#get(long,java.util.concurrent.TimeUnit)首先可以看到内部调用的方法不一样:timedGet方法内部调用了带超时时间的get()方法。该参数是超时时间。点击timedGet方法就知道为什么超时调用get()方法没问题了:答案已经给你写在代码的注释里了:我们这里特意不轮换(比如waitingGet),因为上面是对于nanoTime()调用很像旋转。可以看到在这个方法内部,根本没有调用Runtime.availableProcessors,所以也就没有对应的问题。现在,让我们回到开头:那么你说,下面的asyncResult.get(Integer.MAX_VALUE,TimeUnit.MILLISECONDS)改成asyncResult.get()还是一样的效果?它必须是不同的。再次声明:Dubbo作为一个开源的中间件,可能运行在各种JDK版本中,该方法是其主链接上的核心代码。对于特定的JDK版本来说,这种优化确实对于性能的提升有很大的帮助。所以写中间件还是有点意思的。最后再给大家一次提交Dubbo源码的机会。在它下面的类中:org.apache.dubbo.rpc.AsyncRpcResult仍然有这两个方法:但是上面的get()方法只被测试类调用:你可以完全改变它们并调用get(longtimeout,TimeUnitunit)方法,然后直接删除get()方法。我认为它必须能够合并。如果您想为开源项目做出贡献并熟悉该过程,这是一个不错的小机会。