难道这些 Java 大牛说的都是真的?

独家号 后端架构 作者 Universidad de Dude 原文链接

在我现在最推崇的那本关于Java性能的书籍Optimizing Java一上来,两位大大开篇就喷了已有的关于Java性能方面的书/文章/blog很多都是垃圾(你这篇blog也是喽)。主要的意思还是说,在这个领域,有太多的口口相传的过时的,甚至是完全拍脑门的性能传说经久不衰历久弥坚。虽然在贵Java界只混了几个月,我已经发现除了这本书可以信,R大说的都是对的,OpenJDK的源代码以外,呃,你还真没有什么敢轻易相信的。

谁知你匆匆的设置

事情的开始是这样的。在调查某个服务的GC性能问题的时候,除了修正主要问题(此处省去吹牛逼的512字),我发现以前的哥们设置了魔幻的ParGCCardsPerStrideChunk。打开这种JVM diagnostic选项,一般还是需要很大的勇气的。当时因为我知道他只是从另一个服务借过来的设置,所以在测试完关闭这个设置以后有小的GC性能提升之后,我就去处理主要的问题了。

(这一段是广告)这在以前,或者我相信在大多数企业,这就算完了。你解决了主要问题,挽救了社会挽救了党,谁还去管这种细节呢?然而在大亚麻,起码在我现在的部门,想到我们Senior Manager看到这个东西的时候那张写满了Leadership Principles的脸,我就知道只是做到这样的话,不算完。Dive Deep。

然后在北美的劳动节这天下午,我就来公司搞这个了。目的就是想知道,最初在另外一个服务搞了-XX:ParGCCardsPerStrideChunk=32768的根本原因是什么。

渐渐掩不住一丝尴尬

很快我就发现,网上有很多篇文章和blog,提到了这个设置。一般会说这会提高card table scan的效率,差一点的说这会提高worker thread的并行效率甚至搞不清是针对young gen还是old gen的,最可怕的是很多直接说你Heap大的时候直接用就好了。

反向继续搜索,就发现了大多数人是看了一篇LinkedIn的Engineering Blog。这篇讲到的很多地方还是非常有可取之处的,他们也是在测试了不同的配置之后得出了32768这个实验结论。但是后来的很多人以为这就是个神奇的值,也是够了。

而且他并不是一个人在战斗,一年之后Twitter的人还搞了一个OpenJDK的Ticket。他们发现对于他们来讲8192是个合适的值。这个我后面再讲。

不过LinkedIn做这件事的时候,也是基于一个俄国哥们Alexey Ragozin之前做的实验。他发现他的服务在4096时候达到最佳效果。这个著名的俄国人我还是很服的,就是他之前发现了card table scan的算法效率问题并且在7u40的时候patch了这个问题,并且发明了ParGCCardsPerStrideChunk这个参数。

写到这里大家起码都知道了,并不存在32768这个神奇的值。最早的人们是通过实验得出的对他们自己来说最合适的设置。后面直接拿这个数用的,呃,就有点东施效颦了。当然LinkedIn那篇blog写得也有问题,他没有交待清楚自己得到这个值的步骤,而且他能够说出“The interesting learning here was that the young GC time increases with the increase in the size of old generation”这句话,也说明他理解这件事有问题。

谁知你紧紧的计算

到这里当然还不算完啊,既然你们知道了这最后是找到一个平衡,那么是谁和谁之间的平衡呢?这就要讲到Generational Mark-Sweep算法的实现了。如果你们读过Garbage Collection或者The Garbage Collection Handbook,那就可以直接跳过这一章,哦,不对,你整个blog都不用读了。。。

如果你们没有读过,呃,我也帮你们找好了一篇MSDN的文章。读懂了,再接着往下看。虽然它讲的是.Net CLR的,但是已经是我能给你们找到的最合适的了,反正算法在这一层面差异不大。在教书育人这方面Java界确实长期以来很丢人。

在Java中,一个card的大小是512 bytes,而ParGCCardsPerStrideChunk默认的值是256。那么一个Stride,或者MSDN文章中说的block,就是512 * 256 = 128K。假如你有4G的old gen,就会有4G / 128K = 32K的Strides在young gen collection的时候去扫描。这也就是为什么我前面说LinkedIn文章的interesting有些大惊小怪,呃,算法本来就是这个样子的啊。随着old gen大小增加,young gen collection就是要做更多的事情。

