关于java:我惊了CompletableFuture居然有性能问题

51次阅读

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

你好呀,我是歪歪。

国庆的时候闲来无事,就顺手写了一点之前说的较量的代码,指标就是保住前 100 混个大赛的文化衫就行了。

当初还混在前 50 的队伍外面,稳的一比。

其实我感觉大家做柔性负载平衡那题的思路其实都不会差太多,就看谁能把要害的信息收集起来并利用上了。

因为是基于 Dubbo 去做的嘛,调试的过程中,写着写着我看到了这个中央:

org.apache.dubbo.rpc.protocol.AbstractInvoker#waitForResultIfSync

先看我框起来的这一行代码,aysncResult 的外面有有个 CompletableFuture,它调用的是带超时工夫的 get() 办法,超时工夫是 Integer.MAX_VALUE,实践上来说成果也就等同于 get() 办法了。

从我直观上来说,这里用 get() 办法也应该是没有任何故障的,甚至更好了解一点。

然而,为什么没有用 get() 办法呢?

其实办法上的正文曾经写到起因了,就怕我这样的人产生了这样的疑难:

抓住我眼球的是这这几个单词:

have serious performance drop。

性能重大降落。

大略就是说咱们必须要调用 java.util.concurrent.CompletableFuture#get(long, java.util.concurrent.TimeUnit) 而不是 get() 办法,因为 get 办法被证实会导致性能重大的降落。

对于 Dubbo 来说,waitForResultIfSync 办法,是主链路上的办法。我集体感觉激进一点说,能够说 90% 以上的申请都会走到这个办法来,阻塞期待后果。所以如果该办法如果有问题,则会影响到 Dubbo 的性能。

Dubbo 作为中间件,有可能会运行在各种不同的 JDK 版本中,对于特定的 JDK 版本来说,这个优化的确是对于性能的晋升有很大的帮忙。

就算不说 Dubbo,咱们用到 CompletableFuture 的时候,get() 办法也算是咱们经常会用到的一个办法。

另外,这个办法的调用链路我可太相熟了。

因为我两年前写的第一篇公众号文章就是探讨 Dubbo 的异步化革新的,《Dubbo 2.7 新个性之异步化革新》

当年,这部分代码必定不是这样的,至多没有这个提醒。

因为如果有这个提醒的话,我必定第一次写的时候就留神到了。

果然,我去翻了一下,尽管图片曾经很含糊了,然而还是能隐约看到,之前的确是调用的 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; // Use brief spin-wait on multiprocessors

他说位于 waitingGet 外面,咱们就去看看到底是怎么回事嘛。

然而我本地的 JDK 的版本是 1.8.0_271,其 waitingGet 源码是这样的:

java.util.concurrent.CompletableFuture#waitingGet

先不论这几行代码是啥意思吧,反正我发现没有看到 bug 中提到的代码,只看到了 spins=SPINS,尽管 SPINS 调用了 Runtime.getRuntime().availableProcessors() 办法,然而该字段被 static 和 final 润饰了,也就不存在 BUG 中形容的“频繁调用”了。

于是我意识到我的版本是不对的,这应该是被修复之后的代码,所以去下载了几个之前的版本。

最终在 JDK 1.8.0_202 版本中找到了这样的代码:

和后面截图的源码的差别就在于前者多了一个 SPINS 字段,把 Runtime.getRuntime().availableProcessors() 办法的返回缓存了起来。

我肯定要找到这行代码的起因就是要证实这样的代码的确是在某些 JDK 版本中呈现过。

好了,当初咱们看一下 waitingGet 办法是干啥的。

首先,调用 get() 办法的时候,如果 result 还是 null 那么阐明异步线程执行的后果还没就绪,则调用 waitingGet 办法:

而来到 waitingGet 办法,咱们只关注 BUG 相干这两个分支判断:

首先把 spins 的值初始化为 -1。

而后当 result 为 null 的时候,就始终进行 while 循环。

所以,如果进入循环,第一次肯定会调用 availableProcessors 办法。而后发现是多处理器的运行环境,则把 spins 置为 1<<8,即 256。

