2016年03月27日 Java Basic 浏览(27)

Java GC 专家系列3:GC调优实践

本篇是”GC专家系列“的第三篇。在第一篇理解Java垃圾回收中我们学习了几种不同的GC算法的处理过程,GC的工作方式,新生代与老年代的区别。所以,你应该已经了解了JDK 7中的5种GC类型,以及每种GC对性能的影响。

在第二篇Java垃圾回收的监控中介绍了在真实场景中JVM是如何运行GC,如何监控GC数据以及有哪些工具可用来方便进行GC监控。

在本篇中,我将基于真实的案例来介绍一些GC调优的最佳选项。写本篇文章时,我假设你已经理解了前两篇的内容。为了深入理解本部分内容,你最好先浏览一下前两篇的内容——如果你尚未了解的话。

GC调优是必须的吗

更精确的说, 基于Java的服务是否一定需要GC调优 ?应该说,GC调优并非所有Java服务都必须做的事情。当然这是基于你已经使用了下面的选项或事实:

  • 通过 -Xms 和 -Xmx 选项指定了内存大小

  • 使用了 -server 选项

  • 系统未产生太多超时日志

也就是说,如果你未设置内存大小并且你的系统产生了过多的超时日志,恭喜你需要为你的系统执行GC调优。

但是,请记住: GC调优是不得已时的选择

思考一下GC调优的深层原因。垃圾回收器会去清理Java中创建的对象。GC需要清理的对象数据以及GC执行的次数取决于应用创建对象的多少。因此,为了控制GC的执行,首先你需要 减少对象的创建

俗话说“积重难返”。所以我们需要从小处着手,否则它们将不断壮大直到难以管理。

  • 应该多使用 StringBuilder 和 StringBuffer 对象替代 String 。

  • 减少不必要的日志输出。

即便如此,面对有些场景我们依然无能为力。我们知道解析XML和JSON会占用大量的内存空间。即便我们尽可能少的使用 String ,尽可能好的优化日志输出,然而在解析XML和JSON时仍然会有大量的内存开销,甚至有10~100MB之多,可我们很难杜绝XML和JSON的使用。但是请记住:XML和JSON会带来很大的内存开销。

如果应用的内存占用不断提升,你就要开始对其进行GC调优了。我把GC调优的目标分为以下两类:

  • 降低移动到老年代的对象数量

  • 缩短Full GC的执行时间

降低移动到老年代的对象数量

在Oracle JVM中除了JDK 7及最高版本中引入的G1 GC外,其他的GC都是基于分代回收的。也就是对象会在Eden区中创建,然后不断在Survivor中来回移动。之后如果该对象依然存活,就会被移到老年代中。有些对象,因为占用空间太大以致于在Eden区中创建后就直接移动到了老年代。老年代的GC较新生代会耗时更长,因此减少移动到老年代的对象数量可以降低full GC的频率。减少对象转移到老年代可能会被误解为把对象保留在新生代,然而这是不可能的,相反你可以 调整新生代的空间大小

缩短Full GC耗时

Full GC的单次执行与Minor GC相比,耗时有较明显的增加。如果执行Full GC占用太长时间(例如超过1秒),在对外服务的连接中就可能会出现超时。

  • 如果企图通过缩小老年代空间的方式来降低Full GC执行时间,可能会面临 OutOfMemoryError 或者带来更频繁的Full GC。

  • 如果通过增加老年代空间来减少Full GC执行次数,单次Full GC耗时将会增加。

因此,需要 为老年代空间设置适当的大小

影响GC性能的选项

在理解Java垃圾回收的结尾,我说过不要有这样的想法: 别人通过某个GC选项获得了明显的性能提升,为什么我不直接用这个选项呢 。因为 不同的服务所拥有的对象数量和对象的生命周期是不同的

一个简单场景,如果执行一个任务需要五个条件:A, B, C, D和E,另外一个任务只需要两个条件A和B,哪个任务会快一些?通常只需要条件A和B的任务会快一些。

