JVM-优化

案例

本文介绍了一次生产环境的JVM GC相关参数的调优过程,通过参数的调整避免了GC卡顿对JAVA服务成功率的影响。

背景以及遇到的问题

我们的 Java HTTP 服务属于OLTP类型,对成功率和响应时间的要求比较高,

在生产环境中出现偶现的成功率突然下降然后又自动恢复的情况,如图所示:

JVM 和 GC 相关的参数如下:

-Xmx22528m
-Xms22528m
-XX:NewRatio=2
-XX:+UseConcMarkSweepGC
-XX:+UseParNewGC
-XX:+CMSParallelRemarkEnabled

总结来说,由于服务中大量使用了 Cache,所以堆大小开到了22G。

GC 算法使用 CMS(UseConcMarkSweepGC),开启了降低标记停顿(CMSParallelRemarkEnabled),设置年轻代为并行收集(UseParNewGC),年轻代和老年代的比例为1:2 (NewRatio=2).

JVM GC日志相关的参数如下:

-Xloggc:/data/gc.log
-XX:GCLogFileSize=10M
-XX:NumberOfGCLogFiles=10
-XX:+UseGCLogFileRotation
-XX:+PrintGCDateStamps
-XX:+PrintGCTimeStamps
-XX:+PrintGCDetails
-XX:+DisableExplicitGC
-verbose:gc

问题解决过程

排除应用程序的内存使用问题

首先使用jmap查看内存使用情况:

jmap -histo:live PID

这个命令把程序中当前的对象按照个数和占用的空间排序以后打印出来。

这里没有发现使用异常的对象。

排除Cache内容过多的问题

如果Cache内容过多也会导致JVM老年代容易被用满导致频繁GC,

因此调出GC日志进行查看,发现每次GC以后内存使用一般是从20G降低到5G左右,

因此常驻内存的Cache不是导致GC长时间卡顿的根本原因。

对于GC LOG的查看有多种方式,使用VisualVM比较直观,需要使用VisualGC:

从图中我们可以看到伊甸园和老年代的空间分配,由于整体内存是20G,

设置 -XX:NewRatio=2 因此老年代是14G,伊甸园+S0+S1=7G

调整GC时间点(成功率抖动问题加重)

如果GC需要处理的内存量比较大,执行的时间也就比较长,

STW (Stop the World)时间也就更长。

按照这个思路调整CMS启动的时间点,希望提早GC,也就是让GC变得更加频繁但是期望每次执行的时间较少。

添加了下面这两个参数:

-XX:+UseCMSInitiatingOccupancyOnly
-XX:CMSInitiatingOccupancyFraction=50

意思是说在Old区使用了50%的时候触发GC。

实验后发现GC的频率有所增加,但是每次GC造成的成功率降低现象并没有减弱,因此弃用这两个参数。

调整对象在年轻代内存中驻留的时间(效果不明显)

如果能够降低老年代GC的频率也可以达到降低GC影响的目的,

因此尝试让对象在年轻代内存中进行更长时间的驻留,提升这些对象在年轻代GC时候被销毁的概率。

使用参数-XX:MaxTenuringThreshold=31调整以后收效不明显。

MS-Remark之前强制进行年轻代的GC

首先补充一下CMS的相关知识,在CMS整个过程中有两个步骤是STW的,如图红色部分:

CMS并非没有暂停,而是用两次短暂停来替代串行标记整理算法的长暂停,它的收集周期是这样:

  • 1、初始标记(CMS-initial-mark),从root对象开始标记存活的对象
  • 2、并发标记(CMS-concurrent-mark)
  • 3、重新标记(CMS-remark),暂停所有应用程序线程,重新标记并发标记阶段遗漏的对象(在并发标记阶段结束后对象状态的更新导致)
  • 4、并发清除(CMS-concurrent-sweep)
  • 5、并发重设状态等待下次CMS的触发(CMS-concurrent-reset)。

通过GC日志和成功率下降的时间点进行比对

发现并不是每一次老年代GC都会导致成功率的下降,但是从中发现了一个规律:

前两次GC CMS-Remark过程在4s左右造成了成功率的下降,

但是第三次GC并没有对成功率造成明显的影响,CMS-Remark只有0.18s。

