成为Java GC专家系列(3) ——如何优化Java垃圾回收

转载自:http://shellblog.sinaapp.com/?p=733

本文是成为Java GC专家系列文章的第三篇。在第一篇《成为Java GC专家系列(1) ——Java垃圾回收机制》中我们学习了不同GC算法的执行过程,GC是如何工作的,什么是年轻代和年老代,你应该了解的JDK7中的5种GC类型,以及这5种类型对于应用性能的影响。

在第二篇《成为Java GC专家系列(2) ——监控Java垃圾回收》,我解释了JVM实际上是如何执行垃圾回收的,我们如何监控GC,以及那哪些具可以让我们的工作更快,更高效。在第三篇文章中,我们会基于实际的例子来解释一些优化GC的最佳实践。我认为在阅读本篇文章之前,你已经很好地理解了之前的文章,因此,为了你能够更好地学习本文,如果你还没有读过之前的两篇文章,请先阅读。

为什么需要优化GC

或者说的更确切一些,对于基于Java的服务,是否有必要优化GC?应该说,对于所有的基于Java的服务,并不总是需要进行GC优化,但前提是所运行的基于Java的系统,包含了如下参数或行为:

  • 已经通过 -Xms 和–Xmx 设置了内存大小
  • 包含了 -server 参数
  • 系统中没有超时日志等错误日志

换句话说,如果你没有设定内存的大小,并且系统充斥着大量的超时日志时,你就需要在你的系统中进行GC优化了。

但是,你需要时刻铭记一条:GC优化永远是最后一项任务

你应该考虑一下进行GC的最根本原因。垃圾收集器需要清除在程序中创建的对象,GC执行的次数即需要被垃圾收集器清理的对象个数,与创建对象的数量成正比,因此,首先你应该减少创建对象的数量。

俗话说的好,“冰冻三尺非一日之寒”。我们应该从小事做起,否则日积月累就会很难管理。

  • 我们需要使用StringBuilder 或者StringBuffer 来替代String
  • 应该尽量少的输出日志

但是,我们知道有些情况会让我们束手无策,我们眼睁睁的看着XML以及JSON解析占用了大量的内存。即便我们已经尽可能少的使用String以及尽量少的输出日志,但是大量的临时内存仍然被用于XML或者JSON解析,例如10-100MB。但是,舍弃XML和JSON是很难的。这个适合,我们只需要知道,他会占用很多内存。

如果应用内存使用量经过几次重复调整之后有所改善,你就可以开始GC优化了。

我觉得GC优化可以归纳了两个目的:

  1. 一个是将转移到老年代的对象数量降到最少
  2. 另一个是减少Full GC的执行时间

将转移到年老代的对象数量降到最少

分代垃圾回收策略是由Oracle JVM提供,不包括可以在JDK7以及更高版本中使用的G1 GC。换句话说,对象被创建在伊甸园空间,而后转化到幸存者空间,最终剩余的对象被送到老年代。某些比较大的对象会在被创建在伊甸园空间后,直接转移到年老代空间。年老代空间上的GC处理会年轻代花费更多的时间。因此,减少被移到年老代对象的数据可以显著地减少Full GC的频率。减少被移到年老代空间的对象的数量,可能会被误解为将对象留在新生代。但是,这是不可能的。取而代之,你可以调整新生代空间的大小

减少Full GC执行时间

Full GC的执行时间比Minor GC要长很多。因此,如果Full GC花费了太多的时间(超过1秒),一些连接的部分可能会发生超时错误。

  • 如果你试图通过减少年老代空间来减少Full GC的执行时间,可能会导致OutOfMemoryError 或者 Full GC执行的次数会增加。
  • 与之相反,如果你试图通过增加老年代空间来减少Full GC执行次数,执行时间又会增加。

因此,你需要将老年代空间设定为一个“合适”的值

影响GC性能的参数

正如我们在第二篇文章结尾提到的,不要幻想“某个人设定了GC参数后性能得到极大的提高,我们为什么不和他用一样的参数?”,因为不同的Web服务所创建对象的大小和他们的生命周期都不尽相同。

