在调优JVM的时候,我们的目的是在一定的运行环境下提高吞吐量,降低最大停顿时间。这篇文章以Parallel收集器来进行一次调优实战。

测试环境:青云上海1区A - 性能型 - ubuntu 16.04 - 2核12G

我们要调的是什么?

本文就以我们一个项目的启动速度极慢的Jar包为动手目标,将提升启动速度为目的,那是不太可能的,因为GC的速度本来早就已经优化的很快了,所以提升启动速度的效果不会明显。那我们要调的,要优化的到底是什么?

优化JVM垃圾收集性能从而增大吞吐量或减少停顿时间,让应用在某个业务场景上发挥最大的价值

这是我对JVM调优一个定义,在本文里,将以一个项目的启动过程模拟一个比较消耗资源的Web请求的过程,以在web应用中减少单个请求停顿时间为目的来进行调优。所以评判指标是:Young GC总时间与Full GC总时间,如果总时间无法减少,那么减少最大停顿时间也是优化。

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

你可能有个疑问,前面不是已经提到,并行(Parallel)收集器适用于计算、后台处理这样的弱交互场景而不是web交互场景。但是我们为什么要用这个收集器来减少停顿时间而不是用CMS或G1收集器呢?因为Parallel有个特点,它支持基于行为的自适应调整,以及其他收集器都不支持的两个参数:-XX:MaxGCPauseMillis=<n>-XX:GCTimeRatio=<n>,从而能精确控制吞吐量与停顿时间,且能自动调整堆的大小,所以虽然它不是偏向减少停顿时间的,但它的表现会更加稳定且可控,也是更加偏向业务场景选择的,所以这个收集器也是有用的。

套路

其实JVM调优的套路非常简单,只需要以下四步即可:

  1. 明确本次要调优的目标
  2. 拿到GC日志
  3. 分析日志
  4. 调整JVM参数

重复2和3,直到表现令你满意为止。但是实际上第2步是最考验技术实力的一步,你必须要对JVM内存结构、各种垃圾收集器调优的方式、甚至调优经验有一定的积累才能做好,否则将JVM调坏都有可能。本文也将重点介绍如何调整JVM参数以及为什么。

调试时一定要注意本地机器的内存大小是否够用,同时使用java -XX:+PrintFlagsFinal -version | grep MaxHeapSize查看JVM默认最大堆以及其他最大值来避免影响调试

拿到GC日志

运行Java程序时添加以下参数以输出gc日志 -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -verbose:gc -XX:+PrintTenuringDistribution -XX:+PrintGCApplicationStoppedTime -Xloggc:/tmp/gc.log

测试时运行的命令为nohup java *JVM参数* -jar xxx.jar &

期间,使用jps -ml可以方便的拿到进程PID,从而kill

1
2
3144 sun.tools.jps.Jps -ml
2959 cmp-managerServer.jar

最后,将/tmp/gc.log拿到本地,方便查看

第一次分析日志

有一个在线可视化工具方便查看:http://gceasy.io/

将刚生成的log上传上去,拿到结果:

可以看出:

吞吐量 最大GC时间 最大Young GC时间 Young GC平均时间 Young GC次数 Full GC次数
96.759% 660ms 160 ms 36 ms 30 3
  1. 【JVM Heap Size】:年轻代的峰值接近于最大值
  2. 提示49.47% of GC time (i.e 860 ms) is caused by 'Metadata GC Threshold'.有一半的时间因为元空间不足导致的GC,且最久的一次GC也是元空间导致的,但是页面上显示元空间分配的是 1.05 gb,讲道理不会发生不足的情况,自己使用java -XX:+PrintFlagsFinal -version | grep MetaspaceSize查看到默认元空间大小为20.796875MB
  3. 【Interactive Graphs】:【Young Gen】因为内存不足GC和扩容了很多次
  4. 【GC Causes】:有一半触发GC的原因是Young区不够
  5. GC时间最大值与Full GC次数太久,需要降低

所以我们可以下一个结论,年轻代与元空间的初始空间不足导致频繁GC,那么是不是增加年轻代和元空间的大小就能减少GC次数呢?是的,但是会带来其他问题。