Java HTTP 服务是通过Nginx进行反向代理的,Nginx设置的超时时间是3s,所以如果GC卡顿在3s以内就不会对成功率造成太大的影响。

从GC日志中又发现一个信息:

猜测是remark处理的内存量,处理的越多就越慢。

添加下面两个参数强制在remark阶段和FULL GC阶段之前先在进行一次年轻代的GC,这样需要进行处理的内存量就不会太多。

-XX:+ScavengeBeforeFullGC 
-XX:+CMSScavengeBeforeRemark

调优以后效果很明显,下面是两台配置完全相同的服务器在同一时间段的成功率和响应时间监控图,

第一个没有添加强制年轻代GC的参数。

结论

1、在CMS-remark阶段需要对堆中所有的内存对象进行处理,

​ 如果在这个阶段之前强制执行一次年轻代的GC会大量减少remark需要处理的内存数量,进而降低JVM卡顿对成功率的影响。

2、对于Java HTTP服务,JVM的卡顿时间应该小于HTTP客户端的调用超时时间,否则JVM卡顿会对成功率造成影响。

如何优化 Java GC 「译」

本文的作者是韩国人,写在JDK 1.8发布之前,虽然有些地方有些许过时,但整体内容还是非常有价值的。

GC优化是必要的吗?

或者更准确地说,GC优化对Java基础服务来说是必要的吗?

答案是否定的,事实上GC优化对Java基础服务来说在有些场合是可以省去的,但前提是这些正在运行的Java系统,必须包含以下参数或行为:

  • 内存大小已经通过-Xms-Xmx参数指定过
  • 运行在server模式下(使用-server参数)
  • 系统中没有残留超时日志之类的错误日志

换句话说,如果你在运行时没有手动设置内存大小并且打印出了过多的超时日志,那你就需要对系统进行GC优化。

不过你需要时刻谨记一句话:GC tuning is the last task to be done.

现在来想一想GC优化的最根本原因,垃圾收集器的工作就是清除Java创建的对象,

垃圾收集器需要清理的对象数量以及要执行的GC数量均取决于已创建的对象数量。

因此,为了使你的系统在GC上表现良好,首先需要减少创建对象的数量。

俗话说“冰冻三尺非一日之寒”,我们在编码时要首先要把下面这些小细节做好,否则一些琐碎的不良代码累积起来将让GC的工作变得繁重而难于管理:

  • 使用StringBuilderStringBuffer来代替String
  • 尽量少输出日志

尽管如此,仍然会有我们束手无策的情况。

XML 和 JSON 解析过程往往占用了最多的内存,即使我们已经尽可能地少用String、少输出日志,

仍然会有大量的临时内存(大约10-100MB)被用来解析 XML 或 JSON 文件,但我们又很难弃用XML和JSON。

在此,你只需要知道这一过程会占据大量内存即可。

如果在经过几次重复的优化后应用程序的内存用量情况有所改善,那么就可以启动GC优化了。

笔者总结了GC优化的两个目的:

  1. 将进入老年代的对象数量降到最低
  2. 减少Full GC的执行时间

将进入老年代的对象数量降到最低

除了可以在JDK 7及更高版本中使用的G1收集器以外,其他分代GC都是由Oracle JVM提供的。

关于分代GC,就是对象在Eden区被创建,随后被转移到Survivor区,在此之后剩余的对象会被转入老年代。

也有一些对象由于占用内存过大,在Eden区被创建后会直接被传入老年代。

老年代GC相对来说会比新生代GC更耗时,因此,减少进入老年代的对象数量可以显著降低Full GC的频率。

你可能会以为减少进入老年代的对象数量意味着把它们留在新生代,事实正好相反,新生代内存的大小是可以调节的。

降低Full GC的时间

Full GC的执行时间比Minor GC要长很多,因此,如果在Full GC上花费过多的时间(超过1s),将可能出现超时错误。

  • 如果通过减小老年代内存来减少Full GC时间,可能会引起OutOfMemoryError或者导致Full GC的频率升高。
  • 另外,如果通过增加老年代内存来降低Full GC的频率,Full GC的时间可能因此增加。

因此,你需要把老年代的大小设置成一个“合适”的值

影响GC性能的参数

Java GC参数设置好几个参数并不会提升GC执行的速度,反而会使它变得更慢。

