高吞吐低延迟 Java 应用的 GC 优化

说明

本篇原文做者是 LinkedIn 的 Swapnil Ghike,这篇文章讲述了 LinkedIn 的 Feed 产品的 GC 优化过程,虽然文章写做于 April 8, 2014,但其中的不少内容和知识点很是有学习和参考意义。所以,翻译后献给各位同窗。原文 Garbage Collection Optimization for High-Throughput and Low-Latency Java Applications,连接见参考 [1]。html

背景

高性能应用构成了现代网络的支柱。LinkedIn 内部有许多高吞吐量服务来知足每秒成千上万的用户请求。为了得到最佳的用户体验,以低延迟响应这些请求是很是重要的。java

例如,咱们的用户常用的产品是 Feed —— 它是一个不断更新的专业活动和内容的列表。Feed 在 LinkedIn 的系统中随处可见,包括公司页面、学校页面以及最重要的主页资讯信息。基础 Feed 数据平台为咱们的经济图谱(会员、公司、群组等)中各类实体的更新创建索引,它必须高吞吐低延迟地实现相关的更新。以下图,LinkedIn Feeds 信息展现:为了将这些高吞吐量、低延迟类型的 Java 应用程序用于生产,开发人员必须确保在应用程序开发周期的每一个阶段都保持一致的性能。肯定最佳垃圾收集(Garbage Collection, GC)配置对于实现这些指标相当重要。linux