年轻代越大,确实可以让Young GC减少,但是对于有限的堆大小来说,较大的年轻代意味着较小的老年代,这将增加其GC频率。并且如果年轻代很大,每次的Young GC时间也会增大因为需要GC的内容变多。所以最佳选择取决于应用程序分配的对象的生命周期分布

所以调整年轻代需要这样来:

  1. 先确定能提供给JVM的最大堆内存大小,并固定,注意不要超过系统内存避免使用文件交换
  2. 保持老年代足够大,以容纳应用程序在任何给定时间使用的所有实时数据,并加上一些缓冲区(10到20%或更多)
  3. 反复调整年轻代以满足你的需求,注意避免因年轻代太大导致Young GC时间长

第一次调整JVM参数

根据上面的分析,我们要把老年代设置一个比较大的空间,但最大堆内存和老年代的空间都是够用的,所以不需要额外的限制初始化老年代空间是多少,通过自动扩容是能够满足应用程序的。

将年轻代的最大值调小到256MB通过增加Young GC来减少每次GC的数据量从而减少最大停顿时间:-XX:MaxNewSize=268435456

再根据元空间调优的建议,将元空间大小扩大到96MB以适应第一次加载数据的空间需求,也能减少因为元空间导致的频繁GC:-XX:MetaspaceSize=100663296

建议:使用控制变量法。每次只调一个参数,下面几次调试我是为了篇幅把合成一个调整阶段了,别学我

第二次分析日志

吞吐量 最大GC时间 最大Young GC时间 Young GC平均时间 Young GC次数 Full GC次数
97.554% 570ms 60ms 16 ms 89 1
  1. GC平均时间下降是因为年轻代减小,Young GC次数提升,每次回收的数据减少导致,当然Young GC的最大时间也会减少。对于年轻代来说,满足了减少单个GC停顿时间,能保证大部分web请求来的时候能有比较稳定的表现
  2. 有一次花了570ms的Full GC,可能会导致某些web请求卡顿570ms,是Ergonomics触发的Full GC

Ergonomics是Java虚拟机(JVM)和垃圾收集调优的过程,是Parallel收集器特有的自适应调优机制,我们只需要使用Behavior-Based Tuning配置两个参数即可。这个问题先不解决,先减少Young GC的最大停顿时间来减少年轻代的最大GC时间

第二次调整JVM参数

使用java -XX:+PrintFlagsFinal -version | grep GCTimeRatio查看到默认值是99,也就是默认将垃圾回收的时间设置成了总时间的1%,能达到非常高的吞吐量的效果,这个参数我们不需要调整,因为最大停顿时间的优先级高于吞吐量,所以使用默认的参数就很好,因为如果满足了最大停顿时间,是不是再试图去满足高吞吐量的目标更好呢?

同样能看到最大停顿时间的默认值是无限制,所以我们需要设置一个最大停顿时间,以尝试使垃圾收集暂停时间小于毫秒。这样调整可能会导致GC更频繁地发生,从而降低了应用程序的整体吞吐量。所以加上-XX:MaxGCPauseMillis=10。但这个参数无法限制Ergonomics调节堆大小和Full GC花费的时间。

第三次分析日志

吞吐量 最大GC时间 最大Young GC时间 Young GC平均时间 Young GC次数 Full GC次数
96.99% 810 ms 40ms 9 ms 117 1
  1. 虽然我们设置的最大Young GC时间为10ms,但实际上由于我们添加了最小的年轻代与老年代的限制,只能到40ms,不过比原来的Young GC最大时间160ms好太多了。由于年轻代的是配置策略交给了Ergonomics,Ergonomics会自动调整年轻代的大小来达到减少最大停顿时间的要求,所以最大Young GC时间减少到了40ms。但是Young GC次数却增多了2倍,Young GC总时间相当于没变,所以不能达到web应用中减少单个请求停顿时间的目标,但是满足了减少最大停顿时间的目标。
  2. 耗费810ms的Full GC依然存在。从老年代GC图中可以发现,可能是老年代的峰值达到了顶峰,然后触发的Ergonomics引起的Full GC,随后老年代的空间被扩大了。所以是因为老年代的初始空间不足导致的老年代的Full GC以及动态调整老年代大小。

Young GC平均时间很不错,但是次数太多,导致Young GC总时间基本没变,所以再次尝试通过控制年轻代大小来优化GC次数与总时间,所以将年轻代的初始大小适当调高,避免因Ergonomics设置太小导致Young GC与扩容次数太多

