随着Java应用地运行,可能会出现一系列性能问题,如系统吞吐量低,系统延迟高等,这些通常会直接影响用户体验,造成用户流失,严重时,系统将会崩溃,为了避免这样的情况发生在线上,通常会在上线系统前作一些指标测试,如吞吐量,延迟等,当然影响这些指标的因素可能会有很多,如计算复杂度,网络问题,但本文将仅从JVM的垃圾收集调优讲起。
通常,在系统调优中,都会涉及几个指标:
指标项 | 描述 |
---|---|
延迟时间(Latency) | 延迟时间指的是完成一个操作所需要的时间。 |
吞吐量(Throughput) | 吞吐量指的是单位时间内完成了多少次某项操作。 |
容量(Capacity) | 容量指的是完成某项操作使用的资源容量。 |
在日常开发中,经常会有这样的需求,要求用户的某个操作,必须在1秒内得到响应,这个时候首先需要确认的是,垃圾收集(GC)造成的暂停时间是否占用得过多,若GC造成的暂停时间仅几十毫秒,则可以尝试从其他层面去作优化处理,比如外部数据源,锁争用等问题,事实证明,这些问题可能会比GC问题出现得更多。假设,现在的性能需求是:90%的操作的响应时间必须小于1000ms,没有任何操作的响应时间大于10000ms,并假设GC停顿占用的时间不能超过10%,即90%的GC暂停时间不能超过100ms,没有一次GC的暂停时间超过1000ms。如下面的GC日志:
本次GC的暂停时间为0.0713174 secs,尽管耗费的总CPU时间为0.21 secs,但我们更专注的是应用暂停时间,由于采用的是并行垃圾收集器,真实耗时仅0.07 secs,满足了延迟性能需求。
与延迟时间不同,吞吐量更关注垃圾收集(GC)在应用运行时所占用的时间比。假设,现在性能的需求是:系统每分钟处理1000次事务,要求GC占用的总时间不能超过10%,即6s。如上面的GC日志,一次GC耗时0.07 secs,若一分钟内的GC次数 * 70ms不超过6000ms,也满足了吞吐量性能需求。
容量要求对运行时环境作出更改(如调整内存大小,限制成本等),以达到延迟和吞吐量的性能需求。
通过以下VM参数,可看出类似的GC日志信息:
上面的GC日志表明,使用的是并行垃圾收集器(Parallel Garbage Collector),并且从日志可以看出,Young GC和Full GC耗时都比较长,达到了100ms级别,下面将从不同维度进行一些简单调优。
假如,现在需求是,大部分GC的暂停时间必须小于100ms,最简单直接的做法,尝试将默认是并行垃圾收集器改为CMS垃圾收集器,即:
在JVM各类GC日志剖析一文中可知,在使用CMS垃圾收集器时,Young GC阶段和Old GC的CMS-initial-mark,CMS-initial-mark,Rescan,Remark阶段是以Stop-The-World的形式执行,这样的确降低不少GC的暂停时间,但由于使用CMS后,可能导致GC次数上升,单位时间内,GC的总时间或许会占用更多,因此延迟时间(Latency)和吞吐量这两个维度的性能指标本身就是相生相克,但对于面向用户的应用,通常更注重延迟时间的性能指标。
如上面所述,当使用并行垃圾收集器(-XX:+UseParallelGC)时,虽然每次GC造成的应用暂停时间增加,但单位时间内,GC次数减少,GC的总时间或许会占用更少,因此保证了更多的CPU时间在执行应用程序,这种场景则比较适合非面向用户的应用,如一些后台计算任务,离线分析应用等。
容量作为应用的一些硬性限制,如内存,CPU等。针对容量调优,作得较多可能就是调整JVM堆大小,JVM堆分布等,如当需要适当减少比较耗时的Full GC次数,可以适当增大老年代,又或者适当增加对象晋升年龄(-XX:MaxTenuringThreshold=n)等。
在进行GC性能优化之前,通常需要借助一些工具来查看GC活动信息(如当前内存占用情况,内存容量,单个GC持续时间,GC不同阶段的持续时间等),从而诊断GC如何影响应用程序的性能,下面将介绍一些常用的工具。
JMX作为最基本的获取GC信息的方式,其也是JVM暴露内部运行时状态的标准方式,开发人员可以在应用程序内部使用JMX API,或者通过JMX Client进行远程访问,比较熟悉的JMX Client则是JConsole和JVisualVM(两者均通过安装对应的插件),jdk7u40之后有了另一个第三方工具Java Mission Control(可通过jmc命令运行)。为了通过JMX Client远程连接到目标JVM进程,需要在启动目标JVM进程时,开启jmx远程端口:
初次运行jvisualvm,需要安装MBean插件VisualVM-MBeans,Mission Control已经自带MBean插件:
在java.lang.GarbageCollector目录下,能看到当前JVM正在使用的垃圾收集器,并且还暴露了一些其他信息:
相比标准JMX Client提供的JVM基本信息,JVisualVM通过VisualGC插件,能够实时获取不同区GC的详细信息,如:
有些时候可能不能直接使用JConsole,JVisualVM等界面工具分析Java应用,这个时候jstat则可以发挥作用,其可以在命令直接监控JVM目标进程,并且提供较多可选项,如
可选项 | 样例 | 描述 |
---|---|---|
class |
类加载器相关的信息:
Loaded:加载的类的数量;
Bytes:加载的字节数;
Unloaded:卸载的类的数量;
Bytes:卸载的字节数;
Time:执行类加载和卸载的耗费时间。
|
|
compiler |
JIT编译器相关的信息:
Compiled:完成编译的次数;
Failed:编译失败的次数;
Invalid:编译非法的次数;
Time:编译的时间;
FailedType:编译失败的类型;
FailedMethod:最近一次编译失败的类及其方法名。
|
|
gc |
垃圾收集相关的信息:
S0C:Survivor 0区总内存;
S1C:Survivor 1区总内存;
S0U:Survivor 0区使用内存;
S1U:Survivor 1区使用内存;
EC:Eden区总内存;
EU:Eden区使用内存;
OC:Old区总内存;
OU:Old区使用内存;
PC:Perm区总内存;
PU:Perm区使用内存;
YGC:Young GC次数;
YGCT:Young GC总时间;
FGC:Full GC次数;
FGCT:Full GC总时间;
GCT:GC总时间。
|
|
gccapacity |
分代容量及对应的区信息:
NGCMN:年轻代的最小内存;
NGCMX:年轻代的最大内存;
NGC:年轻代的当前内存;
S0C:Survivor 0区总内存;
S1C:Survivor 1区总内存;
EC:Eden区总内存;
OGCMN:老年代的最小内存;
OGCMX:老年代的最大内存;
OGC:老年代的当前内存;
OC:老年代的总内存;
PGCMN:永久代的最小内存;
PGCMX:永久代的最大内存;
PGC:永久代的当前内存;
PC:永久代的总内存;
YGC:Young GC次数;
FGC:Full GC次数。
|
|
gccause |
垃圾收集统计信息(类似gcutil),及最近和当前的垃圾收集的触发事件信息:
S0:Survivor 0区的占用率;
S1:Survivor 1区的占用率;
E:Eden区的占用率;
O:Old区的占用率;
P:Perm区的占用率;
YGC:Young GC次数;
YGCT:Young GC总时间;
FGC:Full GC次数;
FGCT:Full GC总时间;
GCT:GC总时间;
LGCC:最近一次GC的原因;
GCC:当前GC的原因。
|
|
gcnew |
年轻代相关的统计信息:
S0C:Survivor 0区总内存;
S1C:Survivor 1区总内存;
S0U:Survivor 0区使用内存;
S1U:Survivor 1区使用内存;
TT:对象晋升阈值;
MTT:对象最大晋升阈值;
DSS:期望的Survivor区内存大小;
EC:Eden区总内存;
EU:Eden区使用内存;
YGC:Young GC次数;
YGCT:Young GC总时间。
|
|
gcnewcapacity |
年轻代大小及对应的空间信息:
NGCMN:年轻代的最小内存;
NGCMX:年轻代的最大内存;
NGC:年轻代的当前内存;
S0CMX:Survivor 0区最大内存;
S0C:Survivor 0区当前内存;
S1CMX:Survivor 1区最大内存;
S1C:Survivor 1区当前内存;
ECMX:Eden区总最大内存;
EC:Eden区当前内存;
YGC:Young GC次数;
FGC:Full GC次数。
|
|
gcold |
老年代及永久代相关的统计信息:
PC:永久代总内存;
PU:永久代使用内存;
OC:老年代总内存;
OU:老年代使用内存;
YGC:Young GC次数;
FGC:Full GC次数;
FGCT:Full GC总时间;
GCT:GC总时间。
|
|
gcoldcapacity |
老年代大小相关的信息:
OGCMN:老年代的最小内存;
OGCMX:老年代的最大内存;
OGC:老年代的当前内存;
OC:老年代的总内存;
YGC:Young GC次数;
FGC:Full GC次数;
FGCT:Full GC总时间;
GCT:GC总时间。
|
|
gcpermcapacity |
永久代大小相关的信息:
PGCMN:永久代的最小内存;
PGCMX:永久代的最大内存;
PGC:永久代的当前内存;
PC:永久代的总内存;
YGC:Young GC次数;
FGC:Full GC次数;
FGCT:Full GC总时间;
GCT:GC总时间。
|
|
gcutil | 垃圾收集相关的总结信息:可参见gccause的描述。 | |
printcompilation |
编译方法相关的信息:
Compiled:完成的编译次数;
Size:方法的字节数;
Type:编译类型;
Method:编译的类及其方法名。
|
GC日志作为跟踪GC活动最常用的手段,开发人员可以在启动Java应用前,配置GC日志文件,在JVM各类GC日志剖析一文中已分析了各类GC日志,可供参考:
hprof作为JDK自带的工具,可以用于收集CPU使用和堆使用信息,如:
默认会在Java应用当前工作目录生成java.hprof.txt文件:
可见,99.21%的对象类型为byte[],可跟踪到TRACE 307974:
可以看到byte[]在GCTuningTest的33行被创建:
分配率指的是单位时间内,执行内存分配的次数,比如MB/sec。
从上面的GC日志可以计算出对象分配率:
GC事件 | 发生时刻 | Young GC前 | Young GC后 | 分配的内存大小 | 分配率 |
---|---|---|---|---|---|
1st GC | 291ms | 33280KB | 5088KB | 33280KB | 114MB/sec:[33280KB / 291ms] |
2nd GC | 446ms | 38368KB | 5120KB | 33280KB | 215MB/sec:[33280KB / (446ms - 291ms)] |
3rd GC | 829ms | 71680KB | 5120KB | 66560KB | 174MB/sec:[66560KB / (829ms - 446ms)] |
Total | 829ms | N/A | N/A | 133120KB | 161MB/sec:[133120KB / 829ms] |
从上面的数据,可以看出内存分配率大概为161M/sec。那么内存分配率与哪些因素相关呢?由于新对象都在年轻代的Eden分配,因此可以判定内存分配率会与Eden大小有关,只要减少Minor GC次数,应用线程有更多的时间的分配对象,因此适当增加Eden大小(通过-XX:NewSize -XX:MaxNewSize & -XX:SurvivorRatio等参数)是可以提升内存分配率的。如下面的代码片段,当设置增加-XX:NewSize时,内存分配率也得到提升:
将JVM参数设置为-Xmx32m -XX:+PrintGCDetails -XX:+PrintGCTimeStamps,运行程序后:可以看出Young GC发生得十分频繁,每秒发生近70次,并且每次Young GC后内存占用都很少,并为引起Full GC,这导致GC时间增多,从而应用吞吐量下降,需要降低GC次数,此时可以尝试增加堆大小,如-Xmx128m:
可见,适当增加堆大小可以降低GC频率,从而也会提高应用吞吐量,但这可能并不会降低内存分配率,应当使用之前的工具,如hprof,分析具体的对象实例,从程序级别减少对象分配。如上面的代码,之所以发生频繁GC,主要是sensorValue = Math.random()不断地产生随机数,并装箱为Double类型,若将sensorValue声明为double基本类型,将不会产生新对象,仅仅是重写sensorValue的内存值,因此不会触发GC,当然对于这类情况,JIT会通过逃逸分析技术有可能做一些内部优化。
对于对象晋升,需要建立在晋升率之上,即单位时间内从年轻代晋升到老年代的数据量,通常也是MB/sec。对于存活时间长的对象,从年轻代晋升到老年代,这是正常的,但对于那些存活时间并不长的对象被提早晋升到老年代时就需要注意了,这会导致老年代内存占用增加过快,触发更多的Full GC,影响应用吞吐量,这就是对象过早晋升。
如上面的GC日志,可以计算出对象晋升率:
GC事件 | 发生时刻 | 年轻代占用减少 | 整个堆占用减少 | 对象晋升大小 | 对象晋升率 |
---|---|---|---|---|---|
1st GC | 291ms | 28192K | 8920K | 19272K | 约66.2MB/sec:[19272K / 291ms] |
2nd GC | 446ms | 33248K | 11400K | 21848K | 约140.95MB/sec:[21848K / (446ms - 291ms)] |
3rd GC | 829ms | 66560K | 30888K | 35672KB | 约93.14MB/sec:[35672KB / (829ms - 446ms)] |
Total | 829ms | N/A | N/A | 76792K | 92.63MB/sec:[76792K / 829ms] |
如通过下面的代码片段:
设置JVM运行参数-Xmx24m -XX:NewSize=16m -XX:MaxTenuringThreshold=1 -XX:-UseAdaptiveSizePolicy -XX:+PrintGCDetails -XX:+PrintGCTimeStamps:
可以看到老年代内存一直居高不下,导致Full GC频率过高,因为不断有年轻代对象晋升到了老年代,这时需要适当增加年轻代大小试试:
这里,除了通过增加年轻代内存大小,来降低对象晋升频率,还可以将max.chunks减小也可以达到类似的目的,还有些情况可能需要,如年轻代内存有较大空闲,但仍有对象晋升到老年代,则有可能是对象太大或存活周期太大,可以关注下参数-XX:PetenureSizeThreshold和-XX:MaxTenuringThreshold。
还有一个会影响GC的因素则是程序中的非强引用,即软引用(Soft Ref),弱引用(Weak Ref)及影子引用(Phantom Ref),它们具有各自的内存特性:
运行下面的代码片段,看看WeakReference如何影响GC:
运行该程序-Xmx24m -XX:NewSize=16m -XX:MaxTenuringThreshold=1 -XX:-UseAdaptiveSizePolicy -XX:+PrintGCDetails -XX:+PrintGCTimeStamps:
由于WEAK_REFS_FOR_ALL=false,object始终将被赋值为substitute,即最终所有WeakReference都指向同一个对象,其他申请的对象均为普通对象,因此这些对象将在Young GC阶段被回收,这就造成了频繁的Young GC。尝试将WEAK_REFS_FOR_ALL打开,-Dweak.refs=true:
这时,由于产生了很多WeakReference引用的对象,导致了偶尔频繁的Full GC,日常开发这样使用WeakReference是很常见的,比如用其作为Map中的Key等,所以还得尝试增加堆大小,-Xmx64m -XX:NewSize=32m,这将使得对象在Young GC就被回收。若将上面的WeakReference换为SoftReference,理论上将导致更频繁的Full GC,因为仅被SoftReference所引用的对象,只会在OutOfMemoryError之前被回收,可以通过-XX:+PrintReferenceGC查看引用对象的收集情况。
以上,则是有关JVM垃圾收集调优的基础,在调优之前,应事先明确应用性能是否由JVM GC引起,确定后在通过相关工具,定位具体的GC问题所在,再作参数调整和性能对比。以下是使用CMS时的VM配置Demo,必要是使用G1是不错的选择:
Java GC指南
Java垃圾收集
JVM GC调优
JMX
JConsole使用
JVisualvm使用
JVM配置选项