Java运行时在最近几年发展得更快,15年后,我们终于有了一个新的默认垃圾收集器:G1。另外两个GC正在投入生产,可以作为试验性功能使用:Oracle的Z和OpenJDK的Sherandoah。我们Hazelcast认为是时候将所有这些新选项放到测试台上了,找出哪些选项适合我们的分布式流处理引擎Hazelcast Jet的典型工作负载。
JET用于广泛的用例,具有不同的延迟和吞吐量要求。这里有三个重要的类别:
低延时无限制流处理,状态适中。例如:检测来自10,000台设备的100 Hz传感器数据的趋势,并在10-20毫秒内发送纠正反馈。
高吞吐量、大状态的无限流处理。例如:跟踪数百万用户的GPS位置,推断他们的速度矢量。
老式的大数据量批量处理。相关的措施是完成的时间,这意味着很高的吞吐量需求。例如:分析一天的股票交易数据,以更新给定投资组合的风险敞口。
在场景1中,延迟要求进入GC暂停的危险区域:100毫秒,传统上被认为是最坏情况GC暂停的极佳结果,但在任何用例中都可能是一个令人望而却步的结果。
场景2和场景3在垃圾收集器的需求方面是相似的。延迟不那么严格,但对持久一代的压力很大。
方案2更难处理,因为延迟即使比方案1少,也是相关的
在现代JDK版本中,G1是一个收集器的怪物。它可以轻松处理几十GB的堆(我们尝试了60 GB),将最大GC暂停保持在200ms内。在极端压力下,它不会表现出脆性和灾难性的失效模式。相反,完整的GC暂停上升到低秒数范围。它的阿喀琉斯脚跟是在有利的低压条件下GC暂停的上限,我们不能把它推低到20-25毫秒以下。
JDK8是一个过时的运行时。缺省的并行收集器进入完全GC暂停,而G1虽然没有那么频繁的FullGC,但是停留在只使用一个线程来执行它的旧版本中,导致更长的暂停。即使在12 GB的中等堆上,并行的暂停也超过20秒,G1的暂停超过1分钟。ConcurrentMarkSweep收集器在所有场景中都严格比G1差,其故障模式是多分钟的FullGC暂停。
Z虽然允许比G1低得多吞吐量,但在G1的一个薄弱区域非常好,在轻负载下提供长达10ms的最坏情况停顿。
舍南多令人失望的是,在低压状态下,偶尔会出现高达220ms的潜伏期峰值,但无论如何,这是不规律的。
Z和Sherandoah均未表现出G1那样的平滑失效模式。他们表现出脆性,低延迟制度突然让位于非常长的停顿,甚至OOME。
这篇文章是由两部分组成的系列文章的第一部分,展示了我们对这两个流媒体场景的发现。在第二部分中,我们将介绍批处理的结果。
对于流基准测试,我们使用了这里提供的代码,测试之间有一些细微的差异。这是主要的部分,喷气管道:
<;>;source=p.。readFrom(LongSource(Items_Per_Second))。WITH NativeTimestamps(0)。ReBalance();//在Jet 4.2中引入。groupingKey(n->;n%NUM_KEYS)。窗口(滑动(秒。toMillis(Win_Size_Second),Sliding_Step_Millis))。Aggregate(Counting())。过滤器(kwr->;kwr.。getkey()%Diagnostic_KEYSET_DOWNSAMPLING_FACTOR==0)。窗口(翻滚(Sliding_Step_Millis))。Aggregate(Counting())。WriteTo(。记录仪(WR->;格式(";时间%,d:延迟%,d毫秒,CCA。%,d个密钥";,simpleTime(写入。end()),纳秒。托米利斯(。NanTime())-WR.。end(),WR。RESULT()*DIAGUSTIONCE_KEYSET_DOWNSAMPLING_FACTOR);
此管道表示具有无界事件流的用例,其中引擎被要求执行滑动窗口聚合。例如,您需要这种聚合来获得变化量的时间导数、从数据中去除高频噪声(平滑)或测量某些事件发生的强度(每秒事件数)。引擎可以首先按某个类别(例如,每个不同的物联网设备或智能手机)将流分割成子流,然后独立跟踪每个子流中的聚合值。在Hazelcast Jet中,滑动窗口以您配置的固定大小步长移动。例如,滑动步长为1秒时,您每秒都会获得完整的结果集,如果窗口大小为1分钟,则结果会反映最近一分钟内发生的事件。
代码是完全自包含的,没有外部数据源或接收器。我们使用模拟数据源来模拟事件流,该数据源精确地模拟每秒所选的事件数。连续的事件时间戳是相等的间隔时间。源永远不会发出时间戳仍在将来的事件,否则会尽可能快地发出它们。
如果流水线落后,事件将被缓冲,但不会有任何存储。在落后之后,管道必须以最快的速度接收数据来迎头赶上。由于我们的源是非并行的,因此其吞吐量的限制约为每秒220万个事件。我们每秒使用100万个模拟事件,留下了120万个每秒的追赶净空。
管道通过将发射的滑动窗口结果的时间戳与实际的挂钟时间进行比较来测量其自身的延迟。更详细地说,有两个聚合阶段,它们之间有过滤。单个滑动窗口结果由许多项组成,每个项对应一个子流,我们对最后发出的项的延迟很感兴趣。为此,我们首先过滤掉大部分输出,每10,000个条目保留一次,然后将稀疏的流定向到第二个非键控翻滚窗口阶段,该阶段记录结果大小并测量延迟。无键聚合不是并行化的,因此我们只有一个测量点。过滤阶段是并行的和数据本地的,因此额外聚合步骤的影响非常小(远低于1ms)。
我们使用了一个简单的聚合函数:COUNTING,实际上获得流的事件/秒度量。它具有最小状态(单个长数),并且不产生垃圾。对于任何给定的堆使用量(以千兆字节为单位),每个键的如此小的状态意味着垃圾收集器的最坏情况:非常大量的对象。GC的开销不是堆大小,而是对象计数。我们还测试了一个变量,该变量计算相同的聚合函数,但是使用了产生垃圾的差分实现。
我们在单个节点上执行大多数流基准测试,因为我们关注的是内存管理对流水线性能的影响,而网络延迟只会增加噪声。我们在一个三节点的AmazonEC2集群上重复了一些关键测试,以验证我们的预测,即集群性能不会影响我们的结论。您可以在第2部分的末尾找到更详细的理由。
我们将并行收集器排除在流式工作负载的结果之外,因为它带来的延迟峰值在几乎任何现实场景中都是不可接受的。
在这个场景中,堆使用量不到1 GB。收集器压力不高,它有充足的时间在后台执行并发GC。以下是我们通过测试的三个垃圾收集器观察到的最大管道延迟:
注意,这些数字包括发射窗口结果的大约3毫秒的固定时间。该图表非常简单明了:默认收集器G1本身相当不错,但是如果您需要更好的延迟,您可以使用实验性的Z收集器。对于Javaruntime来说,将GC暂停时间减少到10毫秒以下似乎仍然遥不可及。在我们的测试中,谢南多是个大输家,经常暂停,甚至超过了G1默认的200毫秒。
在场景2中,我们假设由于我们无法控制的各种原因(例如,移动网络),延迟可以增长到低秒数,这放宽了我们必须对流处理流水线施加的要求。另一方面,我们处理的数据卷可能要大得多,大约有数百万或数千万个密钥。
在这种情况下,我们可以配置硬件,使其得到充分利用,依靠GC来管理大型堆,而不是将数据分散在许多集群节点上。
我们使用不同的组合执行了许多测试,以找出各种因素之间的相互作用如何导致运行时跟上或落后。最后,我们发现了决定这一点的两个参数:
第一个与租期生成中的对象数相对应。滑动窗口聚合在相当长的时间内(窗口的长度)保留对象,然后释放它们。这与代际垃圾假说直接背道而驰,该假说认为,物体要么英年早逝,要么永生。这种机制给GC带来了最大的压力,由于GC的工作量与活动对象的数量成比例,因此性能对此参数高度敏感。
第二个参数与应用程序允许多少GC开销有关。为了更好地解释它,让我们用一些图表。管道执行窗口聚合要经历三个不同的步骤:
现在,净空空间已经缩小到几乎为零,管道几乎跟不上,任何暂时的问题,如偶尔的GC暂停,都会导致延迟以非常缓慢的速度增长和恢复。
如果我们改变这张图片,只显示窗口发射后的平均事件摄取率,我们会得到这样的结果:
我们称黄色矩形的高度为追赶需求:它是对源吞吐量的需求。如果超过实际最大吞吐量,则管道失败。
红色和黄色矩形的面积是固定的,它与必须流经管道的数据量相对应。基本上,红色的矩形挤掉了黄色的矩形。但黄色矩形的高度实际上是有限的,在我们的例子中,每秒只能发生220万次事件。因此,每当它的高度超过极限时,我们就会看到一条失败的管道,它的延迟无限制地增长。
对于给定的事件发生率、窗口大小、滑动步长和键集大小,我们推导出预测矩形大小的公式,从而可以确定每种情况下的追赶需求。
现在我们已经有了两个或多或少的独立参数,这些参数来自描述每个单独设置的更多参数,我们可以创建一个2D图表,其中每个基准测试运行都有一个点。我们给每个点分配了一种颜色,告诉我们给定的组合是有效的还是失败的。例如,对于开发人员笔记本电脑上安装了G1的JDK 14,我们会看到这样的图片:
我们区分了";yes";,";no";和";GC&34;,这意味着管道跟不上,因为吞吐量不足而跟不上,或者因为频繁的长时间GC暂停而跟不上。请注意,吞吐量不足也可能是并发GC活动和频繁的短暂GC暂停造成的。归根结底,区别并不重要。
你可以找出一条等高线,将左下角的区域与其余的空间分开,在那里,事情是可行的,而在其他空间,事情是失败的。我们为JDK和GC的其他组合制作了同样的图表,提取了等高线,并得出了以下汇总图表:
作为参考,我们使用的硬件是MacBook Pro 2018,具有6核Intel Core i7和16 GB DDR4 RAM,为JVM配置-Xmx10g。但是,我们确实希望组合之间的总体关系在广泛的硬件参数范围内保持不变。该图表直观地展示了G1相对于其他产品的优势,G1在JDK8上的弱点,以及实验性低延迟收集器在这类工作负载方面的弱点。
基本延迟,即发出窗口结果所需的时间,大致在500毫秒左右,但由于偶尔出现大型GC(对于G1来说,这并不是不合理的长),延迟通常需要增加,在边界情况下(管道几乎跟不上),最长可达10秒,但仍会恢复到一两秒。我们还注意到JIT编译在边界情况下的效果:流水线一开始会有一个不断增加的延迟,但大约两分钟后,它的性能会改善,延迟会完全恢复。