第三次调整JVM参数

添加-XX:NewSize=268435456,同时去掉-XX:MaxNewSize=268435456,因为Ergonomics会帮我们做限制

将老年代的初始(最小)大小设置为128MB避免因Ergonomics设置太小导致Full GC与扩容次数太多:-XX:OldSize=134217728

第四次分析日志

吞吐量 最大GC时间 最大Young GC时间 Young GC平均时间 Young GC次数 Full GC次数
97.488% 550 ms 40ms 13 ms 71 1
  1. 通过扩大年轻代大小,使得Young GC次数比上次减少。与原始数据相比,虽然Young GC次数增加,但Young GC总时间减少了约17%,最大Young GC与平均时间降低,满足了web应用中减少单个请求停顿时间减少最大停顿时间的目标

但是这并不一定是最佳的参数配置,你可以试一下其他内存大小值或者使用一些更高级的参数继续调试。

另外,还不能说已经完全满足了需求,因为最大GC时间550ms必须去解决。但是我们不能直接将老年代的初始大小设置为大一点来避免因Ergonomics设置老年代初始大小太小导致的Full GC,因为Full GC实际上无法避免。而是应该从原理上知道,为什么这次Full GC会花这么长时间、为什么这么频繁,从而减少每次Full GC时间以及次数。

对Full GC的优化

定义:收集整个堆,包括老年代、年轻代、元空间(如果存在的话)的模式。

在Java1.8下Parallel收集器会触发Full GC的原因:

  1. Young GC的平均晋升大小比目前老年代剩余的空间大
  2. 要在元空间分配空间但已经没有足够空间时
  3. System.gc()的建议

我们可以根据这几个建议来减少Full GC的频率、时间:

  1. 将新对象保留在年轻代,应该分配一个合适的年轻代空间最大限度避免新对象直接进入年老代、将部分年轻对象提前向年老代压缩的情况
  2. 让大对象分配时直接进入老年代。因为尝试在年轻代分配大对象,很可能导致空间不足,为了有足够的空间容纳大对象,JVM 不得不将年轻代中的年轻对象挪到老年代。-XX:PetenureSizeThreshold设置大对象直接进入老年代的阈值(但只对串行回收器和ParNew有效)
  3. 设置对象进入老年代的年龄。如果对象每经过一次 GC 依然存活,则年龄再加 1。当对象年龄达到阈值时,就移入老年代,成为老年对象。这个阈值的最大值可以通过参数-XX:MaxTenuringThreshold 来设置,默认值是 15。
  4. 避免动荡的大堆可以减少GC次数,但会增加每次GC时间。

如果要继续优化Full GC的时间和频率。可以结合这次请求的特点:大量分配新对象,我们可以把年轻代的内存再提高一些、将进入老年代的年龄提高一些,从而将新对象多保留在年轻代。然后尝试调整老年代大小到一个Full GC时间和频率能接受的值。这些就留给你尝试了。

根据我试过的一些调试结果来看,减少Full GC的停顿时间意味着Full GC的次数必然增加,因为Full GC因为需要标记、清理的数据量相对于Young GC来说实在是太多了,所以时间不可避免的会长很多,当实在无法减少Full GC时就需要作取舍了。比如在调试的时候要根据系统在一段时间内最大能容忍的停顿时间来调试出具体的Full GC停顿时间,如果需要更低的停顿时间,推荐使用CMS和G1收集器

总结与建议

  • 在进行GC优化之前,需要确认项目的架构和代码等已经没有优化空间
  • 根据你的需求确定选择哪个垃圾收集器
  • 对于Parallel收集器,先设置你想要的吞吐量和停顿时间,让自适应机制去自动调整,如果不行再去调试
  • 一般我们不需要像本文中一样一开始就精确的去优化,写上-Xmx1g(或合适的值)控制一下应用的占用内存大小就行,如果出现GC的性能问题再去调优
  • 细心。要对各种数据、调优参数比较敏感
  • 耐心。别看我只写了调整三次,其实有三十次
  • 复习一下套路
  • oracle官方调优文档CMS收集器的美团调优实战

号外号外

最近在总结一些针对Java面试相关的知识点,感兴趣的朋友可以一起维护~
地址:https://github.com/xbox1994/2018-Java-Interview

Comments