Java GC选项的设置也是一样的道理。设置很多选项未必能提高GC执行速度,相反还可能会更加耗时。 GC调优的基本规则是对两台或更多的服务器设置不同的选项,并对比性能表现 ,然后把被证明能提升性能的选项添加到应用服务器上。请记住这一点。

下表列出了与内存相关的且会影响性能的GC选项:

表1: GC调优需要关注的选项

分类 选项 说明
堆空间 -Xms 启动JVM时的初始堆空间大小

-Xmx 堆空间最大值
新生代空间 -XX:NewRatio 新生代与老年代的比例

-XX:NewSize 新生代大小

-XX:SurvivorRatio Eden区与Survivor区的比例

我经常会使用的选项是: -Xms , -Xmx 和 -XX:NewRatio ,其中 -Xms 和 -Xmx 是必须的。而如何设置 -XX:NewRatio 对性能会有显著的影响。

可能有人会问 如何设置永久代(Perm)的大小 , 可以使用 -XX:PermSize 和 -XX:MaxPermSize 进行设置,但记住只有发生由Perm空间不足导致的 OutOfMemoryError 时才需要设置。

另外一个会影响GC性能的选项是GC类型,下表列出了JDK 6.0中能使用的相关设置选项:

表2: 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 在JDK6中使用G1时,这两个选项必须同时设置

除了G1,其他GC类型都是通过每个选行列的第一行选项进行设置。通常最不会使用的是Serial GC,它是为client应用优化和设计的。

还有很多其他影响GC性能的选项,但不如上面这些对性能的影响明显。另外设置更多选项未必能优化GC的执行时间。

GC调优过程

GC调优过程与一般的性能改进流程很相似,下面会介绍我在GC调优过程中的流程。

1. 监控GC状态

首先需要监控GC状态信息以明确在GC操作过程中对系统的影响。具体方式可以回顾上一篇文章:Java 垃圾回收的监控。

2. 分析监控数据并决定是否需要GC调优

然后通过GC操作状态,对监控结果进行分析,并判断是否有必要进行GC调优。如果分析结果显示GC耗时在0.1-0.3秒以内的话,一般不需要花费额外的时间做GC调优。然而, 如果GC耗时达到1-3秒甚至10秒以上,就需要立即对系统进行GC调优 。

但是如果你的应用分配了10GB的内存,且不能降低内存容量的话,其实是没办法进行GC调优的。这种情况下,你首先要去思考为什么需要分配这么大的内存。如果只给应用分配了1GB或者2GB内存,当有 OutOfMemeoryError 发生时,你需要通过堆dump来分析验证内存溢出的原因并进行修复。

注释:堆dump是把内存情况按一定格式输出到文件,可用于检查Java 内存中的对象和数据情况。可使用JDK中内置的 jmap 命令创建堆dump文件。创建文件过程中,Java进程会中断,因此不要在正常运行时系统上做此操作。

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

如果决定做GC调优,就需要考虑如何选择GC类型、如何设置内存大小。如果你有多台服务器,可通过为每台服务器设置不同的GC选项并对比不同的表现,这一步很重要。

4. 分析GC调优结果

设置GC选项后,至少要收集24小时的GC表现数据,然后就可以着手分析这些数据了。如果足够幸运,通过分析就刚好找到了最合适的GC选项。否则就需要分析GC日志,并分析内存的分配情况。然后通过不同的调整GC类型和内存大小来找到系统的最优选项。

5. 如果结果可接受,则对所有服务应用调优选项并停止调优

如果GC结果令人满意,就可以把相应的选项应用到所有服务器并停止GC调优。

下面的章节会详细介绍每个步骤中的详细过程。

监控GC状态并分析GC结果

监控Web应用(WAS: Web Application Server)GC运行状态的最好方式是使用 jstat 命令。在Java 垃圾回收的监控部分已经介绍了如何使用jstat命令,所以这里就直接介绍怎么样来校验结果数据。

下面的例子中列出了JVM未做GC调优时的数据:

