战胜Go和Redis! Java ZGC新GC在数TB内存中只有毫秒或更短的暂停 - 迈克的博客


这篇文章是分析了ZGC和Shenandoah的垃圾回收在数TB内存中只有毫秒级的暂停时间,并且与Go语言做了比较, Java新家伙赢得了这场低延迟的比赛。Java在低延迟,快速响应,高性能方面优于Go语言和Redis的延迟。文章很长,只做概要摘录:

衡量GC的重要设计指标:

  • 程序吞吐量:您的算法减慢了多少程序?这有时表示为收集与有用工作所花费的CPU时间的百分比。
  • GC吞吐量:在指定固定的CPU时间量的情况下,收集器可以清除多少垃圾?
  • 堆开销:收集器需要多少额外内存超过理论最小值?如果你的算法在收集时分配临时结构,是否会使你的程序的内存使用非常尖锐?
  • 暂停时间:你的收集器会暂停的时间有多长?
  • 暂停频率:您的收集器多久停止一次?
  • 暂停分布:您通常有非常短暂的暂停但有时会有很长的暂停时间吗?或者你更喜欢停顿有点长但一致?
  • 内存分配性能:快速,慢速或不可预测的新内存分配?
  • 压缩:即使有足够的可用空间来满足请求,您的收集器是否会报告内存不足(OOM)错误,因为该空间已经分散在堆中的小块中?如果没有,你可能会发现你的程序变慢并最终死亡,即使它实际上有足够的内存来继续。
  • 并发:您的收集器使用多核机器的程度如何?
  • 缩放:当收集器变大时,收集器的工作情况如何?
  • 微调性能:收集器的配置有多复杂,开箱即用并获得最佳性能?
  • 预热时间:您的算法是否根据测量的行为进行自我调整,如果是,需要多长时间才能达到最佳状态?
  • 内存页释放:您的算法是否会将未使用的内存释放回操作系统?如果是的话,何时?
  • 可移植性:您的GC是否在CPU体系结构上工作,提供比x86更弱的内存一致性保证?
  • 兼容性:您的收藏家使用哪些语言和编译器?是否可以使用非GC设计的语言运行,比如C ++?它需要编译器修改吗?如果是这样,更改GC算法是否需要重新编译所有程序和依赖项?

使用上面指标比较Shenandoah, ZGC 和其他流行GC!

暂停时间
Java GC工程师是一个相当谦逊的团队,即使他们花了数年时间开发新的收集器,也不会卖力推销他们的工作成功。因此,关于ZGC的规范性陈述声称“不会超过10毫秒暂停”,但如果我们转到幻灯片12上的SPECjbb2015基准测试,我们看到实际上,平均暂停时间约为1毫秒。

Shenandoah的暂停时间同样很短。可以看到GC日志以大约200到400暂停微秒,它甚至可以在有小堆的慢速硬件上运行 - 在运行Web服务器的Raspberry Pi上,暂停时间仍然只有3-8毫秒。这对嵌入式设备来说非常好。

请记住,无论堆大小如何,都会获得这些暂停时间。也就是说,如果需要,您可以在数TB的大小上获得毫秒或更短的暂停。能够自动管理大量堆是一项基本功能,可以改变程序的编写方式:如果您的数据集适合这样的堆,您可能根本不需要编写分布式软件。

将这些延迟与本机手动内存管理所带来的延迟进行比较会很有趣。不幸的是,很难找到有关malloc延迟分布的信息。这篇博文描述了一个Redis负载测试,其中glibc malloc延迟的最坏情况大约为10毫秒。这听起来是非常糟糕的情况,但不幸的是,大多数mallocs的基准使用非常不切实际的测试场景,比如分配和立即释放分配,所以很难得到真实的数据。JVM GC使用SPECjbb进行测试,SPECjbb是实施超市物流应用程序的基准。

程序吞吐量
ZGC的目标是开销不低于15%。这与mallocs报告的最坏情况开销相当。将吞吐量与Go收集器进行比较很困难,因为Go收集器的性能影响取决于您选择的内存开销。但是Go的方法对吞吐量的影响非常严重这里有电子表格)。Shenandoah的吞吐量开销有点差,但仍然在15%-20%左右。

压实Compact
无论 Shenandoah和ZGC收集器实际上是在程序运行时在内存移动字节,不会暂停运行的程序。这个技巧最初可能看起来不可能,但它实际上是所有现代收集器背后的关键理念(Go除外)。压缩compact有用有三个原因:

  • 它消除了堆碎片。清除碎片分配器通常存在于手动管理的应用程序中,但它们以增加的开销为代价减少了碎片,并且永远无法完全消除问题。
  • 通过以更好地利用缓存的方式重新组织内存中的数据,它可以使程序更快
  • 它能够以高速度清除大量垃圾,这对于本质上是generational 世代的程序非常有用。