这篇博文将经过一系列步骤来明确需求并优化 GC,它的目标读者是对使用系统方法进行 GC 优化来实现应用的高吞吐低延迟目标感兴趣的开发人员。在 LinkedIn 构建下一代 Feed 数据平台的过程当中,咱们总结了该方法。这些方法包括但不限于如下几点:并发标记清除(Concurrent Mark Sweep,CMS(参考[2]) 和 G1(参考 [3]) 垃圾回收器的 CPU 和内存开销、避免长期存活对象致使的持续 GC、优化 GC 线程任务分配提高性能,以及可预测 GC 停顿时间所需的 OS 配置。web

优化 GC 的正确时机?

GC 的行为可能会因代码优化以及工做负载的变化而变化。所以,在一个已实施性能优化的接近完成的代码库上进行 GC 优化很是重要。并且在端到端的基本原型上进行初步分析也颇有必要,该原型系统使用存根代码并模拟了可表明生产环境的工做负载。这样能够获取该架构延迟和吞吐量的真实边界,进而决定是否进行纵向或横向扩展。算法

在下一代 Feed 数据平台的原型开发阶段,咱们几乎实现了全部端到端的功能,而且模拟了当前生产基础设施提供的查询工做负载。这使咱们在工做负载特性上有足够的多样性,能够在足够长的时间内测量应用程序性能和 GC 特征。缓存

优化 GC 的步骤

下面是一些针对高吞吐量、低延迟需求优化 GC 的整体步骤。此外,还包括在 Feed 数据平台原型实施的具体细节。尽管咱们还对 G1 垃圾收集器进行了试验,但咱们发现 ParNew/CMS 具备最佳的 GC 性能。性能优化

1. 理解 GC 基础知识

因为 GC 优化须要调整大量的参数,所以理解 GC 工做机制很是重要。Oracle 的 Hotspot JVM 内存管理白皮书(参考 [4] )是开始学习 Hotspot JVM GC 算法很是好的资料。而了解 G1 垃圾回收器的理论知识,能够参阅(参考 [3])。网络

2. 仔细考量 GC 需求

为了下降对应用程序性能的开销,能够优化 GC 的一些特征。像吞吐量和延迟同样,这些 GC 特征应该在长时间运行的测试中观察到,以确保应用程序可以在经历多个 GC 周期中处理流量的变化。架构

  • Stop-the-world 回收器回收垃圾时会暂停应用线程。停顿的时长和频率不该该对应用遵照 SLA 产生不利的影响。并发

  • 并发 GC 算法与应用线程竞争 CPU 周期。这个开销不该该影响应用吞吐量。

  • 非压缩 GC 算法会引发堆碎片化,进而致使的 Full GC 长时间 Stop-the-world,所以,堆碎片应保持在最小值。

  • 垃圾回收工做须要占用内存。某些 GC 算法具备比其余算法更高的内存占用。若是应用程序须要较大的堆空间,要确保 GC 的内存开销不能太大。

  • 要清楚地了解 GC 日志和经常使用的 JVM 参数,以便轻松地调整 GC 行为。由于 GC 运行随着代码复杂性增长或工做负载特性的改变而发生变化

咱们使用 Linux 操做系统、Hotspot Java7u5一、32GB 堆内存、6GB 新生代(Young Gen)和 -XX:CMSInitiatingOccupancyFraction 值为 70(Old GC 触发时其空间占用率)开始实验。设置较大的堆内存是用来维持长期存活对象的对象缓存。一旦这个缓存生效,晋升到 Old Gen 的对象速度会显著降低。

使用最初的 JVM 配置,每 3 秒发生一次 80ms 的 Young GC 停顿,超过 99.9% 的应用请求延迟 100ms(999线)。这样的 GC 效果可能适合于 SLA 对延迟要求不太严格应用。然而,咱们的目标是尽量减小应用请求的 999 线。GC 优化对于实现这一目标相当重要。

3. 理解 GC 指标

衡量应用当前状况始终是优化的先决条件。了解 GC 日志的详细细节(参考 [5])(使用如下选项):

 
  1. -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps

  2. -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime

能够对该应用的 GC 特征有整体的把握。

在 LinkedIn 的内部监控 inGraphs 和报表系统 Naarad,生成了各类有用的指标可视化图形,好比 GC 停顿时间百分比、一次停顿最大持续时间以及长时间内 GC 频率。除了 Naarad,有不少开源工具好比 gclogviewer 能够从 GC 日志建立可视化图形。在此阶段,能够肯定 GC 频率和暂停持续时间是否知足应用程序知足延迟的要求。

4. 下降 GC 频率

在分代 GC 算法中,下降 GC 频率能够经过:(1) 下降对象分配/晋升率;(2) 增长各代空间的大小。

在 Hotspot JVM 中,Young GC 停顿时间取决于一次垃圾回收后存活下来的对象的数量,而不是 Young Gen 自身的大小。增长 Young Gen 大小对于应用性能的影响须要仔细评估:

  • 若是更多的数据存活并且被复制到 Survivor 区域,或者每次 GC 更多的数据晋升到 Old Gen,增长 Young Gen 大小可能致使更长的 Young GC 停顿。较长的 GC 停顿可能会致使应用程序延迟增长和(或)吞吐量下降。

  • 另外一方面,若是每次垃圾回收后存活对象数量不会大幅增长,停顿时间可能不会延长。在这种状况下,下降 GC 频率可能会使整个应用整体延迟下降和(或)吞吐量增长。

对于大部分为短时间存活对象的应用,仅仅须要控制上述的参数;对于长期存活对象的应用,就须要注意,被晋升的对象可能很长时间都不能被 Old GC 周期回收。若是 Old GC 触发阈值(Old Gen 占用率百分比)比较低,应用将陷入持续的 GC 循环中。能够经过设置高的 GC 触发阈值可避免这一问题。

因为咱们的应用在堆中维持了长期存活对象的较大缓存,将 Old GC 触发阈值设置为

 
  1. -XX:CMSInitiatingOccupancyFraction=92 -XX:+UseCMSInitiatingOccupancyOnly

来增长触发 Old GC 的阈值。咱们也试图增长 Young Gen 大小来减小 Young GC 频率,可是并无采用,由于这增长了应用的 999 线。

5. 缩短 GC 停顿时间

减小 Young Gen 大小能够缩短 Young GC 停顿时间,由于这可能致使被复制到 Survivor 区域或者被晋升的数据更少。可是,正如前面提到的,咱们要观察减小 Young Gen 大小和由此致使的 GC 频率增长对于总体应用吞吐量和延迟的影响。Young GC 停顿时间也依赖于 tenuring threshold (晋升阈值)和 Old Gen 大小(如步骤 6 所示)。

在使用 CMS GC 时,应将因堆碎片或者由堆碎片致使的 Full GC 的停顿时间下降到最小。经过控制对象晋升比例和减少 -XX:CMSInitiatingOccupancyFraction 的值使 Old GC 在低阈值时触发。全部选项的细节调整和他们相关的权衡,请参考 Web Services 的 Java 垃圾回收(参考 [5] )和 Java 垃圾回收精粹(参考 [6])。

咱们观察到 Eden 区域的大部分 Young Gen 被回收,几乎没有 3-8 年龄对象在 Survivor 空间中死亡,因此咱们将 tenuring threshold 从 8 下降到 2 (使用选项:-XX:MaxTenuringThreshold=2 ),以下降 Young GC 消耗在数据复制上的时间。

咱们还注意到 Young GC 暂停时间随着 Old Gen 占用率上升而延长。这意味着来自 Old Gen 的压力使得对象晋升花费更多的时间。为解决这个问题,将总的堆内存大小增长到 40GB,减少 -XX:CMSInitiatingOccupancyFraction 的值到 80,更快地开始 Old GC。尽管 -XX:CMSInitiatingOccupancyFraction 的值减少了,增大堆内存能够避免频繁的 Old GC。在此阶段,咱们的结果是 Young GC 暂停 70ms,应用的 999 线在 80ms。

6. 优化 GC 工做线程的任务分配

为了进一步下降 Young GC 停顿时间,咱们决定研究 GC 线程绑定任务的参数来进行优化。

-XX:ParGCCardsPerStrideChunk 参数控制 GC 工做线程的任务粒度,能够帮助不使用补丁而得到最佳性能,这个补丁用来优化 Young GC 中的 Card table(卡表)扫描时间(参考[7])。有趣的是,Young GC 时间随着 Old Gen 的增长而延长。将这个选项值设为 32678,Young GC 停顿时间下降到平均 50ms。此时应用的 999 线在 60ms。

还有一些的参数能够将任务映射到 GC 线程,若是操做系统容许的话,-XX:+BindGCTaskThreadsToCPUs 参数能够绑定 GC 线程到个别的 CPU 核(看法释 [1])。使用亲缘性 -XX:+UseGCTaskAffinity 参数能够将任务分配给 GC 工做线程(看法释 [2])。然而,咱们的应用并无从这些选项带来任何好处。实际上,一些调查显示这些选项在 Linux 系统不起做用。

7. 了解 GC 的 CPU 和内存开销

并发 GC 一般会增长 CPU 使用率。虽然咱们观察到 CMS 的默认设置运行良好,可是 G1 收集器的并发 GC 工做会致使 CPU 使用率的增长,显著下降了应用程序的吞吐量和延迟。与 CMS 相比,G1 还增长了内存开销。对于不受 CPU 限制的低吞吐量应用程序,GC 致使的高 CPU 使用率可能不是一个紧迫的问题。

下图是 ParNew/CMS 和 G1 的 CPU 使用百分比:相对来讲 CPU 使用率变化明显的节点使用 G1 参数 -XX:G1RSetUpdatingPauseTimePercent=20:

下图是 ParNew/CMS 和 G1 每秒服务的请求数:吞吐量较低的节点使用 G1 参数 -XX:G1RSetUpdatingPauseTimePercent=20

8. 为 GC 优化系统内存和 I/O 管理

一般来讲,GC 停顿有两种特殊状况:(1) 低 user time,高 sys time 和高 real time (2) 低 user time,低 sys time 和高 real time。这意味着基础的进程/OS设置存在问题。状况 (1) 可能意味着 JVM 页面被 Linux 窃取;状况 (2) 可能意味着 GC 线程被 Linux 用于磁盘刷新,并卡在内核中等待 I/O。在这些状况下,如何设置参数能够参考该 PPT(参考 [8])。

另外,为了不在运行时形成性能损失,咱们可使用 JVM 选项 -XX:+AlwaysPreTouch 在应用程序启动时先访问全部分配给它的内存,让操做系统把内存真正的分配给 JVM。咱们还能够将 vm.swappability 设置为0,这样操做系统就不会交换页面到 swap(除非绝对必要)。

可能你会使用 mlock 将 JVM 页固定到内存中,这样操做系统就不会将它们交换出去。可是,若是系统用尽了全部的内存和交换空间,操做系统将终止一个进程来回收内存。一般状况下,Linux 内核会选择具备高驻留内存占用但运行时间不长的进程(OOM 状况下杀死进程的工做流(参考[9])进行终止。在咱们的例子中,这个进程颇有可能就是咱们的应用程序。优雅的降级是服务优秀的属性之一,不过服务忽然终止的可能性对于可操做性来讲并很差 —— 所以,咱们不使用 mlock,只是经过 vm.swapability 来尽量避免交换内存页到 swap 的惩罚。

LinkedIn 动态信息数据平台的 GC 优化

对于该 Feed 平台原型系统,咱们使用 Hotspot JVM 的两个 GC 算法优化垃圾回收:

  • Young GC 使用 ParNew,Old GC 使用 CMS。

  • Young Gen 和 Old Gen 使用 G1。G1 试图解决堆大小为 6GB 或更大时,暂停时间稳定且可预测在 0.5 秒如下的问题。在咱们用 G1 实验过程当中,尽管调整了各类参数,但没有获得像 ParNew/CMS 同样的 GC 性能或停顿时间的可预测值。咱们查询了使用 G1 发生内存泄漏相关的一个 bug(看法释[3]),但还不能肯定根本缘由。

使用 ParNew/CMS,应用每三秒进行一次 40-60ms 的 Young GC 和每小时一个 CMS GC。JVM 参数以下:

 
  1. // JVM sizing options

  2. -server -Xms40g -Xmx40g -XX:MaxDirectMemorySize=4096m -XX:PermSize=256m -XX:MaxPermSize=256m

  3. // Young generation options

  4. -XX:NewSize=6g -XX:MaxNewSize=6g -XX:+UseParNewGC -XX:MaxTenuringThreshold=2 -XX:SurvivorRatio=8 -XX:+UnlockDiagnosticVMOptions -XX:ParGCCardsPerStrideChunk=32768

  5. // Old generation options

  6. -XX:+UseConcMarkSweepGC -XX:CMSParallelRemarkEnabled -XX:+ParallelRefProcEnabled -XX:+CMSClassUnloadingEnabled -XX:CMSInitiatingOccupancyFraction=80 -XX:+UseCMSInitiatingOccupancyOnly

  7. // Other options

  8. -XX:+AlwaysPreTouch -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -XX:-OmitStackTraceInFastThrow

使用这些参数,对于成千上万读请求的吞吐量,咱们应用程序的 999 线下降到 60ms。

感谢

参与了原型应用程序开发的同窗有:Ankit Gupta、Elizabeth Bennett、Raghu Hiremagalur、Roshan Sumbaly、Swapnil Ghike、Tom Chiang 和 Vivek Nelamangala。另外,感谢 Cuong Tran、David Hoa 和 Steven Ihde 在系统优化方面的帮助。

参考

[1] Garbage Collection Optimization for High-Throughput and Low-Latency Java Applications(https://engineering.linkedin.com/garbage-collection/garbage-collection-optimization-high-throughput-and-low-latency-java-applications)

[2] 并发标记清除(Concurrent Mark Sweep,CMS) https://www.oracle.com/technetwork/java/javase/memorymanagement-whitepaper-150215.pdf

[3] G1(https://www.oracle.com/webfolder/technetwork/tutorials/obe/java/G1GettingStarted/index.html)

[4] 内存管理白皮书(https://www.oracle.com/technetwork/java/javase/memorymanagement-whitepaper-150215.pdf)

[5]  Web Services 的 Java 垃圾回收(https://engineering.linkedin.com/26/tuning-java-garbage-collection-web-services)

[6] Java 垃圾回收精粹(http://mechanical-sympathy.blogspot.com/2013/07/java-garbage-collection-distilled.html)

[7] 卡表扫描时间(http://blog.ragozin.info/2012/03/secret-hotspot-option-improving-gc.html)

[8] Gc and-pagescan-attacks-by-linux(http://www.slideshare.net/cuonghuutran/gc-andpagescanattacksbylinux)

[9] OOM 状况下杀死进程的工做流 (https://www.kernel.org/doc/gorman/html/understand/understand016.html)

解释

[1] -XX:+BindGCTaskThreadsToCPUs 参数彷佛在Linux 系统上不起做用,由于 hotspot/src/os/linux/vm/oslinux.cpp 的 distributeprocesses 方法在 JDK7 或 JDK8 中没有实现。

[2] -XX:+UseGCTaskAffinity 参数在 JDK7 和 JDK8 的全部平台彷佛都不起做用,由于任务的亲缘性属性永远被设置为 sentinelworker = (uint) -1。源码见 hotspot/src/share/vm/gcimplementation/parallelScavenge/{gcTaskManager.cpp,gcTaskThread.cpp, gcTaskManager.cpp}。

[3] G1 存在一些内存泄露的 bug,可能 Java7u51 没有修改。这个 bug 仅在 Java 8 修正了。

相关文章
相关标签/搜索