JVM性能调优——GC优化

当Java程序性能达不到既定目标,且其他优化手段都已经穷尽时,通常需要调整垃圾回收器来进一步提高性能,称为GC优化。但GC算法复杂,影响GC性能的参数众多,且参数调整又依赖于应用各自的特点,这些因素很大程度上增加了GC优化的难度。即便如此,GC调优也不是无章可循,仍然有一些通用的思考方法。

一、如何开启GC日志

1、在Tomcat 的安装路径下,找到bin/catalina.sh 加上下面的配置,具体参数,自己配置:

JAVA_OPTS='-Xms1024m -Xmx2048m -XX:PermSize=64M -XX:MaxNewSize=128m -XX:MaxPermSize=64m -XX:ParallelGCThreads=8 -XX:+UseConcMarkSweepGC -Xloggc:/usr/local/tomcat/logs/tomcat_gc.log'

2、重启tomcat

[root@centos7 ~]# systemctl restart tomcat

3、查看GC日志

[root@centos7 ~]# cat /usr/local/tomcat/logs/tomcat_gc.log

二、参数基本策略

各分区的大小对GC的性能影响很大。如何将各分区调整到合适的大小,分析活跃数据的大小是很好的切入点。

活跃数据的大小是指,应用程序稳定运行时长期存活对象在堆中占用的空间大小,也就是Full GC后堆中老年代占用空间的大小。可以通过GC日志中Full GC之后老年代数据大小得出,比较准确的方法是在程序稳定后,多次获取GC数据,通过取平均值的方式计算活跃数据的大小。活跃数据和各分区之间的比例关系如下(见参考文献1):
JVM性能调优——GC优化_第1张图片
例如,根据GC日志获得老年代的活跃数据大小为300M,那么各分区大小可以设为:

总堆:1200MB = 300MB × 4* 新生代:450MB = 300MB × 1.5* 老年代: 750MB = 1200MB - 450MB*

这部分设置仅仅是堆大小的初始值,后面的优化中,可能会调整这些值,具体情况取决于应用程序的特性和需求。

三、优化步骤

GC优化一般步骤可以概括为:确定目标、优化参数、验收结果。

确定目标

明确应用程序的系统需求是性能优化的基础,系统的需求是指应用程序运行时某方面的要求,譬如: - 高可用,可用性达到几个9。 - 低延迟,请求必须多少毫秒内完成响应。 - 高吞吐,每秒完成多少次事务。

由于笔者所在团队主要关注高可用和低延迟两项指标,所以接下来分析,如何量化GC时间和频率对于响应时间和可用性的影响。通过这个量化指标,可以计算出当前GC情况对服务的影响,也能评估出GC优化后对响应时间的收益,这两点对于低延迟服务很重要。

举例:假设单位时间T内发生一次持续25ms的GC,接口平均响应时间为50ms,且请求均匀到达,根据下图所示:

JVM性能调优——GC优化_第2张图片
那么有(50ms+25ms)/T比例的请求会受GC影响,其中GC前的50ms内到达的请求都会增加25ms,GC期间的25ms内到达的请求,会增加0-25ms不等,如果时间T内发生N次GC,受GC影响请求占比=(接口响应时间+GC时间)×N/T 。可见无论降低单次GC时间还是降低GC次数N都可以有效减少GC对响应时间的影响。

优化

通过收集GC信息,结合系统需求,确定优化方案,例如选用合适的GC回收器、重新设置内存比例、调整JVM参数等。

进行调整后,将不同的优化方案分别应用到多台机器上,然后比较这些机器上GC的性能差异,有针对性的做出选择,再通过不断的试验和观察,找到最合适的参数。

验收优化结果

将修改应用到所有服务器,判断优化结果是否符合预期,总结相关经验。

接下来,我们通过三个案例来实践以上的优化流程和基本原则(本文中三个案例使用的垃圾回收器均为ParNew+CMS,CMS失败时Serial Old替补)。

案例一 Major GC和Minor GC频繁

确定目标

服务情况:Minor GC每分钟100次 ,Major GC每4分钟一次,单次Minor GC耗时25ms,单次Major GC耗时200ms,接口响应时间50ms。

由于这个服务要求低延时高可用,结合上文中提到的GC对服务响应时间的影响,计算可知由于Minor GC的发生,12.5%的请求响应时间会增加,其中8.3%的请求响应时间会增加25ms,可见当前GC情况对响应时间影响较大。

(50ms+25ms)× 100次/60000ms = 12.5%,50ms × 100次/60000ms = 8.3% 。

小结

如何选择各分区大小应该依赖应用程序中对象生命周期的分布情况:如果应用存在大量的短期对象,应该选择较大的年轻代;如果存在相对较多的持久对象,老年代应该适当增大。

通过增大新生代空间来降低Minor GC的频率。例如在相同的内存分配率的前提下,新生代中的Eden区增加一倍,Minor GC的次数就会减少一半。

服务中存在大量短期临时对象,扩容新生代空间后,Minor GC频率降低,对象在新生代得到充分回收,只有生命周期长的对象才进入老年代。这样老年代增速变慢,Major GC频率自然也会降低。

案例二 请求高峰期发生GC,导致服务可用性下降