而后再次进行循环,走入到 spins>0 的分支判断,接着做一个随机运算,随机进去的值如果大于等于 0,则对 spins 进行减一操作。

只有减到 spins 为 0 的时候才会进入到前面的这些被我框起来的逻辑中:

也就是说这里就是把 spins 从 256 减到 0,且因为随机函数的存在,循环次数肯定是大于 256 次的。

然而还有一个大前提,那就是每次循环的时候都会去判断循环条件是否还成立。即判断 result 是否还是 null。为 null 才会持续往下减。

所以,你说这段代码是在干什么事儿?

其实正文上曾经写的很分明了:

Use brief spin-wait on multiprocessors。

brief,这是一个四级词汇哈,得记住,要考的。就是“短暂”的意思,是一个不规则动词,其最高级是 briefest。

对了,spin 这个单词大家应该意识吧,后面遗记给大家教单词了,就一起讲了,看小黑板:

所以正文上说的就是:如果是多处理器,则应用短暂的自旋期待一下。

从 256 减到 0 的过程,就是这个“brief spin-wait”。

然而认真一想,在自旋期待的这个过程中,availableProcessors 办法只是在第一次进入循环的时候调用了一次。

那为什么说它消耗性能呢?

是的,的确是调用 get() 办法的只调用了一次,然而你架不住 get() 办法被调用的中央多啊。

就拿 Dubbo 举例,绝大部分状况下的大家的调用形式都用的是默认的同步调用的计划。所以每一次调用都会到异步转同步这里阻塞期待后果,也就说每次都会调用一次 get() 办法,即 availableProcessors 办法就会被调用一次。

那么解决方案是什么呢?

在后面我曾经给大家看了,就是把 availableProcessors 办法的返回值找个字段给缓存起来:

然而前面跟了一个“problem”,这个“problem”就是说如果咱们把多处理器这个值缓存起来了,假如程序运行的过程中呈现了从多处理器到单处理器的运行环境变动这个值就不精确了,尽管这是一个不太可能的变动。然而即便这个“problem”真的产生了也没有关系,它只是会导致一个小小的性能损失。

所以就呈现了后面大家看到的这样的代码,这就是“we can cache this value in a field”:

而体现到具体的代码变更是这样的:

http://cr.openjdk.java.net/~s…

所以,当你去看这部分源码的时候,你会看到 SPINS 字段上其实还有很长一段话,是这样的:

给大家翻译一下:

1. 在 waitingGet 办法中,进行阻塞操作前,进行旋转。

2. 没有必要在单处理器上进行旋转。

3. 调用 Runtime.availableProcessors 办法的老本是很高的,所以在此缓存该值。然而这个值是首次初始化时可用的 CPU 的数量。如果某零碎在启动时只有一个 CPU 能够用,那么 SPINS 的值会被初始化为 0,即便前面再使更多的 CPU 在线,也不会发生变化。

当你有了后面的 BUG 的形容中的铺垫之后,你就明确了为什么这里写上了这么一大段话。

有的同学就真的去翻代码,兴许你看到的是这样的:

什么状况?基本就看不到 SPINS 相干的代码啊,这不是坑骗老实人吗?

你别慌啊,猴急猴急的,我这不是还没说完嘛?

咱们再把眼光放到图片中的这句话上:

只须要在 JDK 8 中进行这个修复即可,因为 JDK 9 和更高版本的代码都不是这样的写的了。

比方在 JDK 9 中,间接拿掉了整个 SPINS 的逻辑,不要这个短暂的自旋期待了:

http://hg.openjdk.java.net/jd…

尽管,拿掉了这个短暂的自旋期待,然而其实也算是学习了一个骚操作。

问:怎么在不引入工夫的前提下,做出一个自旋期待的成果?

答案就是被拿掉的这段代码。

然而有一说一,我第一次看到这个代码的时候我就感觉顺当。这一个短短的自旋能缩短多少工夫呢?