GC优化的基本原则是将不同的GC参数应用到两个及以上的服务器上然后比较它们的性能,然后将那些被证明可以提高性能或减少GC执行时间的参数应用于最终的工作服务器上。

下面这张表展示了与内存大小相关且会影响GC性能的GC参数

表1:GC优化需要考虑的JVM参数

类型 参数 描述
堆内存大小 -Xms 启动JVM时堆内存的大小
-Xmx 堆内存最大限制
新生代空间大小 -XX:NewRatio 新生代和老年代的内存比
-XX:NewSize 新生代内存大小
-XX:SurvivorRatio Eden区和Survivor区的内存比

在进行GC优化时最常用的参数是-Xms,-Xmx-XX:NewRatio

-Xms-Xmx参数通常是必须的,所以NewRatio的值将对GC性能产生重要的影响。

有些人可能会问如何设置永久代内存大小,你可以用-XX:PermSize-XX:MaxPermSize参数来进行设置,但是要记住,只有当出现OutOfMemoryError错误时你才需要去设置永久代内存。

还有一个会影响GC性能的因素是垃圾收集器的类型,下表展示了关于GC类型的可选参数(基于JDK 6.0):

表2:GC类型可选参数

GC类型 参数 备注
Serial GC -XX:+UseSerialGC
Parallel GC -XX:+UseParallelGC -XX:ParallelGCThreads=value
Parallel Compacting GC -XX:+UseParallelOldGC
CMS GC -XX:+UseConcMarkSweepGC -XX:+UseParNewGC -XX:+CMSParallelRemarkEnabled -XX:CMSInitiatingOccupancyFraction=value -XX:+UseCMSInitiatingOccupancyOnly
G1 -XX:+UnlockExperimentalVMOptions -XX:+UseG1GC 在JDK 6中这两个参数必须配合使用

除了G1收集器外,可以通过设置上表中每种类型第一行的参数来切换GC类型,最常见的非侵入式GC就是Serial GC,它针对客户端系统进行了特别的优化。

会影响GC性能的参数还有很多,但是上述的参数会带来最显著的效果,请切记,设置太多的参数并不一定会提升GC的性能。

GC优化的过程

GC优化的过程和大多数常见的提升性能的过程相似,下面是笔者使用的流程:

1.监控GC状态

你需要监控GC从而检查系统中运行的GC的各种状态,具体方法请查看系列的第二篇文章《如何监控Java GC》

2.分析监控结果后决定是否需要优化GC

在检查GC状态后,你需要分析监控结构并决定是否需要进行GC优化。如果分析结果显示运行GC的时间只有0.1-0.3秒,那么就不需要把时间浪费在GC优化上,但如果运行GC的时间达到1-3秒,甚至大于10秒,那么GC优化将是很有必要的。

但是,如果你已经分配了大约10GB内存给Java,并且这些内存无法省下,那么就无法进行GC优化了。在进行GC优化之前,你需要考虑为什么你需要分配这么大的内存空间,如果你分配了1GB或2GB大小的内存并且出现了OutOfMemoryError,那你就应该执行堆转储(heap dump)来消除导致异常的原因。

注意:

堆转储(heap dump)是一个用来检查Java内存中的对象和数据的内存文件。该文件可以通过执行JDK中的jmap命令来创建。在创建文件的过程中,所有Java程序都将暂停,因此,不要再系统执行过程中创建该文件。

你可以在互联网上搜索heap dump的详细说明。对于韩国读者,可以直接参考我去年发布的书:《The story of troubleshooting for Java developers and system operators》 (Sangmin Lee, Hanbit Media, 2011, 416 pages)

3.设置GC类型/内存大小

如果你决定要进行GC优化,那么你需要选择一个GC类型并且为它设置内存大小。此时如果你有多个服务器,请如上文提到的那样,在每台机器上设置不同的GC参数并分析它们的区别。

4.分析结果

在设置完GC参数后就可以开始收集数据,请在收集至少24小时后再进行结果分析。如果你足够幸运,你可能会找到系统的最佳GC参数。如若不然,你还需要分析输出日志并检查分配的内存,然后需要通过不断调整GC类型/内存大小来找到系统的最佳参数。

5.如果结果令人满意,将参数应用到所有服务器上并结束GC优化

如果GC优化的结果令人满意,就可以将参数应用到所有服务器上,并停止GC优化。

在下面的章节中,你将会看到上述每一步所做的具体工作。