$ 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和YGCT,YGCT 除以 YGC算出平均单次YGC耗时为0.05秒。也就是说在新生代执行一次垃圾回收的平均耗时为50毫秒。通过这份结果,我们可以无须关注新生代的垃圾回收。

然后再看一下FGCT和FGC,FGCT除以FGC算出平均单次FGC耗时为19.68秒。也就是平均需要消耗19.68秒来执行一次Full GC。上面的结果(共3次Full GC)可能是每次Full GC都耗时19.68秒,也有可能是其中两次都只耗时1秒,而另外一次却消耗了58秒。然而不管哪种情况,都迫切需要进行GC调优。

当然也可以通过 jstat 来校验结果,不过分析GC的最好方式是使用 -verbosegc 选项来启动JVM。在前面的文章中我已经详细介绍了生成日志的方式以及如何进行分析。就分析 -verbosegc 日志而言, HPJMeter 是我最偏爱的工具,因为它简单易用。使用HPJMeter可以轻松获取GC执行时间的开销以及GC发生的频率。

如果GC执行时间满足以下判断条件,那么GC调优并没那么必须。

  • Minor GC执行迅速(50毫秒以内)

  • Minor GC执行不频繁(间隔10秒左右一次)

  • Full GC执行迅速(1秒以内)

  • Full GC执行不频繁(间隔10分钟左右一次)

括号内的值并非绝对,依据应用的服务状态会有不同。有些服务可能要求Full GC处理速度不能超过0.9秒,另外一些服务可能会宽松些。因此校验GC结果并根据具体的服务需要,决定是否要进行GC调优。

在校验GC状态时,不要只关心Minor GC和Full GC的耗时,也要 GC执行次数也同样重要 。如果新生代太小,Minor GC就会频繁执行(甚至每间隔1秒就要执行一次)。另外,新生代太小导致转移到老年代的对象增多,也会引起Full GC的频繁执行。因此使用`-gccapacity`配合jstat命令,以检查内存空间的使用情况。

设置GC类型和内存大小

设置GC类型

Oracle JVM提供了5种GC类型,如果是低于JDK 7的版本,可以使用Parallel GC, Parallel Compacting GC, CMS GC。当然,到底选哪一个并没有统一的准则或标准。

所以 如何选择合适的GC类型 ?推荐方案是将这三种GC都应用到应用中进行对比。不过可以明确的是CMS GC肯定比Parallel GCs更快,即然这样只使用CMS GC便好。然而CMS GC也有出问题的时候,通常Full GC中使用CMS GC会执行更快,如果CMS GC的并发模式失败,则会出现比Parallel GCs慢的情况。

并发模式失败

我们来深入看一下并发模式失败的场景。

Parallel GC与CMS GC最大的区别在于压缩任务。压缩任务通过压缩内存使用来移除内存中的碎片空间,以清理两块已分配使用的内存空间中的间隙。

在Parallel GC中,只要执行Full GC便会进行内存压缩,因此耗时更长。不过Full GC之后,因为压缩的原故,可以分配连续的空间,所以内存的分配速度为更快一些。

与之相反,CMS GC的执行中并不会伴随内存压缩,因此GC速度会更快一些。然而,因此未做内存压缩, GC清理过程中释放的内存便会成为空闲空间。因为空间不连续,可能会导致在创建大对象时空间不足。例如,如果老年代尚有300M空闲,却不能为10MB的对象分配足够的连续空间。这时便会发生 并发模式失败 的警告,并触发内存压缩。如果使用CMS GC,在内存压缩过程中可能会比Parallel GCs更为耗时,也可能会带来其他问题。关于”并发模式失败”更详细的介绍可以看Oracle 工程师的文章: 理解CMS GC 日志 。

结论就是,要为你的系统寻找合适的GC类型。

每个系统都有一个最适当的GC类型,所以你需要找到这个GC类型。如果你有6台服务器,建议你为每两组设置相同的选项,并通过 -verbosegc 选项对结果进行分析和比较。

调整内存大小