简单来说,如果一个任务的执行条件是A,B,C,D和E,同样的任务执行条件换为A和B,你会觉得哪个更快?从一般人的直觉来看,在A和B条件下执行的任务会更快。

Java GC参数也是相同的道理,设定一些参数不但没有提高GC执行速度,反而可能导致他更慢。GC优化的最基本原则是将不同的GC参数用于2台或者多台服务器,并进行对比,并将那些被证明提高了性能或者减少了GC执行时间的参数应用于服务器。请谨记这一点。

下面这个表格列出了GC参数中与内存大小相关的,可以影响性能的参数。

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

定义 参数 说明
堆内存 -Xms 启动JVM时的堆内存空间大小

-Xmx 堆内存的最大值
年轻代 -XX:NewRatio 年轻代与年老代的比值

-XX:NewSize 年轻代大小

-XX:SurvivorRatio 伊甸园空间和幸存者空间的比值

我在进行GC优化时经常使用-Xms,-Xmx和-XX:NewRatio。-Xms和-Xmx也是必须的。你如何设定NewRatio 会对GC性能产生十分显著的影响。

有些人可能会问如何设定Perm区域的大小?你可以通过-XX:PermSize 和-XX:MaxPermSize参数来设定。

另一个可能影响GC性能的参数是GC类型。下表列出了所有可选的GC类型(基于JDK6.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中这两个参数必须同时使用


影响GC性能的参数有很多,但是上面提到的参数会带来最显著的效果。请牢记,设定过多的参数不一定会减少GC执行时间。除了G1 GC,可以通过每种类型第一行的参数来切换GC类型。最常用的GC类型是Serial GC。他专门针对客户端系统进行了优化。

GC优化过程

GC优化的过程与大多数性能改善的过程及其类似。下面是我使用的GC优化过程。

1.监控GC状态

首先你需要监控GC来检查在系统执行过程中GC的各种状态。请参考前一篇文章中提到的监控方法 成为Java GC专家系列(2) ——监控Java垃圾回收

2.在分析监控结果后,决定是否进行GC优化

在检查GC状态的过程中,你应该分析监控结果以便决定是否进行GC优化,如果分析结果表明执行GC的时间只有0.1-0.3秒,那你就没必要浪费时间去进行GC优化。但是,如果GC的执行时间是1-3秒,或者超过10秒,GC将势在必行。

但是,如果你已经为Java分配了10GB的内存,并且不能再减少内存大小,你将无法再对GC进行优化。在进行GC优化之前,你必须想清楚你为什么要分配如此大的内存空间。假如当你分1 GB 或 2 GB内存时出现OutOfMemoryError ,你应该执行堆内存转储(heap dump),并消除隐患。

注意:

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

你可以在互联网上搜索堆内存[s1] 转储的详细说明。对于韩国的读者,可以参考我去年发布的书: 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状态及分析结果

查看运行中的Web Application Server (WAS)的GC状态的最佳方法是通过jstat命令,在第二篇文章成为Java GC专家系列(2) ——监控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得到0.050秒(50毫秒)。这意味着新生代空间上的GC操作平均花费50毫秒。在这种情况,你大可不必担心新生代空间上执行的GC操作。
接下来,我们来看一下FGCT 和FGC。,计算FGCT/ FGC得到19.68秒,这意味着GC的平均执行时间为19.68秒,可能是每次花费19.68秒执行了三次,也可能是其中的两次执行了1秒而另一次执行了58秒。不论哪种情况,都需要进行GC优化。

通过jstat 命令可以很轻易地查看GC状态,但是,分析GC的最佳方式是通过–verbosegc参数来生成日志,在之前的文章中我已经解释了如何分析这些日志,HPJMeter 是我个人最喜欢的用于分析-verbosegc 日志的工具。他很易于使用和分析结果。通过HPJmeter你可以很轻易查看GC执行时间以及GC发生频率。如果GC执行时间满足下面所有的条件,就意味着无需进行GC优化了。

  • Minor GC执行的很快(小于50ms)
  • Minor GC执行的并不频繁(大概10秒一次)
  • Full GC执行的很快(小于1s)
  • Full GC执行的并不频繁(10分钟一次)

上面提到的数字并不是绝对的;他们根据服务状态的不同而有所区别,某些服务可能满足于Full GC每次0.9秒的速度,但另一些可能不是。因此,针对不同的服务设定不同的值以决定是否进行GC优化。

在查看GC状态的时候有件事你需要特别注意,那就是不要只关注Minor GC 和Full GC的执行时间。还要关注GC执行的次数,例如,当新生代空间较小时,Minor GC会过于频繁的执行(有时每秒超过1次)。另外,转移到老年代的对象数增多,则会导致Full GC执行次数增多。因此,别忘了加上–gccapacity参数来查看具体占用了多少空间。

设定GC类型/内存空间大小

1.设定GC类型

OracleJVM有5种GC类型,但是在JDK7之前的版本中,只能在Parallel GC, Parallel Compacting GC 和CMS GC之中选择一个,对于选择哪个没有明确的原则和规则。

这样的话,我们该如何选择呢?强烈建议三者都选,但是,有一点是很明确的:CMS GC比Parallel GCs更快。如果真的如此,那么就选CMS GC了。但是,CMS GC也不总是更快。整体来看,CMS GC模式下的Full GC执行更快,不过,一旦出现并行模式失败,他将比Parallel GC更慢。

CONCURRENT MODE失败

我们来详细讲解一下concurrent mode失败。

Parallel GC 和 CMS GC 最大的不同来自于压缩任务。压缩任务是通过删除已分配内存空间中的空白空间以便压缩内存,清理内存碎片。

在Parallel GC模式下,压缩工作在Full GC执行时进行,这会费很多时间,但是,在执行完Full GC之后,由于能够顺序地分配空间,随后的内存能够被更快的分配。

与之相反的,CMS GC并不进行压缩处理,因此,CMS GC执行的更快。但是,由于没有压缩,在进行磁盘清理之前,内存中会有很多空白空间。这就是说,可能没有足够的空间存储大的对象,例如,虽然老年代空间还有300MB空间,但是一些10MB的对象无法被顺序的存储。在这种情况下,会出现“并行模式失败”警告,并执行压缩处理。在CMS GC模式下,压缩处理的执行时间要比Parallel GCs长很多。另外,这还将导致另外一个问题。关于并发模式失败的详细说明,可以参考Oracle工程师撰写的Understanding CMS GC Logs。

综上所述,你需要找到最适合你的系统的GC类型。

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

2.设定内存空间大小

下面展示了内存空间大小,GC执行次数以及GC执行时间三者间的关系。

    • 大内存空间
      • 减小GC执行次数
      • 增加GC执行时间
    • 小内存空间
      • 减小GC执行时间
      • 增加GC执行次数

关于如何设置内存空间的大小,没有唯一的标准答案。如果服务器资源足够,而且Full GC也可能在1秒内完成,设置为10GB当然可行。。但绝大多数服务器并不是这样,当内存设为10GB时,可能要花费10~30秒来执行Full GC。当然,执行时间会随对象的大小而改变。

鉴于如此,我们应该如何设定内存空间大小呢?一般来说,我建议为500MB。不过请注意这不是让你将WAS的内存参数设置为–Xms500m 和–Xmx500m。根据优化GC之前的状态,如果Full GC执行之后内存空间剩余300MB,那么最好将内存设置为1GB(300MB(默认程序占用)+ 500MB(老年代最小空间)+200MB(空闲内存))。也就是说你要为老年代额外设置500MB。因此,如果你有三个执行服务器,内存分别设置为1GB,1.5GB,2GB,并且检查结果。

理论上来讲,GC执行速度应该遵循1GB> 1.5GB> 2GB,因此1GB执行GC速度最快。但是并不说明1GB空间的Full GC会花费1秒而2GB空间会花费2秒。时间取决于服务器的性能和对象的大小。因此,最佳的方式是建立尽可能多的衡量指标来监控他们。

对于内存空间大小,你应该额外设定NewRatio参数。NewRatio参数是新生代和老年代空间的比例,即XX:NewRatio=1意味着新生代与老年代之比为1:1。对于1GB来说就是新生代和老年代各500MB。如果NewRatio为2,意味着新生代老年代之比为1:2,因此该值越大,老年代空间越大,新生代空间越小。

这看似一件不是很重要的事情,但NewRatio参数会显著地影响整个GC的性能。如果新生代空间很小,会用更多的对象被转移到老年代空间,这样导致频繁的Full GC,增加暂停时间。

你可以简单的认为NewRatio 为1是最佳的选择,但是,有时可能设置为2或3更好,我就见过很多这样的例子。

如何最快的完成GC优化?对比性能测试的结果应该是最快地方法,为每一台服务器设置不同的参数并监控他们的状态,强烈建议至少监控1或2天的数据。但是,当你对GC优化是,你要确保每次执行相同的负载。并且请求的比率,例如URL都应该是一致的。不过,即便对于专业测试人员要想精确的控制负载也是很难的,并要花费大量的时间准备。因此,相对来说比较方便和容易的方法是调整才参数,之后花费较长的时间收集结果。

分析GC优化结果

在设置了GC参数以及-verbosegc参数之后,通过tail命令确保日志被正确的生成。如果参数设置的不正确或者日志没有生成,你将白白浪费你的时间。如果日志正确的话,持续收集1到2天。随后最好将日志下载到本地PC并用HPJMeter来分析

  • Full GC 执行时间
  • Minor GC执行时间
  • Full GC 执行间隔
  • Minor GC 执行间隔
  • Entire Full GC 执行时间
  • Entire Minor GC 执行时间
  • Entire GC 执行时间
  • Full GC e执行时间
  • Minor GC 执行时间

找到最佳的GC参数是件非常幸运的事情,然而在大多数场合,我们并不会得到幸运之神的眷顾,在进行GC优化时要尽量小心谨慎,想一步完成优化往往会导致OutOfMemoryError 。

优化示例

好了,我们一直在纸上谈兵,现在我们看一些实际的GC优化的例子。


示例1

下面这个例子针对 Service S的优化,对于最近被部署的 Service 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


最左边的Perm 空间对于最初的GC优化不是很重要,这一次YGC参数的值更加有用。


Minor GC和Full GC的平均值如下表所示

表3:Service S的Minor GC 和Full GC的平均执行时间

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

对于Minor GC来说,37ms还凑合。但是对于Full GC来说,1.389s也就意味着如果你的数据库超时时间设为1s,那在垃圾回收的时候就会出现超时操作。这种情况下,我们就需要对GC进行优化。

首先你应该知道在GC优化之前的内存使用情况。使用命令jstat –gccapacity来查看。我们的测试服务器执行命令后的结果如下:

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
  • 老年代实际使用空间: 1,884,160 KB

因此,总的内存空间为2GB,不算Perm空间的话,新生代与老年代之比为1:9。通过jstat和-verbosegc 日志进行数据收集,并把三台服务器按照如下方式设置(不再设置其他额外参数)。

  • NewRatio=2
  • NewRatio=3
  • NewRatio=4

一天之后,检查系统的GC日志后发现,在设置了NewRatio参数后很幸运的没有发生Full GC,

为什么了?因为大部分的对象在创建后很快就被垃圾回收掉(译者注:在年轻代中),所以很多对象在没有被转移到年老代时就已经在年轻代被回收掉。

这种情况下,我们没有必要再修改其他选项,只需要为NewRatio设定一个最佳值。但是,我们怎么来确定NewRatio的最佳值了?为了确定最佳值,我们需要比较几个NewRatio下Minor GC的平均时间。

  • NewRatio=2: 45 ms
  • NewRatio=3: 34 ms
  • NewRatio=4: 30 ms

我们看到NewRatio=4 是最佳的参数,虽然它的新生代空间最小,但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

这是一个针对ServiceA的例子,我们通过公司内部的应用性能管理系统(APM)发现JVM暂停了相当长的时间(超过8秒),因此我们决定进行GC优化。我们找到了Full GC执行时间过长的原因,并着手解决。

进行GC优化的第一步,就是我们添加了-verbosegc参数,并得到如下结果。

成为Java GC专家系列(3) ——如何优化Java垃圾回收_第1张图片

图1:进行GC优化之前的STW时间

如上图所示,由HPJMeter自动生成的图片之一。X坐标表示JVM执行的时间。Y坐标表示每次GC的时间。CMS绿点,表示Full GC结果。Parallel Scavenge蓝点,表示Minor GC结果。

之前我曾经说过CMS GC是最快的,但是上面的的结果显示出于某种原因,它最多花费了15秒。是什么导致这个结果?是否想起我之前提过的,CMS在进行内存清理时,会变慢。与此同时,服务的内存被设定为 –Xms1g和–Xmx4g ,且实际分配了4GB内存。

因此,我将GC类型从CMS改为Parallel GC。并且将内存改为2GB,设定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


相对于4GB时的15秒,Full GC变成了平均每次3秒。但是3秒一样比较慢,因此我设计了如下6种场景。

  • Case 1: -XX:+UseParallelGC -Xms1536m -Xmx1536m -XX:NewRatio=2
  • Case 2: -XX:+UseParallelGC -Xms1536m -Xmx1536m -XX:NewRatio=3
  • Case 3: -XX:+UseParallelGC -Xms1g -Xmx1g -XX:NewRatio=3
  • Case 4: -XX:+UseParallelOldGC -Xms1536m -Xmx1536m -XX:NewRatio=2
  • Case 5: -XX:+UseParallelOldGC -Xms1536m -Xmx1536m -XX:NewRatio=3
  • Case 6: -XX:+UseParallelOldGC -Xms1g -Xmx1g -XX:NewRatio=3

哪一个最快呢?结果显示,内存越小,结果越好。下图展示了Case6的结果。这是GC的性能最好。最长的响应时间只有1.7秒。平均时间在1秒之内。

成为Java GC专家系列(3) ——如何优化Java垃圾回收_第2张图片

图2:Case6的时间图表

基于以上结果。我们按照Case6调整了GC参数。但是,这导致了每天晚上都会发生OutOfMemoryError。在这里很难解释具体的原因。简单来说,批处理程序导致了内存泄漏。相关的问题已经被解决。

如果对GC日志只分析很短的时间就贸然对所有服务器进行优化是非常危险的。请时刻牢记,你必须同时分析GC日志和应用程序。

我们回顾了两个关于GC优化的例子,正如我之前提到的,例子中提到的GC参数,可以设置在相同的服务器之上,但前提是他们具有相同的CPU,操作系统,JDK版本以及运行着相同的服务。但是不要直接把我用过的参数用到你的服务至上,它们未必能很好的工作。

结论

我凭借经验进行GC优化,而没有执行堆转储并分析内存的详细内容。精确地分析内存可以得到更好的优化效果。但是,这种分析一般适用于内存使用量相对固定的场合。不过,如果服务严重过载并占用的大量的内存,强力建议根据之前的经验进行GC优化。

我已经在一些服务上设置了G1 GC参数,并进行过性能测试。但还没有应用与正式环境,G1 GC参数的速度要快于其他任何GC类型。但是,你必须要升级到JDK7。另外,他的稳定性也暂时没有保障,没人知道是否会出现致命的错误。因此还不到将其正式应用的时候

在未来的某一天,等到JDK7真正稳定了(这不是说他现在不稳定),并且WAS针对JDK7进行优化后,G1 GC最终能够按照预期的那样工作了,我们可能就不需要在进行GC优化了。

想了解GC优化的更多内容,请登录Slideshare.com 查看关联资源。强烈推荐Everything I Ever Learned About JVM Performance Tuning @Twitter,作者Attila Szegedi,一位Twitter工程师。请花些时间阅读。

你可能感兴趣的:(成为Java GC专家系列(3) ——如何优化Java垃圾回收)