监控GC状态并分析结果

在运行中的Web应用服务器(Web Application Server,WAS)上查看GC状态的最佳方式就是使用jstat命令。

下面的例子展示了某个还没有执行GC优化的JVM的状态(虽然它并不是运行服务器)。

$ jstat -gcutil 21719 1s
S0    S1    E    O    P    YGC    YGCT    FGC    FGCT GCT
48.66 0.00 48.10 49.70 77.45 3428 172.623 3 59.050 231.673
48.66 0.00 48.10 49.70 77.45 3428 172.623 3 59.050 231.673

我们先看一下YGC(从应用程序启动到采样时发生 Young GC 的次数)和YGCT(从应用程序启动到采样时 Young GC 所用的时间(秒)),计算YGCT/YGC会得出,平均每次新生代的GC耗时50ms,这是一个很小的数字,通过这个结果可以看出,我们大可不必关注新生代GC对GC性能的影响。

现在来看一下FGC( 从应用程序启动到采样时发生 Full GC 的次数)和FGCT(从应用程序启动到采样时 Full GC 所用的时间(秒)),计算FGCT/FGC会得出,平均每次老年代的GC耗时19.68s。有可能是执行了三次Full GC,每次耗时19.68s,也有可能是有两次只花了1s,另一次花了58s。不管是哪一种情况,GC优化都是很有必要的。

使用jstat命令可以很容易地查看GC状态,但是分析GC的最佳方式是加上-verbosegc参数来生成日志。在之前的文章中笔者已经解释了如何分析这些日志。HPJMeter是笔者最喜欢的用于分析-verbosegc生成的日志的工具,它简单易用,使用HPJmeter可以很容易地查看GC执行时间以及GC发生频率。

此外,如果GC执行时间满足下列所有条件,就没有必要进行GC优化了:

  • Minor GC执行非常迅速(50ms以内)
  • Minor GC没有频繁执行(大约10s执行一次)
  • Full GC执行非常迅速(1s以内)
  • Full GC没有频繁执行(大约10min执行一次)

括号中的数字并不是绝对的,它们也随着服务的状态而变化。有些服务可能要求一次Full GC在0.9s以内,而有些则会放得更宽一些。因此,对于不同的服务,需要按照不同的标准考虑是否需要执行GC优化。

当检查GC状态时,不能只查看Minor GC和Full GC的时间,还必须要关注GC执行的次数。如果新生代空间太小,Minor GC将会非常频繁地执行(有时每秒会执行一次,甚至更多)。此外,传入老年代的对象数目会上升,从而导致Full GC的频率升高。因此,在执行jstat命令时,请使用-gccapacity参数来查看具体占用了多少空间。

设置GC类型/内存大小

设置GC类型

Oracle JVM有5种垃圾收集器,但是在JDK 7以前的版本中,你只能在Parallel GC, Parallel Compacting GC 和CMS GC之中选择,至于具体选择哪个,则没有具体的原则和规则。

既然这样的话,我们如何来选择GC呢?最好的方法是把三种都用上,但是有一点必须明确——CMS GC通常比其他并行(Parallel)GC都要快(这是因为CMS GC是并发的GC),如果确实如此,那只选择CMS GC就可以了,不过CMS GC也不总是更快,当出现concurrent mode failure时,CMS GC就会比并行GC更慢了。

Concurrent mode failure

现在让我们来深入地了解一下concurrent mode failure

并行GC和CMS GC的最大区别是并行GC采用“标记-整理”(Mark-Compact)算法而CMS GC采用“标记-清除”(Mark-Sweep)算法,compact步骤就是通过移动内存来消除内存碎片,从而消除分配的内存之间的空白区域。

对于并行GC来说,无论何时执行Full GC,都会进行compact工作,这消耗了太多的时间。不过在执行完Full GC后,下次内存分配将会变得更快(因为直接顺序分配相邻的内存)。

相反,CMS GC没有compact的过程,因此CMS GC运行的速度更快。但是也是由于没有整理内存,在进行磁盘清理之前,内存中会有很多零碎的空白区域,这也导致没有足够的空间分配给大对象。

例如,在老年代还有300MB可用空间,但是连一个10MB的对象都没有办法被顺序存储在老年代中,在这种情况下,会报出“concurrent mode failure”的warning,然后系统执行compact操作。