下面先列出内存大小与GC执行次数、每次GC耗时之间的关系:

  • 大内存

    • 会降低GC执行次数

    • 相应的会增加GC执行耗时

  • 小内存

    • 会缩知单次GC耗时

    • 相应的会增加GC执行次数

当然,关于使用大内存还是小内存并没有唯一正确的答案。如果服务器资源足够且Full GC执行耗时能控制在1秒以内,使用10GB的内存也是可以的。但大多数时候如果设置内存为10GB,GC执行效果并不尽人意,执行一次Full GC可能要消耗10~30秒(具体时长也会根据对象大小情况而不同)。

既然如此, 如何正确设置内存大小 。通常情况下,我会推荐500MB大小。这不是说你要把自己的WAS(Web Application Server)内存选项设置为 -Xms500 和 -Xmx500m 。基于当前未调优时的场景,检查Full GC之后内存大小变化。如果Full GC之后尚有300MB空间剩余,这样最好把内存设置到1GB(300MB(默认使用) + 500MB(老年代最小容量) + 200MB(空闲空间))。这意味着你应该才老年代至少设置500MB空间。如果你有3台服务器,可以分别设置1GB、1.5GB和2GB,并检查每台机器的执行结果。

理论上,根据内存大小不同单次执行GC速度应该是1GB > 1.5GB > 2GB,所以1GB的内存会中三个之中GC速度最快的。但并不能保证1GB的内存Full GC耗时1秒,2GB的内存Full GC耗时2秒。实际耗时与机器性能和对象大小也有关系。所以最好的度量方式是设置每种可能性并分析他们的监控结果。

有设置内存大小时,还需要设置另外一选项: NewRatio 。 NewRatio 是新生代与老年代的比值的倒数(即老年代与新生代的比值)。如果 XX:NewRatio=1 ,就是说新生代 : 老年代的比值为1:1。对于1GB内存,就是新生代与老年代各500MB。如果 NewRatio 的值是2,则是新生代 : 老年代的值为1:2。因此比值设置的越大,老年代的空间就越大,相应的新生代空间会越小。

设置 NewRatio 也不是一件重要的事,但可能会对整个GC性能带来严重影响。如果新生代太小,对象就会转移到老年代,引起频繁的Full GC,导致更多的耗时。

你可能简单的认为设置 NewRatio=1 会带来最佳的效果,然而并非如此。把 NewRatio 设置为2或3更容易带来好的GC表现。当然我也实际遇到过一些这样的例子。

完成GC调优的最快途径是什么?通过对比性能测试的结果是得到GC调优结果的最快途径。通过为每个服务器设置不同的选项并观察GC状态,最好能观察1到2天的数据。如果是通过性能测试来做GC调优的话,要为每个服务器准备相同的负载和业务操作。请求比例的分配也要与业务条件相一致。然而即便是专业的性能测试人员,准备精确的负载数据也并非易事,通常需要花费很大精力来做准备。所以更简捷的GC调优方式就是对业务应用准备GC选项,然后通过等待GC结果并进行分析,尽管可能需要更长的等待时间。

分析GC调优结果

在应用GC选项并设置 -verbosegc 后,可以通过 tail 命令检查日志是否按期望的方式正常输出。如果选项未精确的设置或者没有按期望输出,你所花费的时间都将白费。如果日志输出与期望相符,等待1到2天的运行后便可检查和分析结果。最简单的方式是把日志文件复制到本地PC,并使用 HPJMeter 进行分析。

分析过程中主要关注以下数据,下面列表是按我自己定义的优先级列出的。其中决定GC选项的最重要的数据是Full GC执行时间。

  • Full GC(平均)耗时

  • Minor GC(平均)耗时

  • Full GC执行间隔

  • MinorGC执行间隔

  • Full GC整体耗时

  • Minor GC整体耗时

  • GC整体耗时

  • Full GC执行次数

  • Minor GC执行次数

如果足够幸运,你能恰好找到合适的GC选项,通常你并没这么幸运。执行GC调优时一定要格外小心,因为如果你试图一次就完成GC调优,得到的可能会是 OutOfMemoryError 。