确定目标
GC日志显示,高峰期CMS在重标记(Remark)阶段耗时1.39s。Remark阶段是Stop-The-World(以下简称为STW)的,即在执行垃圾回收时,Java应用程序中除了垃圾回收器线程之外其他所有线程都被挂起,意味着在此期间,用户正常工作的线程全部被暂停下来,这是低延时服务不能接受的。本次优化目标是降低Remark时间。

优化
CMS的四个主要阶段:

  1. Init-mark初始标记(STW) ,该阶段进行可达性分析,标记GC ROOT能直接关联到的对象,所以很快。
  2. Concurrent-mark并发标记,由前阶段标记过的绿色对象出发,所有可到达的对象都在本阶段中标记。
  3. Remark重标记(STW) ,暂停所有用户线程,重新扫描堆中的对象,进行可达性分析,标记活着的对象。因为并发标记阶段是和用户线程并发执行的过程,所以该过程中可能有用户线程修改某些活跃对象的字段,指向了一个未标记过的对象,如下图中红色对象在并发标记开始时不可达,但是并行期间引用发生变化,变为对象可达,这个阶段需要重新标记出此类对象,防止在下一阶段被清理掉,这个过程也是需要STW的。特别需要注意一点,这个阶段是以新生代中对象为根来判断对象是否存活的。
  4. 并发清理,进行并发的垃圾清理。

小结:

如果Remark前执行一次Minor GC,大部分对象就会被回收。CMS就采用了这样的方式,在Remark前增加了一个可中断的并发预清理(CMS-concurrent-abortable-preclean),该阶段主要工作仍然是并发标记对象是否存活,只是这个过程可被中断。此阶段在Eden区使用超过2M时启动,当然2M是默认的阈值,可以通过参数修改。如果此阶段执行时等到了Minor GC,那么上述灰色对象将被回收,Reamark阶段需要扫描的对象就少了。

除此之外CMS为了避免这个阶段没有等到Minor GC而陷入无限等待,提供了参数CMSMaxAbortablePrecleanTime ,默认为5s,含义是如果可中断的预清理执行超过5s,不管发没发生Minor GC,都会中止此阶段,进入Remark。 根据GC日志红色标记2处显示,可中断的并发预清理执行了5.35s,超过了设置的5s被中断,期间没有等到Minor GC ,所以Remark时新生代中仍然有很多对象。

对于这种情况,CMS提供CMSScavengeBeforeRemark参数,用来保证Remark前强制进行一次Minor GC。

通过案例分析了解到,由于跨代引用的存在,CMS在Remark阶段必须扫描整个堆,同时为了避免扫描时新生代有很多对象,增加了可中断的预清理阶段用来等待Minor GC的发生。只是该阶段有时间限制,如果超时等不到Minor GC,Remark时新生代仍然有很多对象,我们的调优策略是,通过参数强制Remark前进行一次Minor GC,从而降低Remark阶段的时间。

案例三 发生Stop-The-World的GC

确定目标:

GC日志如下图(在GC日志中,Full GC是用来说明这次垃圾回收的停顿类型,代表STW类型的GC,并不特指老年代GC),根据GC日志可知本次Full GC耗时1.23s。这个在线服务同样要求低时延高可用。本次优化目标是降低单次STW回收停顿时间,提高可用性。

优化:

首先,什么时候可能会触发STW的Full GC呢?

  1. Perm空间不足;
  2. CMS GC时出现promotion failed和concurrent mode failure(concurrent mode failure发生的原因一般是CMS正在进行,但是由于老年代空间不足,需要尽快回收老年代里面的不再被使用的对象,这时停止所有的线程,同时终止CMS,直接进行Serial Old GC);
  3. 统计得到的Young GC晋升到老年代的平均大小大于老年代的剩余空间;
  4. 主动触发Full GC(执行jmap -histo:live [pid])来避免碎片问题。

然后,我们来逐一分析一下: - 排除原因2:如果是原因2中两种情况,日志中会有特殊标识,目前没有。 - 排除原因3:根据GC日志,当时老年代使用量仅为20%,也不存在大于2G的大对象产生。 - 排除原因4:因为当时没有相关命令执行。 - 锁定原因1:根据日志发现Full GC后,Perm区变大了,推断是由于永久代空间不足容量扩展导致的。

找到原因后解决方法有两种:

  1. 通过把-XX:PermSize参数和-XX:MaxPermSize设置成一样,强制虚拟机在启动的时候就把永久代的容量固定下来,避免运行时自动扩容。
  2. CMS默认情况下不会回收Perm区,通过参数CMSPermGenSweepingEnabled、CMSClassUnloadingEnabled ,可以让CMS在Perm区容量不足时对其回收。

由于该服务没有生成大量动态类,回收Perm区收益不大,所以我们采用方案1,启动时将Perm区大小固定,避免进行动态扩容。

优化结果

调整参数后,服务不再有Perm区扩容导致的STW GC发生。

小结

对于性能要求很高的服务,建议将MaxPermSize和MinPermSize设置成一致(JDK8开始,Perm区完全消失,转而使用元空间。而元空间是直接存在内存中,不在JVM中),Xms和Xmx也设置为相同,这样可以减少内存自动扩容和收缩带来的性能损失。虚拟机启动的时候就会把参数中所设定的内存全部化为私有,即使扩容前有一部分内存不会被用户代码用到,这部分内存在虚拟机中被标识为虚拟内存,也不会交给其他进程使用。

你可能感兴趣的:(JVM)