但是CMS GC在这种情况下执行的compact操作耗时要比并行GC高很多,并且这还会导致另一个问题,关于“concurrent mode failure”的详细说明,可用参考Oracle工程师撰写的《Understanding CMS GC Logs》

综上所述,你需要根据你的系统情况为其选择一个最适合的GC类型。

每个系统都有最适合它的GC类型等着你去寻找,如果你有6台服务器,我建议你每两个服务器设置相同的参数,然后加上-verbosegc参数再分析结果。

设置内存大小

下面展示了内存大小、GC运行次数和GC运行时间之间的关系:

大内存空间

  • 减少了GC的次数
  • 提高了GC的运行时间

小内存空间

  • 增多了GC的次数
  • 降低了GC的运行时间

关于如何设置内存的大小,没有一个标准答案,如果服务器资源充足并且Full GC能在1s内完成,把内存设为10GB也是可以的,但是大部分服务器并不处在这种状态中,当内存设为10GB时,Full GC会耗时10-30s,具体的时间自然与对象的大小有关。

既然如此,我们该如何设置内存大小呢?通常我推荐设为500MB,这不是说你要通过-Xms500m-Xmx500m参数来设置WAS内存。

根据GC优化之前的状态,如果Full GC后还剩余300MB的空间,那么把内存设为1GB是一个不错的选择(300MB(默认程序占用)+ 500MB(老年代最小空间)+200MB(空闲内存))。这意味着你需要为老年代设置至少500MB空间,因此如果你有三个运行服务器,可以把它们的内存分别设置为1GB,1.5GB,2GB,然后检查结果。

理论上来说,GC执行速度应该遵循1GB> 1.5GB> 2GB,1GB内存时GC执行速度最快。然而,理论上的1GB内存Full GC消耗1s、2GB内存Full GC消耗2 s在现实里是无法保证的,实际的运行时间还依赖于服务器的性能和对象大小。因此,最好的方法是创建尽可能多的测量数据并监控它们。

在设置内存空间大小时,你还需要设置一个参数:NewRatio

NewRatio的值是新生代和老年代空间大小的比例。

如果XX:NewRatio=1,则新生代空间:老年代空间=1:1,如果堆内存为1GB,则新生代:老年代=500MB:500MB。如果NewRatio等于2,则新生代:老年代=1:2,因此,NewRatio的值设置得越大,则老年代空间越大,新生代空间越小。

你可能会认为把NewRatio设为1会是最好的选择,然而事实并非如此,根据笔者的经验,当NewRatio设为2或3时,整个GC的状态表现得更好。

完成GC优化最快地方法是什么?

答案是比较性能测试的结果。

为了给每台服务器设置不同的参数并监控它们,最好查看的是一或两天后的数据。当通过性能测试来进行GC优化时,你需要在不同的测试时保证它们有相同的负载和运行环境。

然而,即使是专业的性能测试人员,想精确地控制负载也很困难,并且需要大量的时间准备。因此,更加方便容易的方式是直接设置参数来运行,然后等待运行的结果(即使这需要消耗更多的时间)。

分析GC优化的结果

在设置了GC参数和-verbosegc参数后,可以使用tail命令确保日志被正确地生成。

如果参数设置得不正确或日志未生成,那你的时间就被白白浪费了。如果日志收集没有问题的话,在收集一或两天数据后再检查结果。最简单的方法是把日志从服务器移到你的本地PC上,然后用HPJMeter分析数据。

在分析结果时,请关注下列几点(这个优先级是笔者根据自己的经验拟定的,我认为选取GC参数时应考虑的最重要的因素是Full GC的运行时间。):

  • 单次Full GC运行时间
  • 单次Minor GC运行时间
  • Full GC运行间隔
  • Minor GC运行间隔
  • 整个Full GC的时间
  • 整个Minor GC的运行时间
  • 整个GC的运行时间
  • Full GC的执行次数
  • Minor GC的执行次数

找到最佳的GC参数是件非常幸运的,然而在大多数时候,我们并不会如此幸运,在进行GC优化时一定要小心谨慎,因为当你试图一次完成所有的优化工作时,可能会出现OutOfMemoryError错误。

posted @ 2021-08-30 16:16  Ricardo_ML  阅读(117)  评论(0编辑  收藏  举报