随着 JDK 版本的不断升级,其 GC 策略也随之不停革新,从早期的 1.4 到如今的 11(本文仅讨论在线上环境落地规模较大的版本),其对应的 GC 策略也随之由 Serial、Parallel、CMS 演进至当前的 G1 甚至即将落地的 ZGC 。每一次的调整无不是基于环境的适配性以及业务场景特性,无论如何,只要能够基于特定的操作系统内核、物理内存、JDK版本以及业务特性,达到收益最大化,采用何种实现策略都不为过。当然,还是建议大家以官方的推荐为准,基于自己的业务场景进行不断优化调整,这样才能保证万无一失,使得我们的业务能够健康发展。
本文主要以 CMS GC 为核心,简要解析有关在基于CMS GC 策略下进行 Java 虚拟机调优的最佳实践建议。涉及常见关键症状、堆空间以及 GC Log。此最佳实践建议适用于在 Java® 8上运行的AM / OpenAM、DS / OpenDJ、IDM / OpenIDM和IG /OpenIG。其实,从本质上讲,Java 虚拟机调整并不是一门精确的科学理论,因为它会因各个环境差异性和应用程序多样性而表现的有所不同。基于对当前环境进行一致的压力及稳定性测试才是评估所优化调整影响业务场景的唯一真实方法及指导。
那么,在现实的业务场景中,我们是如何决策优化呢?在开始进行 Java 虚拟机调整之前,笔者建议大家需要关注以下几点:
1、 内存泄漏
Memory leaks 内存泄漏问题,几乎伴随着整个 Java 虚拟机生态,基于不同的应用场景,其表现形式为多样化。因此,如果无论我们基于何种方式的调整,其效果往往不是那么理想,这些可疑点可能总会导致垃圾回收(GC)问题。此点为 Java 虚拟机性能调整过程中所必须面对、解决的。 例如,应用中的一些对象不再被应用程序使用同时垃圾收集无法识别的情况。因此,这些未使用的对象仍然在 Java 堆空间中无限期地存在。不停的堆积最终会触发 java.lang.OutOfMemoryError 错误。
2、资源成本
关于成本问题,视公司的实际情况而定,仁者见仁,智者见智。技术实力落后的团队,更倾向于增加更多的硬件设备资源来提高性能,毕竟,现在的资源相比之前成本低多了。然而,技术实力足够牛逼的团队,更愿意从本质上,即技术层面非资源层面,去解决问题。毕竟,后者更能够对整个业务系统的瓶颈、缺陷有所了解、把控,更能在后续的容量规划中提供有价值的参考数据作为支撑,虽然后者可能耗费较多时间成本,但基于后续的业务规划而言,其相比前者更具有战略价值。这也是笔者所推崇的。
3、近期规划
我们都知道,性能调优是一个持久的过程,针对我们自己的业务场景所规划的函数模型,例如:P 的表现值由(x、y、z、...)*r 等多个不同性能因子决定,那么,可能在某一有限的项目活动周期内,我们可能无法顾及所有的性能因子,因此,就需要进行优先级决策,从影响的大小或者范围进行降级排序,然后对选择的性能因子进行验证,调整,完善,当然,也有可能其中的某些因子本身就互逆,导致某些场景失效。所以,如果需要解决根本问题,则调优只能以阶段性规划为导向局部因素调整来满足当前项目周期内的业务痛点。
4、最终期望
基于上述3点所述,在我们解决当前环境中的核心痛点后,需要从业务角度去优化后续带来的挑战,例如,新业务的落地、流量的剧增以及其他场景对当前系统架构产生影响的各个方面。因此,从长远规划来看,架构的稳定性比调整单纯的性能更为重要,尽管存在重叠。
本文主要基于笔者当前公司的应用环境,主要采用 Java 8。在 Java 8中,CMS 是默认的垃圾收集器,其核心目标为倾向于以最少的垃圾收集暂停时间提供最佳的性能特性。因此,只有当我们的应用服务在进行性能对比,在基于 CMS 策略下,其效能不能够支撑我们的业务需求场景下,才考虑尝试使用 G1 或者其他的垃圾收集器。下面,我们针对 Java 虚拟机中核心的组件进行简要解析,以帮助大家能够更深入了解其运行原理及管理机制。在进行解析之前,我们先来了解下 Java 虚拟机体现架构,具体如下示意图所示:
从上面架构图可以看出,各个组件在整个 Java 虚拟机体系中的位置以及发挥的作用。关于 Java 虚拟机体系架构的详细解析,暂不在本章中所述,大家有兴趣可以去官网查阅。
Heap
什么是堆?它能干嘛?何时需要对它进行调整?Java 堆是分配给在 JVM中运行应用程序的内存资源池。堆内存中的对象可以在线程之间共享。垃圾回收(GC)是指管理运行时内存的自动调节过程。JVM 会随着 GC Pause的影响,其频率、持续时间各不相同。如果这些暂停变得更加频繁或持续更长的时间,表明我们的应用程序可能需要调整 Java堆。
在实际的业务场景中,通常我们会指定初始堆 -Xms 与最大堆 -Xmx,并对其分配内存的大小尽可能一致,以获得最优的性能表现。否则,JVM 在增加其堆时会运行完整的垃圾收集器(GC)周期,在此期间,JVM 可以将正在进行的操作暂停几秒钟。通常,较小的堆会增加 JVM GC 的执行频次,但会减少持续时间。同样,较大的堆将减少频率并增加持续时间。调整 JVM 堆时,我们的初衷目标在于在频率和持续时间之间取得平衡,以减少 GC 对应用程序的影响。
下面,我们来看一下整个 JVM 内存的资源分配示意图,具体如下所示:
基于上述参考图,堆最初是在 JVM 启动时创建的,并将其划分为不同的“空间”或“代”,其中,核心的是 Young(新生)和 Tenured(老年):
Young 代用于新生对象。GC 进程(ParNew)变满时会自动运行,该进程将删除未使用的对象并将所有生存时间足够长的对象移至 Tenured 代,从而释放 Young 代中用于更多新对象的空间。通常,年轻代中的对象是短暂且短暂的。虽然很小,但活跃,GC 经常发生,但对性能的影响有限。
Tenured 代用于寿命更长的对象,也就是我们所说的老年对象。另一个 GC 进程(CMS)变满时将运行,以删除所有未使用的对象。与 Young 代相比,Tenured 代更大且不那么活跃,但是 GC 往往会对性能产生更大的影响。
那么,什么时候需要对 Heap 下手呢?通常,当业务场景出现如下表现时,可考虑进行调整,具体:
1、CPU使用率过高
当我们的程序处理能力无法满足实际的业务需求,通过后台 GC Log 查看,出现大量的 Full GC,频繁的 Full GC 操作需要耗费大量的 CPU 时间片,所以,我们会看到 CPU 出现激增情况。
2、应用服务挂起
当我们的应用程序假死并停止响应时,前端显示 500 或者业务中断,同时,日志文件中也将不会输出相关有价值的信息,或者我们在执行某一定时任务时遇到一般响应缓慢的情况,则可能是内存分配异常导致。
3、大量的转储文件生成
针对内存层面,当 JVM 中对象过多, Java堆耗尽时,就会产生 Java Heap Dump文件,通常以 .heapdump 或 .hprof 格式展现。此文件较大,最小大于1G,最大不限制,通常大约在2~10 G之间,不断地生成此类文件,很容易使得磁盘打爆,服务挂掉。针对 CPU 层面,则往往会生成较多的 javacore.txt 文件,此文件伙同堆转储文件。
GC Loging
接下来,我们来了解下 GC Log 相关方面细节。通常,在基于 Java 8 ,建议大家优先采用 CMS(Concurrent Mark Sweep),它是一个吞吐量收集器,在与应用程序同时运行时往往会提供最佳性能,这意味着较少的暂停时间。实际的场景中,通过设置以下 JVM 选项来指定此收集器,具体如下所示:
-XX:+UseConcMarkSweepGC
ParNew 部分
ParNew 部分的 GC 日志输出示例,具体如下所示:
21.222: [GC21.222: [ParNew: 535347K->39439K(623550K), 0.0394310 secs] 584661K->87777K(3102652K), 0.0401790 secs] [Times: user=0.17 sys=0.00, real=0.04 secs] 26.525: [GC26.525: [ParNew: 583983K->27488K(623550K), 0.0868170 secs] 614171K->104810K(3102652K), 0.0875620 secs] [Times: user=0.24 sys=0.01, real=0.09 secs]
该日志文件中的每一行代表一个 GC,并显示有关 ParNew 的以下类型的信息:
1、时间戳信息-如果未设置 PrintGCTimeStamps 和 PrintGCDateStamps 选项,则仅以 JVM 启动后的秒数显示:
21.222: [GC21.222: 26.525: [GC26.525:
此信息很关键,因为它显示了 GC 的频率。 假设我们定义的目标是使 GC 每1到5秒发生一次;如果它们每秒发生一次以上,则需要调整 JVM,并且很有可能会看到很高的CPU使用率。
2、年轻代信息
[ParNew: 535347K->39439K(623550K), 0.0394310 secs] [ParNew: 583983K->27488K(623550K), 0.0868170 secs]
此信息显示执行 GC 之前的 Young 空间的初始大小,执行 GC 之后的大小,可用的总大小以及执行 GC 所需的时间。如果执行 GC 所需的时间过长(超过0.1秒),则可能是您的 Young Generation堆大小太大。如果将 NewSize 和 MaxNewSize 设置为相同的值,则可用的总大小不会增加,这就是我们希望在日志中看到的大小。
3、总分配堆信息
584661K->87777K(3102652K), 0.0426850 secs] 614171K->104810K(3102652K), 0.0892180 secs]
此信息显示执行 GC 之前的总体堆的初始大小,执行 GC 之后的大小以及可用的总大小。使用第二批数据(完成 GC 后的大小),我们可以看到它如何逐渐增长到完成 GC 时的水平,然后又减小到基线数据。如果发生内存泄漏,即使执行完 GC 后,基线数字也会逐渐增加,因为它越来越满。
4、系统耗时信息
[Times: user=0.13 sys=0.00, real=0.02 secs] [Times: user=0.27 sys=0.02, real=0.11 secs]
该信息显示了在用户空间上花费了多少时间,在内核或系统空间上花费了多少时间以及对用户的真正影响是什么。如果用户时间过长,则表明没有足够的CPU来容纳用户线程数。如果系统时间过长,则表明应用系统正在将内存交换到磁盘,这意味着没有足够的物理内存来满足您的堆大小。
CMS 部分
日志文件的 CMS 部分与 ParNew 部分不同,因为它显示了同一 GC 的多行。CMS部分的示例 GC 日志输出如下所示:
30.136: [GC [1 CMS-initial-mark: 26386K(786432K)] 26404K(1048384K), 0.0074495 secs] 30.144: [CMS-concurrent-mark-start] 30.663: [CMS-concurrent-mark: 0.521/0.529 secs] 340.663: [CMS-concurrent-preclean-start] 30.801: [CMS-concurrent-preclean: 0.017/0.018 secs] 30.804: [GC40.704: [Rescan (parallel) , 0.1790103 secs]40.883: [weak refs processing, 0.0100966 secs] [1 CMS-remark: 26386K(786432K)] 52644K(1048384K), 0.1897792 secs] 30.984: [CMS-concurrent-sweep-start] 31.020: [CMS-concurrent-sweep: 0.126/0.126 secs] 31.020: [CMS-concurrent-reset-start] 31.135: [CMS-concurrent-reset: 0.127/0.127 secs]
该日志显示了 GC 经历的不同阶段。所有标记为并发的事件都是在应用程序运行时发生的,因此影响很小。CMS 初始标记和 CMS 标记显示堆大小调整详细信息。
通常,在实际的业务场景中,我们往往会看到这些部分日志打印混杂在一起,例如,某些 ParNews 比 CMS 多一些,ParNews多一些,另一个CMS 等,因为 GC 进程会自动发生以管理堆。
CMS GC 常见场景分析
此部分内容主要解析在基于 CMS 策略时,出现的各种场景以及针对性的解决方案,以便大家能够掌握此处内容。
1、年轻代分配过小
以下日志片段显示了 Young 代堆大小太小的问题,具体如下所示:
1.813: [GC1.813: [ParNew: 1152K>128K(1152K), 0.0008100 secs] 16620K->15756K(26936K), 0.0008350 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 1.816: [GC1.816: [ParNew: 1152K>128K(1152K), 0.0006430 secs] 16780K->15913K(26936K), 0.0006640 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 1.819: [GC1.819: [ParNew: 1152K>128K(1152K), 0.0005370 secs] 16937K->16038K(26936K), 0.0005570 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
基于上述 GC Log 输出,我们可以看到:在1秒钟内发生了多个 GC。这意味着应用程序需要增加 Young 代的大小(NewSize和MaxNewSize),还可能需要增加总堆大小以补偿此更改(Xms和Xmx)。或者,可以更改 NewRatio 选项(默认情况下设置为2,这意味着 Tenured 代堆的大小是 Young 代堆的两倍,或者 Young 代堆的大小是 Tenured 世代堆大小的1/3。)。
2、老年代分配过小
以下日志片段显示了一个问题,其中,Young 代堆大小太大,因此 Tenured 代堆大小太小,具体如下所示:
275.616: [GC (CMS Initial Mark) [1 CMS-initial-mark: 104176K(135168K)] 2758985K(6741248K), 0.8762860 secs] [Times: user=0.88 sys=0.00, real=0.88 secs] 276.583: [CMS-concurrent-mark-start] 276.657: [CMS-concurrent-mark: 0.063/0.065 secs] [Times: user=0.12 sys=0.00, real=0.06 secs] 276.657: [CMS-concurrent-preclean-start] 276.657: [CMS-concurrent-preclean: 0.001/0.001 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 276.657: [CMS-concurrent-abortable-preclean-start] 276.657: [CMS-concurrent-abortable-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 276.657: [GC (CMS Final Remark)[YG occupancy: 2657419 K (6606080 K)]277.658: [Rescan (parallel) , 0.9815460 secs]278.639: [weak refs processing, 0.0000320 secs]278.640: [scrub string table, 0.0011700 secs] [1 CMS-remark: 104176K(135168K)] 2761595K(6741248K), 0.9828250 secs] [Times: user=7.18 sys=0.09, real=0.99 secs] 277.631: [CMS-concurrent-sweep-start] 277.658: [CMS-concurrent-sweep: 0.026/0.027 secs] [Times: user=0.03 sys=0.00, real=0.02 secs] 277.658: [CMS-concurrent-reset-start] 277.658: [CMS-concurrent-reset: 0.001/0.001 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 280.673: [GC (CMS Initial Mark) [1 CMS-initial-mark: 104079K(135168K)] 2774091K(6741248K), 0.9033730 secs] [Times: user=0.90 sys=0.00, real=0.90 secs] 281.577: [CMS-concurrent-mark-start] 281.640: [CMS-concurrent-mark: 0.063/0.063 secs] [Times: user=0.13 sys=0.00, real=0.07 secs] 281.640: [CMS-concurrent-preclean-start] 281.641: [CMS-concurrent-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 281.641: [CMS-concurrent-abortable-preclean-start] 281.641: [CMS-concurrent-abortable-preclean: 0.000/0.000 secs] [Times: user=0.00 sys=0.00, real=0.00 secs] 281.641: [GC (CMS Final Remark)[YG occupancy: 2670011 K (6606080 K)]281.641: [Rescan (parallel) , 0.9914290 secs]282.633: [weak refs processing, 0.0000110 secs]282.633: [scrub string table, 0.0008100 secs] [1 CMS-remark: 104079K(135168K)] 2774091K(6741248K), 0.9923100 secs] [Times: user=7.14 sys=0.11, real=0.99 secs] 282.634: [CMS-concurrent-sweep-start] 282.659: [CMS-concurrent-sweep: 0.024/0.025 secs] [Times: user=0.06 sys=0.01, real=0.02 secs] 282.659: [CMS-concurrent-reset-start] 282.659: [CMS-concurrent-reset: 0.001/0.001 secs] [Times: user=0.00 sys=0.00, real=0.00 secs]
如上述 GC Log 所见,CMS 回收的发生频率很高,而两者之间没有任何 ParNew 回收。这是由于 Young 代堆大小太大而导致每次发生 ParNew 回收时,Tenured 堆大小都会立即变满并运行 CMS 回收。如果我们在不增加总堆大小的情况下增加了 Young 代的堆大小,则可能会发生这种情况。如果有内存泄漏,也可能发生此日志摘要信息。
针对 CMS 场景中的 CMS Failure、Full GC 以及其他相关内容,可参考之前文章,具体链接为:CMS 触发GC(Allocation Failure)解析。
最后,从宏观角度,针对系统调优,笔者给出以下建议,具体:
1、尽可能对当前的应用系统进行负载/压力测试,以建立性能基线。
2、在进行调优时,尽可能每次只调整其中一个性能因子,观测及记录每一次的优化结果,必要时进行恢复。
至此,关于 Java虚拟机 CMS GC 调优解析相关内容本文到此为止,大家有什么疑问、想法及建议,欢迎留言沟通。