内存分配
使用压缩的一个重要原因是它清除了大的连续内存区域。这反过来意味着分配可以快速完成 - 一旦线程抓住了清空堆区域的一大块,它就可以通过简单地增加单个指针并进行比较来分配内存,即只需要两三条指令,内联到分配站点。

释放内存页
Java在内存使用方面声名狼借的原因之一是,它的大多数垃圾收集器都不会经常将内存释放回操作系统。在服务器端没有问题,但是在台式机,尤其是许多程序竞争RAM的开发人员桌面上,这种行为是反社会的。Shenandoah有一个很好的功能 -  即使应用程序处于空闲状态它也会主动进行垃圾收集,并稳定地将内存释放回操作系统。这使其成为与IDE等桌面应用程序一起使用的潜在不错选择。JVM使用的默认G1收集器也学习了如何在Java 12中执行此操作

兼容性
JVM运行许多语言,新的GC不仅可以让Java受益,还可以授益与JavaScript,Python,Ruby,Haskell(通过Eta),Clojure,Scala,Kotlin,R,COBOL。


与Go比较
在我之前关于垃圾收集的2016年的文章中,我观察到Go正在宣传自己,因为它有一个“一刀切”的收集器,它比“企业”替代品更好,并且足以在可预见的未来做好一切。但事实并非如此,所以我批评他们的营销方式。几年后,Go团队发表了这个名为“Getting to Go”的优秀演讲,我将其描述为几乎完全相反 - 这非常诚实。

它告诉我们Go GC的设计是以不寻常的方式设计的,主要是由于谷歌之前未提及的内部工程限制,特别是由于对短期成功的惊人需求。

最初的计划是做一个无障碍的并发复制GC。那是长期计划。读取障碍的开销存在很大的不确定性,因此Go想要避免它们。
但是短期内2014年我们不得不一起行动......我们也需要快速的东西并专注于延迟,但性能影响必须小于编译器提供的加速。所以我们受到限制。
我们还关注编译器速度,即编译器生成的代码......在2015年也迫切需要短期成功。

Go的GC设计有另一个限制 - 在他们的同事已经沉迷于暂停延迟的长尾的环境中。

无论原因如何,Go的不寻常的设计选择显然是由于不同寻常的限制:他们在一家公司经历着对尾部延迟的痴迷,他们的编程时间有限,因为他们同时在Go中重写标准库,而且大多数他们所需要的只是非常快地推动暂停延迟以获得“短期成功”。

Go确实降低了暂停延迟,尽管其他领域的成本很高,他们在工程预算有限的情况下很快就做到了,他们获得了他们所需的短期成功。令人遗憾的是,他们采用了一些奇怪的要求,例如不希望向用户公开微调开关,这导致他们被不同程序的各种各样的需求严重压制。

Go编译器是一个经典的批处理作业。暂停时间对于编译器来说根本不重要,只有总运行时间才有效。但是减少暂停时间的技术都会增加总运行时间,因此这是一个问题。用于批处理作业的良好GC算法类似于JVM的并行GC。

他们尝试了一个“面向请求的收集器”,它可以更好地扩展到某些重要的应用程序:

正如你所看到的那样,如果你有ROC而不是很多共享,事情实际上可以很好地扩展。如果你没有ROC,那就差不多了。

但是......它减慢了他们的编译器:

那时我们对编译器有很多担心,我们无法放慢编译器的速度。不幸的是,编译器正是ROC没有做好的程序。我们看到30%,40%,50%和更多的减速,这是不可接受的。Go对其编译器的速度感到自豪。

GC设计很难!Go的人可能已经重走了JVM路线,让用户根据他们是否更关心延迟或吞吐量两种中一个而选择GC算法。

所以他们放弃并尝试了一种新的方法 - 一个普通的世代垃圾收集器。

因此,他们在2018年时的建议是:等待内存价格下降,并要求Go用户给Go应用程序大量的堆内存,以减少当前算法需要完成的GC工作量。

如果我们将这个故事与JVM世界中的等同物进行比较,我们可以看到不同的东西。在SPECjbb 2015基准测试(模拟仓库管理数据库应用程序)中,ZGC强制实际上不会减慢应用程序的速度,但会显着改善延迟:

暂停时间几乎总是毫秒或更短,但吞吐量不会受到严重损害。

同时有三个标准调整标志选项 - 一个用于选择ZGC而不是另一个算法,一个用于设置最大堆大小,一个通常可以单独保留,因为它会自动调整,但会设置收集器获得的CPU时间。通过利用Linux内核的大页面功能,可以使用一些更加模糊的内容来进一步提高性能。

总的来说,在我看来,Java家伙正在赢得低延迟游戏的胜利。