退出这个自旋,是为了稍晚一点执行后续逻辑中的 park 代码,这个稍重一点的操作。然而我感觉这个“brief spin-wait”的收益其实是微不足道的。

所以我也了解为什么后续间接把这一整坨代码拿掉了。而拿掉这一坨代码的时候,其实作者并没有意识到这里有 BUG。

而这里提到的作者,其实就是 Doug Lea 老爷子。

我为什么这样说呢?

根据就在这个 BUG 链接外面提到的编号为 8227018 的 BUG 中,它们其实形容的是同一个事件:

这外面有这样一段对话,呈现了 David Holmes 和 Doug Lea:

Holmes 在这外面提到了“cache this value in a field”的解决方案,并失去了 Doug 的批准。

Doug 说:JDK 9 曾经不必 spin 了。

所以,我集体了解是 Doug 在不晓得这个中央有 BUG 的状况下,拿掉了 SPIN 的逻辑。至于是出于什么思考,我猜想是收益的确不大,且代码具备肯定的迷惑性。还不如拿掉之后,了解起来直观一点。

Doug Lea 大家都耳熟能详,David Holmes 是谁呢?

.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 版本上,上面的示例程序能够实现每秒 400 多万次对 Runtime.availableProcessors 的调用。

但在 JDK build 1.8b191 和所有起初的次要和主要版本(包含 11)上,它能实现的最大调用量是每秒 4 万次左右,性能降落了 100 倍。

这就导致了 CompletableFuture.waitingGet 的性能问题,它在一个循环中调用了 Runtime.availableProcessors。因为咱们的应用程序在异步代码中体现出显著的性能问题,waitingGet 就是咱们最后发现问题的中央。

测试代码是这样的:

  public static void main(String[] args) throws Exception {AtomicBoolean stop = new AtomicBoolean();
        AtomicInteger count = new AtomicInteger();

        new Thread(() -> {while (!stop.get()) {Runtime.getRuntime().availableProcessors();
                count.incrementAndGet();}
        }).start();

        try {
            int lastCount = 0;
            while (true) {Thread.sleep(1000);
                int thisCount = count.get();
                System.out.printf("%s calls/sec%n", thisCount - lastCount);
                lastCount = thisCount;
            }
        }
        finally {stop.set(true);
        }
    }

依照 BUG 提交者的形容,如果你在 64 位的 Linux 上,别离用 JDK 1.8b182 和 1.8b191 版本去跑,你会发现有近 100 倍的差别。

至于为什么有 100 倍的性能差别,一位叫做 Fairoz Matte 的老哥说他调试了一下,定位到问题呈现在调用“OSContainer::is_containerized()”办法的时候:

而且他也定位到了问题呈现的最开始的版本号是 8u191 b02,在这个版本之后的代码都会有这样的问题。

带来问题的那次版本升级干的事是改良 docker 容器检测和资源配置的应用。

所以,如果你的 JDK 8 是 8u191 b02 之前的版本,且零碎调用并发十分高,那么祝贺你,有机会踩到这个坑。

而后,上面几位大佬基于这个问题给出了很多解决方案,并针对各种解决方案进行探讨。

有的解决方案,听起来就感觉很麻烦,须要编写很多的代码。

最终,大道至简,还是抉择了实现起来比较简单的 cache 计划,尽管这个计划也有一点瑕疵,然而呈现的概率非常低且是能够承受的。

再看 get 办法

当初咱们晓得了这个没有卵用的知识点之后,咱们再看看为什么调用带超时工夫的 get() 办法,没有这个问题。

java.util.concurrent.CompletableFuture#get(long, java.util.concurrent.TimeUnit)

首先能够看到外部调用的办法都不一样了:

有超时工夫的 get() 办法,外部调用的是 timedGet 办法,入参就是超时工夫。

点进 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(long timeout, TimeUnit unit) 办法,而后把 get() 办法间接删除了。

我感觉必定是能被 merge 的。

如果你想为开源我的项目做奉献,相熟一下流程,那么这是一个不错的小机会。

正文完
 0