JVM调优的时候会涉及三个指标,分别是:内存占用量、系统延迟与系统吞吐量。
-
内存占用
系统运行时,Java虚拟机需要的内存。
-
延迟
系统运行过程中由于垃圾收集引起的暂停时间。
-
吞吐量
单位时间内完成的任务数量。
Java虚拟机把堆内存划分为三个区域:轻年代、老年代与永久代:
-
轻年代(young代)
轻年代又分为一个Eden区和两个Survivor区(一个from Survivor和一个to Survivor),每次只会使用Eden和其中一个Survivor区,这么分配的原因是轻年代采用了“复制”算法来回收。当创建新的对象时,(大部分情况下)这个对象所占的空间会在Eden区中分配,如果Eden区的空闲空间不足,这时虚拟机会触发一次Minor GC,将Eden区和from Survivor区中还存活的对象转移到to Survivor区中。在经历若干次Minor GC之后,如果对象还是存活,那就会被移到老年代中去。
-
老年代(old代)
存放系统中长期存活的对象,比如通过spring拖管的一些单例对象,如service对象、dao对象等。还有一部分是在Minor GC后轻年代的空间仍然不足时,从轻年代转移过来的对象,这部分对象一般是导至系统发生Full GC的主要原因。
-
永久代(perm代)
存储已被虚拟机加载的类信息、常量、静态变量、即时编译器编译后的代码等数据。
-Xmx
和-Xms
这个两个参数分别指定虚拟机堆空间(轻年代加老年代)大小的最大值和最小值。当-Xms
的值小于-Xmx
的值的时候,Java堆的大小可以在最大值和最小值之前浮动。生产环境一般将这两个值的大小设成一致的,因为虚拟机动态调整Java堆大小的时候需要进行Full GC,影响系统的性能。
-Xmn
参数用于指定轻年代的大小。虚拟机没有提供直接的参数来指定Eden区与Survivor的大小,而是通过指定Eden区与Survivor的比值-XX:SurvivorRatio
来配置的。
永久代的大小是通过-XX:PermSize
参数来配置的,例如:
-Xmx
和-Xms
设置为50m,表示Java堆的总空间为50m,-Xmn10m
表示轻年代空间大小为10m,即老年代空间大小为50 - 10 = 40m,-XX:PermSize=20m
表示永久代空间大小为20m。-XX:SurvivorRatio=8
表示Eden与Survivor的比例为8:1,不难算出Eden区的空间大小为8m,两个Survivor区的空间大小都为1m。
在启动Java应用的时候,上面提到的这些参数都不是必须的,这时虚拟机会根据应用的情况自行计算。而我们应该如何去衡量每个区域应该分配多少内存呢?在发生Full GC之后,老年代与永久代的存活对象所占有的空间,就是系统要求的最小空间。所以在系统运行稳定的时候收集Full GC日志可以估算出系统运行时长期存活对象所需要的空间大小,比如
老年代长期存活对象占用了4137K(约4M)的空间,永久代的存活对象占用了9964K(约10M)的空间。通常来讲,老年代在最小空间应该是存活对象大小的3到4倍,也就是12-16M之间,永久代的最小空间是存活对象的1.5倍左右,也就是10-15M之间,轻年代的大小应该配置为老年代存活对象的1到1.5倍,即4-6M之间。针对上面这条Full GC日志的分析,我们可以估算出最小的内存配置如下:
当然,内存占用量的优化,是以降低系统吞吐量和更高的延迟为代价的,在实际项目中几乎不会这么做,但是掌握内存优化的知识对其它两个指标的优化是很有帮助的。
系统低延迟优化
对于一些提供基础服务的应用系统而言,对延迟的表现会非常的敏感,这样的系统建议使用ParNew加上CMS的收集器组合。CMS收集器是一种以获取最短回收停顿时间为目标的收集器,因为老年代的垃圾回收执行线程会和应用程序的线程并发的执行,这样就提供了一个机会来减少最坏延迟的频率和最坏延迟的时间消耗。
进行系统延迟优化的第一步,是通过GC日志、VM启动参数以及各种监控工具(如visualVM、jconsole等)采集信息,需要采集的信息包括:
-
虚拟机堆中各个区域的空间大小,轻年代(包括Eden区域与Survivor区域)、老年代与永久代,空间大小。
-
在发生Full GC之后,老年代与永久代的存活对象所占的空间大小。
-
Minor GC的发生频率与执行耗时。
-
CMS回收周期(也可以理解为Full GC吧)的触发频率及耗时,以及观察是否有发生Concurrent Mode Failure的情况。
-
对象从轻年代向老年代的转移率。
首先说明一下什么是Concurrent Mode Failure?CMS是一款并发的收集器,垃圾收集线程与用户线程是并发执行的,意味着垃圾收集线程在执行的同时,用户线程还可能会不断产生新的垃圾,称为“浮动垃圾”,CMS无法在当次收集中处理掉它们,只好留待下一次GC时再清理掉。如果CMS运行期间预留的内存被浮动垃圾占满而无法满足程序需要,就会出现一次Concurrent Mode Failure,这时虚拟机将启动后备预案:临时启用Serial Old收集器来重新进行老年代的垃圾收集,也就是发生了stop-the-world的情况,这样停顿时间就很长了。对应延迟敏感的应用来讲Concurrent Mode Failure的情况经常出现的话,相当于系统故障了,必须立即解决。
为了避免或者说降低Concurrent Mode Failure的发生频率,虚拟机提供了一个参数-XX:CMSInitiatingOccupancyFraction
,来指定一个百分比值,当老年代的空间使用率达到这个比值的时候,CMS就会触发一次回收周期。CMSInitiatingOccupancyFraction的值设置得过低会增加Full GC的发生频率,而设置过高又可能会导致Concurrent Mode Failure的频繁出现,在实际调优的时候需要不断的尝试不同的值来进行测试。另外,CMSInitiatingOccupancyFraction值的大小还依赖于对象从轻年代向老年代的转移率,如果转移率较高,那CMSInitiatingOccupancyFraction的值应该尽量设得低一些,如果转移率较低,就可以设得相对高一些。
另外需要注意的是参数-XX:+UseCMSInitiatingOccupancyOnly
,它指定虚拟机总是使用-XX:CMSInitiatingOccupancyFraction
的值作为老年代的空间使用率限制来启动CMS垃圾回收。如果没有使用-XX:+UseCMSInitiatingOccupancyOnly
,那么虚拟机只是利用这个值来启动第一次CMS垃圾回收,后面都是使用自动计算出来的值。
对于延迟敏感的应用来说,Minor GC的执行耗时决定了应用的平均延迟,Full GC的耗时则决定了应用的最大延迟。要优化应用的最大延迟,我们需要从老年代入手。对于CMS收集器来说,我们可以认为它把老年代的空间划分成了三个部分:
其中长期存活对象指的是Full GC发生之后老年代中长期存活的对象所占用的空间,可用空间是用来存放从轻年代向老年代转移的对象的,保留空间是在CMS Full GC发生时,为了防止发生Concurrent Mode Failure而预留的一部分空间。不难发现,可用空间的大小结合对象从轻年代向老年代的转移率决定了老年代Full GC的发生频率,而可用空间的大小同时也决定了Full GC的执行耗时。很明显,要优化应用的最大延迟耗时,我们必须降低可用空间的大小,但是这样又会导致Full GC的频率变大,所以我们在降低可用空间的同时还得想办法降低对象从轻年代向老年代的转移率,尽可能让应用中的短期存活的对象在Minor GC阶段最大化的被回收。
虚拟机中轻年代的空间划分为一个Eden区域和两个Survivor区域(一个from,一个to):
前面说过,当应用需要创建新的对象时,虚拟机就会为这个对象在Eden区中分配空间,如果Eden区的空间不足,虚拟机会触发一次Minor GC,将Eden区和from Survivor区中还存活的对象转移到to Survivor区中。不过也存在一些特殊情况:
-
如果Survivor的空间不能容纳下存活对象,那虚拟机会根据对象的年龄有选择的将部分对象直接移动到老年代,这样就会加快Full GC发生的频率。
-
虚拟机提供了一个-XX:PretenureSizeThreshold参数,令大于这个设置值的对象直接在老年代分配。这样做的目的是避免在Eden区及两个Survivor区之间发生大量的内存复制,但这样也会加剧Full GC发生的频率,良好的应用应该尽避免大对象的产生。
对于轻年代来说,Eden区域在空间大小决定了Minor GC的发生频率,而Minor GC的耗时除了受到Eden与Survivor空间大小及比例之外,Minor GC之后存活对象大小也会有影响。但是在调优Minor GC的时候,我们通常把轻年代看作是一个整体,也就说是如果应用平均延迟的频率(即Minor GC发生的频率)高于预期,我们可以适当的增加轻年代的总空间;如果应用的平均延迟时间(即Minor GC的耗时)大于预期,我们就适当的减少轻年代的总空间。
应用的平均延迟时间与平均延尺频率是两个相互矛盾的因素,如果我们始终无法通过调整轻年代的大小来同时满足应用对这两个指标的要求,那:
-
我们可能要修改应用程序,减少新对象的创建。
-
或者改变虚拟机的部署模型。比如采用集群,把一个应用的压力分摊到多台机器上,这样应用对内存空间的要求就会降低,从而极大的提升平均延迟时间与平均延尺频率两个指标。
应用的平均延迟时间与平均延尺频率达到预期后,下一步就到了优化对象向老年代的转移率了。前面我们提到过对象的年龄,实际上虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,将被移动到Survivor空间中,并且对象年龄设为1。对象在Survivor区中每“熬过”一次Minor GC,年龄就增加1岁,当它的年龄增加到一定程度(默认为15岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数-XX:MaxTenuringThreshold
设置。
为了更好的适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了MaxTenuringThreshold才能晋升老年代,如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半(target survivor space occupancy,默认值为50%,通过 -XX:TargetSurvivorRatio
配置),年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到MaxTenuringThreshold中要求的年龄。为了监控Survivor空间中对象的年龄分布情况,我们可以在启动虚拟机的时候加上参数-XX:+PrintTenuringDistribution
。
下面这个例子的启动参数为:
通过参数可以算出一个Survivor的空间大小正好是1M,其对应的GC日志如下:
这个例子中,最大任期阀值被设置为15(通过max 15表示),但是内部计算出来的任期阀值是1,通过new threshold 1表示,说明Survivor的空间太小。Desired survivor size 524288 bytes的值等于一个Survivor的大小乘于0.5(target survivor space occupancy,默认值为50%,通过 -XX:TargetSurvivorRatio
配置)。后面会列出对象的岁数列表。每行会列出每一个岁数的字节数,在这个例子中,岁数是1的对象有1024800字节,而且每行后面有一个总的字节数,如果有多行输出的话,总字节数是前面的每行的累加数。
由于期望的Survivor大小(524288)比实际总共Survivor字节数(1024800)小,也就是说,Survivor空间溢出了,这次MinorGC会有一些对象移动到老年代。另外,设定的最大任期阀值是15,但是实际上JVM使用的是1,也表明了Survivor的空间太小了。
要保证Survivor可以容纳下1024800的对象,那Survivor的空间大小必须大于1024800 / 0.5(target survivor space occupancy,默认值为50%,通过 -XX:TargetSurvivorRatio
配置),约为2M。
所以我们增大Survivor的空间试试,注意在调整Survivor空间大小时,应该尽量保持其它区域的大小不变。所以我们把启动参数改为:
调整后的GC日志:
通过调整后的GC日志发现,Survivor的空间还是小了,再用同样的方法得出Survivor的空间大小为4M,继续修改启动参数:
再次调整后的GC日志:
从任期分布的情况来看,Survivor空间没有溢出,由于存活的总大小没有超过预期的Survivor空间,以及任期阀值和最大任期阀值是相等的。这个表明,对象的老化速度是高效的,Survivor空间没有溢出。经过这一步的调整,我们已经极大的优化了对象向老年代的转移率了,但是重新调整轻年代的空间后,我们可能需要重新评估应用的平均延迟时间与平均延尺频率。
由于岁数超过1的对象很少,可以把最大任期阀值设置为1来测试一下,即设置选项-XX:MaxTenuringThreshhold=1
。这样可以避免在MinorGC的时候不必要地把对象从“from” survivor复制到“to” survivor,从而加快Minor GC的执行时间,但也要注意CMS的对象从轻年代移动到老年代最终会导致碎片的增加,有可能导致stop-the-world压缩垃圾回收,这些都是不希望出现的,所以宁愿把-XX:MaxTenuringThreshhold
尽量设大点,也不要太快的移动到老年代。
吞吐量优化
像一些在夜间处理定时任务的应用,并不是很在意系统的延迟,但是对吞吐量的要求却很高,这样的应用建议使用吞吐量优先的收集器,即Parallel Scavenge与Parallel Old的组合。
对于吞吐量的优化来说,Minor GC造成的影响一般可以忽略不计,主要是避免系统在稳定状态下发生Full GC,方法与低延迟优化的原理类似。一方面要保证老年代的可用空间大于长期存对象的1.5倍;另一方面,要优化每次Minor GC后对象从新年代向老年代的转移率。
HotSpot VM Parallel Scavenge收集器提供了一种自适应大小的特性(通过参数-XX:+UseAdaptiveSizePolicy
打开),这是一个开关参数,当这个参数打开之后,就不需要手工指定新生代的大小(-Xmn)、Eden与Survivor区的比例(-XX:SurvivorRatio)、晋升老年代对象大小(-XX:PretenureSizeThreshold)等细节参数了,虚拟机会根据当前系统的运行情况收集收能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量,这种调节方式称为GC自适应调节策略(GC Ergonomics)。自适应调节策略在大多数应用下,能够很好的工作。
但是关闭自适应大小以及优化Eden空间和Survivor空间以及老年代空间是探索提升应用吞吐量的一种办法。关闭自适应大小会改变应用的程序的灵活性,尤其是在修改应用程序,以及随着时间的推移应用的数据发生了变化。
使用参数-XX:-UseAdaptiveSizePolicy
来关闭自适应调节策略,这时我们可以使用参数-XX:+PrintAdaptiveSizePolicy
来输出关于Survivor空间占用更详细的信息,Survivor空间是否溢出,对象是否从新年代移动到老年代等信息。
下面这个例子的启动参数为:
对应的GC日志如下:
以GCAdaptiveSizePolicy开头的一些额外信息输出来了,survived标签表明To Survivor空间的对象字节数,promoted标签表示转移到老年代的字节数,而overflow表示新年代是否有对象溢出到老年代,换句话说,就是表明了To Survivor是否有足够的空间来容纳从Eden空间和From Survivor空间移动而来的对象。为了更好的吞吐量,期望在应用处于稳定运行状态下,survivor空间不要溢出。如果Survivor空间溢出,对象会再达到任期阀值或者消亡之前被移动到老年代。换句话说,对象过快的移动到老年代。频繁的Survivor空间溢出会导致FullGC。
从上面的GC日志我们可以看出,每次Minor GC时To Survivor空间的存活对象大约是1M(通过survived标签判断),通过之前的介绍我们知道,如果Survivor空间的使用率超过了参数-XX:TargetSurvivorRatio的值,虚拟机会在对象达到最大岁数之前把对象移动到老年代。要优化这个应用,要求Survivor的空间大小 * TargetSurvivorRatio的值要大于1M,即Survivor的空间应该调整为2M。
优化后的启动参数如下(调整Survivor大小的同时,最好保持其它区域的大小不变):
调整后的GC日志:
从优化后的GC日志可以看出,虽然偶尔会有对象转移到老年代,但是并不有发生溢出,说明对象老新年代向老年代的转率是高效的。后面我们可以直接调整老年代的空间大小来控制Full GC的频率和延迟时间,如果始终无法在频率和延迟时间之间找到合理的平衡,那就只能修改应用的部暑模型了。