在这个过程中,card table的scan,Stride向worker threads的分配,以及thread的Stride switching,都是有相当高的消耗。所以会存在如果Stride的大小太小,worker threads要做太多的调度和分配的话,会导致耗时增长。256这个当年最早时的默认值,在很多情况下,已经无法满足大的old gen的需求了。因此在很多场景下,增加到4K,8K,甚至32K会有显著的GC impact减小。然而这也不是必然的,比如你的代码、系统、负载决定了你根本没有什么old gen到young gen的reference的话,你就几乎没有什么dirty strides,这时即使你的old gen很大、负载非常大,消耗顶多是在card table scan上,256一样可能是一个非常适合的值。

而Stride size设置过大也是有问题的。因为在一个Stride里面,真的引用了young gen里面的对象的对象,可能只有一两个。如果Stride太大,worker thread就浪费了大量不必要的时间去试那些根本不存在这样的引用的对象上。这时候反而会更慢。

因此,你要找到的就是在上两段提到的这两种消耗上的一个平衡点。而根据各种JVM的设置和你的系统特性的不同,这个平衡点都是不一样的。甚至不同的运行环境中,比如不同的硬件,对于AWS来讲不同大小的region,可能都是不一样的。

再也藏不住心中扯淡

事情到这里还没有完。仍然有一些其他的JVM选项,可以影响这个过程。你们肯定马上想到了控制ParNew thread数量的ParallelGCThreads。更多的Thread,在有足够CPU core的帮助下(所以注意这句哦),确实可以更快得完成任务。而且你还可以设置ParGCStridesPerThread(注意这个也是个diagnostic的)控制每次worker thread整几个Strides,设置BindGCTaskThreadsToCPUs来减少thread的switching,设置UseGCTaskAffinity来保证每次worker thread被唤醒的时候争取拿到上次没有完成的工作。这里我就又要喷一句了,我还看到不少地方,有一些人说UseGCTaskAffinity没有用,因为那篇LinkedIn的blog里提到了这个参数对他们的系统好像没有什么用。于是到最后就传播成了,呃,“UseGCTaskAffinity没有用”。这个东西是干什么的,到底有用没有用,你真的找不到文章,去gcTaskManager.cpp里面看看get_task是怎么实现的,一分钟就明白了好不好!

还有一点就是前面提到的Twitter同学们搞的那个Ticket 。他们实验出来自己合适的设置后,然后在他们自己的OpenJDK分支上修改了,去通过设置old gen的大小,呃,在JVM启动时来决定到底应该把ParGCCardsPerStrideChunk设置成多大。仔细读了前面一章的都知道,这个并不是仅仅由old gen的大小决定的。更大的决定因素是你的系统。他们那么改可能适合了他们的一个服务,而不会是适合所有的场景。在我的实验中,不同的service达到的sweat point完全不一样。真的有很大old gen,256或者512是最佳设置的。根本的原因还是因为只是实验测试,没有去理解算法啊。。。况且他建议给出一个范围在运行时调整。呃,他有想过如果运行时调整这个,那意味着card table的大小要变,card table的值要重新build;如果合并还好办,但是如果分成更多的区域,那么要整个区域重新扫描的。。。

说什么痴情的脚步追不上变心的翅膀

好了,这个故事讲完了。最后总结一下的话,可以列为三点:

  • R大说的都是对的。
  • R大说的都是对的。
  • R大说的都是对的。

这你们早都知道?好吧,重新来过:

  • R大说的都是对的,Optimizing Java是可以读的,JVM源代码是真理所在。
  • 除了上面的之外,网上所有的关于JVM性能的文章,都要打破沙锅问到底,尤其是作者没有问到底的。因为有太多JVM界的Steve Bannon。(那你是JVM界的Bernie Sanders喽(好像也好不到哪里去啊?
  • 实验和测试只是一方面,更重要的是理解算法,这样才能真正理解和解决问题。

开发者头条

程序员分享平台