调优案例

上面我们对于GC调优的讨论还仅是纸上谈兵,现在开始我们看一些具体的GC调优的案例。

案例1

这个例子是为服务S进行的GC优化。对于这个新上线的服务S,在执行Full GC时有些过于耗时。

先看一下 jstat -gcutil 的结果:

S0 S1 E O P YGC YGCT FGC FGCT GCT
12.16 0.00 5.18 63.78 20.32 54 2.047 5 6.946 8.993

在开始进行调优时不用太关心 持久代 空间的设置,相对而言YGC的数值更值得关注。

从上面的结果中我们可算出执行Minor GC和Full GC的平均时间上的开销,如下表:

表3:服务S执行Minor GC和Full GC的平均耗时

GC类型 GC 执行次数 GC执行时间 平均耗时
Minor GC 54 2.047 37 ms
Full GC 5 6.946 1389 ms

对于Minor GC来说, 37 ms 还不算坏,而Full GC的平均耗时 1.389 s 对于系统来说在执行Full GC时可能会导致频繁的超时现象,例如DB超时设置为1 s的话就会发生超时。所以这个案例中的系统需要进行GC调优。

首先在开始GC调优之前先检查当前的内存设置。可以使用 jstat -gccapacity 选项查看内存的使用情况。下面是服务S的检查结果:

NGCMN NGCMX NGC S0C S1C EC OGCMN OGCMX OGC OC PGCMN PGCMX PGC PC YGC FGC
212992.0 212992.0 212992.0 21248.0 21248.0 170496.0 1884160.0 1884160.0 1884160.0 1884160.0 262144.0 262144.0 262144.0 262144.0 54 5

其中关键的数据如下:

  • 新生代使用:212, 992 KB(约208 MB)

  • 老年代使用:1,884,160 KB(约1.8 GB)

所以除去持久代之外的内存分配为2 GB,且新生代 : 老年代为 1:9 (即 NewRatio=9 )。为了看到更详细的信息,对系统的三个不同实现均设置了 -verbosegc 并分别设置了 NewRatio 选项,除此之外未添加其他选项。

  • NewRatio = 2

  • NewRatio = 3

  • NewRatio = 4

一天之后检查GC时日志时幸运的发生,在设置 NewRatio 之后尚未有Full GC发生。

发生了什么?因为大多数对象在创建之后不久就被销毁,所以新生代里的对象在移到老年代之前就被销毁掉了。

既然如此,就没必要再设置其他选项,只是选择好最佳的 NewRatio 即可。 如何选取最佳NewRatio ?只能逐个分析设置不同 NewRatio 值时的Minor GC的平均耗时。

上面三个 NewRatio 设置对应的Minor GC平均耗时如下:

  • NewRatio=2: 45ms

  • NewRatio=3: 34ms

  • NewRatio=4: 30ms

因为 NewRatio=4 时Minor GC具有最小的耗时,所以就是我们选择的最佳设置,即便此时新生代的空间相对较小。应用此选项后,服务再也没有Full GC发生。

下面是系统重新设置过选项后,某天通过 jstat -gcutil 查看到的结果:

S0 S1 E O P YGC YGCT FGC FGCT GCT
8.61 0.00 30.67 24.62 22.38 2424 30.219 0 0.000 30.219

你可能认为因为系统接收的请求太少以致于GC发生频率较低,然而在Minor GC执行了2,424次的情况下系统未发生Full GC。

案例2

下面介绍的是服务A的例子。我们在公司的应用性能管理平台(APM: Application Performance Manager)上发现服务A的JVM周期性的出现长时间的停顿(超过8秒未有响应)的现象。所以我们决定对其进行GC调优。经过排查我们发现此系统在执行Full GC时太过耗时,需要进行优化。

在着手优化之前,我们为系统加上了 -verbosegc 选项,输出结果如下图:

Java GC 专家系列3:GC调优实践-图片-1

图1:GC调优之前的GC耗时

上图是HPJMeter自动分析结果后提供的系统GC随着JVM运行的耗时图。 X-轴 是JVM从启动后的运行时间轴, Y-轴 是每次GC的响应时间。其中绿色的是Full GC使用的CMS垃圾回收的耗时,蓝色的是Minor GC使用的Parallel Scavenge垃圾回收的耗时。

前面我说过CMS GC是最快的,但上图可看到有场景耗时竟达到15秒之多。 什么原因导致这种后果? 回想一下我前面说过的:当内存压缩时CMS将会变慢。另外服务A设置了 -Xms1g 和 -Xmx4g 的选项,操作系统为其分配的内存为4 GB。

然后我把GC类型由GMS换成了Parallel GC,并把内存大小设置为2G, NewRatio 设置为3。一段时间之后通过 jstat -gcutil 查看到的结果如下:

S0 S1 E O P YGC YGCT FGC FGCT GCT
0.00 30.48 3.31 26.54 37.01 226 11.131 4 11.758 22.890

Full GC的速度提升了,与4GB内存时的15秒相比,现在平均每次只需要3秒。但3秒仍然不尽人意,所以我设计了以下六组选项:

  • -XX:+UseParallelGC -Xms1536m -Xmx1536m -XX:NewRatio=2

  • -XX:+UseParallelGC -Xms1536m -Xmx1536m -XX:NewRatio=3

  • -XX:+UseParallelGC -Xms1g -Xmx1g -XX:NewRatio=3

  • -XX:+UseParallelOldGC -Xms1536m -Xmx1536m -XX:NewRatio=2

  • -XX:+UseParallelOldGC -Xms1536m -Xmx1536m -XX:NewRatio=3

  • -XX:+UseParallelOldGC -Xms1g -Xmx1g -XX:NewRatio=3

哪一个会更快呢?结果显示内存越小,速度越快。下图是第六组选项的GC持续时长分布图,代表了最优的GC性能提升。图中看到最慢的为1.7秒,而平均值降低到1秒以内。

Java GC 专家系列3:GC调优实践-图片-2

图2:使用第六组选项后的GC耗时

因此我把服务A的GC选项调整为了第六组中的设置,然而每天夜里却连续发生了 OutOfMemoryError 。个中艰辛不再细说,简而言之就是批量的数据处理任务导致了JVM内存泄露。到此为止,所有的问题都明了了。

如果只对GC日志做短时间的观察例把GC调优的结果应用到所有服务器上是一件非常危险的事情。一定要记住,如果GC调优能够顺利执行而无故障只有一条途径:像分析GC日志一样分析系统的每一个服务操作。

上面通过两个GC调优的案例演示了GC调优的具体处理过程。如我所述,案例中的GC选项可以不做调整的应用到那些具有相同CPU、操作系统和 JDK 版本以及执行相同功能的服务上去。然而不要把这些选项应用到你的系统上,因为他们未必适用。

总结

我执行GC调优一般基于经验而无需通过堆dump后对内存进行详细的分析,尽管精确的内存状态可能会带来更好的GC调优结果。在一般情景,如果内存负载较低时,通过分析内存对象可能效果更好,不过如果服务负载较高,内存空间使用较多时,更推荐基于经验来做GC调优。

我曾经在一些服务上对G1 GC做过性能测试,不过还没有全面使用。结果证明G1 GC执行速度比其他任何GC都要快,不过需要把JDK升级到 JDK 7 才能享受到G1带来的性能提升,另外G1的稳定性目前尚不能完全保证,没有人知道是否会带来严重的bug。所以大范围使用 G1 还尚待时日。

当 JDK 7 稳定以后(并不是说它当前不稳定),并且WAS针对JDK 7做过优化之后,G1也许会稳定的运行在服务器上,到那时也许就不再需要进行GC调优了。

更多GC调优的细节可以在 Slideshare 上搜索相关材料。我最推荐的是Twitter 工程师 Attila Szegedi写的这篇 我在Twitter学到的关于JVM调优的一切 ,有时间可以学习一下。

作者:Sangmin Lee, 性能实验室高级工程师,NHN公司



用户头像