Java JVM性能优化/调优

Java HotSpot为满足不同应用场景提供多种垃圾收集器,通用优化手段难以发挥JVM和应用最佳性能。

JVM调优根据运行时环境和应用对象生命周期特点有针对的调整JVM参数。

从基本概念入手,结合几个简单案例说明JVM优化过程。

JVM堆内存从逻辑上分为年轻代和年老代。

年轻代由新生区/伊甸园和两个交换区/幸存者空间构成。

大多数对象最初都在新生区中创建。

同一个时刻总有一个交换区是空的,存放GC时新生区和另一个交换区中存活的对象,直到对象变为垃圾和晋升到年老代。

注:上图中各代排列组合不包括并发收集器(cms)和G1

GC分类

Java各版本统计中可以看出,Java8依然是主流,本文围绕java 8 中GC进行讨论。

-XX:+UseSerialGC 串行垃圾收集器

-XX:+UseParallelGC 并行垃圾收集器

-XX:+UseConcMarkSweepGC CMS垃圾收集器

-XX:ParallelCMSThreads= CMS收集器 - 要使用的线程数

-XX:+UseG1GC G1 

查看应用当前GC

使用jps命令查看应用进程号:

jps

将进程号作为pid参数传给jmap:

jhsdb jmap --heap --pid 8512

打印出堆信息中包含GC信息:

Attaching to process ID 8512, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 11.0.1+13-LTS

using thread-local object allocation.
Garbage-First (G1) GC with 8 thread(s)

常规选项

-Xms 初始化堆大小

-Xmx 堆空间允许最大值

-Xmn 年轻代大小

-XX:PermSize 永久空间初始值

-XX:MaxPermSize 永久空间允许最大值

JVM监控工具

GCViewergchistojdk命令行调试工具、Java VisualVM。

GC可调整项

GC可调项大致分为三个方面:

Garbage collector:垃圾收集器。

Heap size:堆空间大小。

Runtime compiler:运行时编译(JIT)。

一般最新版本JVM通常不需要对JIT作调整。

GC性能指标

GC性能指标通常有三个:

最大暂停时间、应用吞吐量、内存占用。

暂停时间

GC回收内存时,暂停应用所持续时间。

吞吐量

未花费在GC上的总时间百分比。

吞吐量包括分配中花费的时间(通常不需要对分配速度进行调优)。

内存分配占比

如果已满足吞吐量和最大暂停时间目标,GC会减小堆大小,直到无法满足其中一个目标,然后,再调整以解决未达到的目标。

更多可参考关于jvm堆分配问题

不同应用对GC要求不同。

如,有人认为衡量web服务器指标是吞吐量,垃圾收集期间暂停是可容忍的,或者会被网络延迟掩盖。

通常,会针对某一代内存大小进行调整。

如,一个非常大的年轻代可能会最大化吞吐量,但以内存占用、及时性(promptness)和暂停时间为代价。

使用小的年轻代以牺牲吞吐量为代价,但可最小化年轻代暂停时间。

通常,对某一代内存大小的选择,以不影响另一代收集频率和暂停时间为参考。

注:及时性是指从对象变为垃圾到内存可用的间隔时间,是分布式系统一个重要考虑因素,包括远程方法调用(RMI)。

调整策略

除非明确知道所需最大堆,否则,不要使用堆最大值选项调整默认值。

如果堆内存使用已达最大化,而吞吐量未被满足,说明堆最大值过小。

将最大值设置为接近平台上物理内存总量,但不足以引发内存交换(swapping)。

再次执行应用程序,吞吐量仍然没达标,说明对应用程序时间目标的期望,超出实际可用内存(期望过高)。

如果吞吐量目标实现,暂停时间太长,选择一个最大暂停时间目标。

选择最大暂停时间目标意味着吞吐量目标将无法满足,因此,选择对应用程序而言可接受的折中值。

测量/Measurement

针对应用程序制定度量标准,测量吞吐量和内存占用。

如,使用客户端负载生成器测试web服务器吞吐量,使用pmap命令在操作系统上测量服务器内存空间。

同时,检查JVM自身的诊断输出。

如:打印GC信息。

-verbose:gc

输出:

[GC 325407K->83000K(776768K), 0.2300771 secs] 
[GC 325816K->83372K(776768K), 0.2454258 secs] 
[Full GC 267628K->83769K(776768K), 1.8479984 secs]

示例说明:

两次minor GC和一次major GC,箭头前后数字表示垃圾收集前后活动对象总大小。

在minor GC中箭头后的数字,包含不能被回收但不一定存活的对象,对象存活时间可能恰好与GC重叠,或者被其他分代中的对象引用。

小括号中数字为堆的可用空间(不包含永久代),减去一个交换区/幸存空间的值。

最后时间是GC耗时,大约四分之一秒。

注:-verbose:gc输出格式不同版本间会有所变化。

年老代进行收集GC称为major GC,年轻代为Minor GC,Full GC被定义成对整个堆的收集,通常jvm GC实现将Full GC和major GC划等号。

GC详细信息

查看GC详细信息使用如下参数:

-XX:+PrintGCDetails

GC日志

[GC [DefNew: 64575K->959K(64576K), 0.0457646 secs] 196016K->133633K(261184K), 0.0459067 secs]]

示例说明:

DefNew:64575K-> 959K(64576K):minor GC对年轻代空间回收达到98%,耗时0.0457646秒。

196016K-> 133633K(261184K):堆的使用率降低到约51%。

0.0459067秒:超出年轻代GC实际用时,收集过程出现一些额外开销。

GC时间戳

-XX:+PrintGCTimeStamps

调整后的GC日志

111.042: [GC 111.042: [DefNew: 8128K->8128K(8128K), 0.0000505 secs]111.042: [Tenured: 18154K->2311K(24576K), 0.1290354 secs] 26282K->2311K(32704K), 0.1293306 secs]

示例说明:

开始执行应用程序大约111秒。

minor GC大约在同一时间开始。

18154K->2311K(24576K):年老代major GC信息,年老代使用减少约10%。花大约0.13秒。

分代/Generations

当一个对象无法通过应用程序指针访问时,被定为垃圾。

最直接的垃圾收集算法是遍历每个可访问对象,未被找到的对象成为垃圾。

这种方法耗时与存活对象数量成正比,对拥有大量活动对象的大型应用是不可接受的。

从J2SE 1.2开始虚拟机引入多种垃圾收集算法,算法将分代收集组合在一起。

简单垃圾收集器会检查堆中每个活动对象,分代收集器利用大多数应用程序都具有的特点避免额外工作。

应用程序一个重要特点是新生代死亡率。

通常很多对象在创建不久后就成了垃圾,还有一些对象在初始化时创建,直到进程退出。

观察发现,将回收工作集中在“年轻代”上,可提高GC效率。

优化此类场景,内存按代进行管理。

将不同年龄对象存放在不同内存区域进行管理。

垃圾收集发生在各个分代中。

对象在新生/伊甸(eden)区被创建,大多数对象都死在这里。

当年轻代被填满时,会发生一次小规模垃圾回收。

如果新生代对象死亡率较高,可优化Minor GC。

这种GC成本与所收集活动对象数量成正比。

如果年轻代充满垃圾对象,收集起来会非常快。

一些幸存对象被移动到年老代。

当需要收集年老代时会启动major GC,major GC通常会比较慢,它涉及所有活动对象。

年轻代包括新生区/伊甸园(eden)和两个交换区/幸存空间(survivor spaces)。

对象最初是在eden中分配,其中一个交换区为空,GC将新生区和另一个交换区中存活对象复制到空交换区,经多轮交换后,存活对象被复制到年老代。

查看GC执行过程,启动时加入以下参数:

java -XX:+PrintGCDetails -jar simple.jar

分代调整

许多参数会影响分代大小,下图说明了堆中已提交空间和虚拟空间的差异

-Xmx选项指定堆的总大小。

-Xms决定已提交空间的大小。

NewRatio表示年老代与年轻代相对比例。

GC发生在分代区域被对象“填满”时,吞吐量与可用内存大小成反比。

总可用内存影响GC性能。

默认JVM根据每次GC上活动对象的多少调整堆大小,以求空闲空间比例保持在指定范围内。

该范围由以下参数指定,总大小以-Xms为下限和-Xmx为上限:

-XX:MinHeapFreeRatio= <minimum>
-XX:MaxHeapFreeRatio=<maximum>

大型应用在默认值方面经常会有两个问题,一是慢启动,初始堆很小,须经过多次major GC调整 。

另一个问题,大多服务端应用程序,默认最大堆通常比较小。

服务端应用程序经验法则:

除非对暂停时间有明确要求,否则尝试更多内存,默认值通常太小。

将-Xms和-Xmx设为相同值,提高可预测性。

若作出一个糟糕的选择,JVM通常也补偿不了。

确保随处理器数量增加适当增加内存,以发挥并行化分配能力。

JVM参数手册

注:Java1.5为JVM引入人机工程概念,根据环境参数和应用特点作自动匹配调整,不影响显式参数。

年轻代

对jvm影响较大参数是年轻代内存大小,年轻代空间越大,minor GC发生次数越少。

对于有限内存加大年轻代意味着年老代空间减少,意味着major GC次数增加,最佳选择取决于应用中对象生命周期分布。

默认年轻代大小由NewRatio参数控制。

如,设置-XX:NewRatio=3,意味着年轻代与年老代间比例为1:3。

换句话说,新生区/伊甸园和交换区/幸存者空间整体大小是堆大小的四分之一。

NewSize和MaxNewSize限制年轻代大小。

将它们设成相同值,比精细化调整更有用。

交换区/幸存者空间

SurvivorRatio参数用于调整交换区/幸存者空间大小,但对性能影响通常不那么明显。

如,-XX:SurvivorRatio=6,将新生区(eden)与交换区比率调整为6:1。

每个交换区将占年轻代空间八分之一 (有两个交换区)。

若交换区太小则GC复制会直接溢出到年老代。

若交换区太大将是浪费。

在每次GC时虚拟机会选一个阈值,即新生代中对象的年龄。

如下参数对于观察应用程序中对象生命周期分布很有用。

-XX:+PrintTenuringDistribution

年轻代最大值根据堆总大小和NewRatio参数最大值计算。

MaxNewSize默认值not limited,意味此值不受限制,除非在命令行上明确指定MaxNewSize值。

服务器应用程序经验法则:

先确定可为JVM提供的内存总量,根据年轻代大小定制合理的性能指标,然后,找出最佳设置。

除非出现过多major GC或过大暂停时间,否则,应为年轻代分配尽可能多的内存。

将一半或更少的堆空间分配给年轻代,通常会出现反效果。

随着处理器数量增加,一定要增加年轻代,让分配可以并行化。

串行收集器

串行收集器使用单线程执行垃圾收集工作,消除了线程间通信开销,它相对有效。

因无法利用多处理器硬件,所以适合单处理器机器。

对具有小数据集(最大约100 MB)的应用非常有用,使用该GC需显式调用以下参数:

-XX:+UseSerialGC

并行收集器/吞吐量收集器

并行收集器是类似于串行收集器的分代收集器,区别在于使用多线程加速执行major GC和minor GC。

使用此GC可减少垃圾收集开销,该GC启用参数:

-XX:+UseParallelGC

-XX:+UseParallelGC

-XX:+UseConcMarkSweepGC

不能一起使用。java 1.4.2后引入参数校验。

在明确知道收集器特性前,始终让JVM自行选择收集器及堆大小。

应用场景

若采用大量处理器提高应用性能,可选择吞吐量收集器。

默认吞吐量收集器minor GC使用线程数与CPU数保持一致。

可使用命令行选项控制垃圾收集器线程数:

 -XX:ParallelGCThreads=<desired number>

使用吞吐量收集器目的在于让minor GC暂停时间更短。

GC线程会在年老代为可能晋升的对象预留一部分空间(“promotion buffers”),这种做法导致内存碎片效应。

减少垃圾回收器线程数将减少这种碎片效应。

最大暂停时间期望值

-XX:MaxGCPauseMillis = <NNN>

吞吐量期望值

-XX:GCTimeRatio = <NNN>

 1 / (1 + <N>),

如,-XX:GCTimeRatio=19垃圾收集总时间为1/20或5%。默认值为99,垃圾收集时间为1%。

二者间优先顺序:

最大暂停时间目标、吞吐量目标。

吞吐量收集器尽量向期望值靠拢,默认没有最大暂停时间的目标。

吞吐量收集器将调整java堆和相关参数,尝试让垃圾收集暂停时间小于期望值。

这些调整可能导致垃圾收集器降低应用程序总吞吐量,并在某些情况下可能两者都无法满足。

调整分代大小

收集器保存统计信息(如,平均暂停时间)在GC结束时更新。

确认目标是否满足,然后,对分代大小进行调整。

调整分代内存采用增量方式,增量是分代大小的固定百分比。

增长和减少以不同比例进行。

默认分代以20%的增量增长,以5%的增量缩小。

增长和缩小百分比由如下参数决定:

-XX:YoungGenerationSizeIncrement=<nnn > 
-XX:TenuredGenerationSizeIncrement=<nnn>

吞吐量未满足可加大以上参数。

-XX: AdaptiveSizeDecrementScaleFactor=<nnn >

暂停时间不达标可加大此参数。

堆大小

未明确设置时,jvm会根据物理内存的大小计算堆的初始值和最大值。

若平台物理内存大小为phys_mem,则堆初始大小为phys_mem/DefaultInitialRAMFraction。

DefaultInitialRAMFraction是一个命令行选项,默认值64。

同样,堆最大值将被设置为phys_mem/DefaultMaxRAM。

DefaultMaxRAMFraction默认值为4。

并行压缩

并行压缩是并行收集器在行执行major GC时,使用并行线程对空间进行压缩。

若没有压缩major GC过程为单个线程。若开启UseParallelGC选项,则默认启用并行压缩。关闭压缩参数如下:

-XX:-UseParallelOldGC

并发收集器

大多数并发收集器都为防止垃圾收集暂停。

用于大中型应用,对响应时间比总吞吐量重要的场景。

最小化暂停的技术会降低应用性能。

Java HotSpot VM提供两个并发收集器,以下选项启用其一:

-XX:+UseConcMarkSweepGC

CMS收集器。

-XX:+UseG1GC

G1收集器。

除非对应用暂停时间有严格要求,否则首选项是让JVM自行选择收集器。

如有必要,调整堆大小以提高性能。

若性能仍无法达标,使用以下几点作为参考作为调整垃圾收集器的依据。

应用具有较小数据集(最大约100 MB),选择串行收集器-XX:+UseSerialGC。

应用在单个处理器上运行且对暂停时间没要求,让JVM自行选择或选择串行收集器-XX:+UseSerialGC。

应用程序性能为第一优先级,且对暂停时间无要求(或1秒或更长)让JVM自行选择或选择并行收集器-XX:+UseParallelGC。

响应时间比总体吞吐量更重要,且垃圾收集暂停必须保持在大约1秒内,选择cms或GC并发收集器。

若推荐收集器未达到性能,应先尝试调整堆和分代大小满足目标。

若性能仍不达标,尝试使用不同收集器:

使用并发收集器减少暂停时间;

使用并行收集器提高多处理器硬件总吞吐量。

并发低暂停收集器(CMS)

若应用能从暂停时间中受益,可尝试并发低暂停收集器。

通常,对具有较多长生命周期对象的应用,且拥有两颗以上CPU的机器,选择此类收集器较为适合。

此收集器尝试减少年老代GC暂停时间。

在major GC中使用单独的线程与应用程序同步执行。

启用参数:

XX:+UseConcMarkSweepGC

对于每次major GC,并发收集器将在GC开始和中间,短暂地暂停应用程序线程。

第二次暂停往往是两个暂停中较长的,在暂停期间使用多个线程执行收集工作。

收集工作的其他部分与应用程序并行。

虽使用多个线程执行收集,但minor GC工作方式与串行收集器相似。

有关此GC的特点参阅以下面几项说明。

并发开销

并发收集器会占用一部分应用CPU资源,以缩短major GC暂停时间。

年轻代

若启动年轻代GC,发现年老代中没有足够空间容纳从年轻代新晋级的对象,并发收集器会选择恢复操作,类似于吞吐量收集器。

Full GC

应用程序线程和GC线程在major GC期间并发执行,GC线程发现存活对象可能在收集完成时变成了垃圾,这些对象称为浮动垃圾。

浮动垃圾的数量取决于并发GC运行时长及应用程序自身。

有一个粗略的经验法则,尝试将年老代内存增加20%。

两次暂停 

并发收集器在并发收集周期中暂停应用程序两次。

第一次将从根直接到达的对象标记为存活,称为初始标记(initial mark)。

第二次暂停出现在标记阶段末尾,查找在并发标记阶段由于应用程序线程并发执行漏掉的对象,第二次停顿称为“remark”。

并行阶段

并发标记发生在initial mark和remark之间。

并发标记阶段GC线程会抢占一部分应用程序资源。

在remark后有一个并行的扫描阶段,收集垃圾对象。

在此阶段,并发垃圾收集器线程再次抢占应用程序CPU资源。

在清除阶段过后,并发收集器休眠,直到下一个major GC开始。

设置GC

避免年老代被“填满”时被动GC,有几种方法可主动启动并发GC。

并发收集器统计剩余时间(T-until-full)和执行并发收集所需时间(T-collect)。

当T-until-full接近T-collect时,将启动一个并发收集。

对这个测试进行适当的补充,以便尽早开始一次保守GC。

若年老代占用率高于初始占用率(即,在启动并发GC之前对当前堆的占用百分比)。

默认设置为68%左右。

可使用如下参数设置:

-XX:CMSInitiatingOccupancyFraction=<nn>

停顿安排/调度

年轻代和年老代GC期间暂停分别发生不能重叠,但它们可以快速连续地发生,这样一个GC暂停之后紧接着另一个GC暂停,看起来拉长了暂停时间。

为避免这种情况,并发GC的remark暂停被安排在上一代和下一代暂停之间。

initial mark暂停通常太短,不值得进行安排。

增量模式

并发GC能以增量方式完成并发阶段的工作。

增量模式目的是通过周期性地停止并发阶段的执行,将处理器交还给应用程序,减少并发阶段时长的影响。

这种模式(“i-cms”)将收集器并发完成的工作划分为多个时间片,这些时间片安排在年轻代收集之间。

当应用程序运行在具有少量处理器,又需并发收集器提供低暂停时间,此特性非常有用。

并发收集周期通常包括以下步骤:

停止所有应用程序线程做初始标记;

恢复所有应用程序线程。

执行并发标记(使用一颗处理器进行并发工作)。

并发预清理(使用一颗处理器进行并发工作)。

停止所有应用程序线程,进行remark;

恢复所有应用程序线程。

进行并发扫描(使用一颗处理器进行并发工作)。

并发重置(使用一颗处理器进行并发工作)。

通常,并发收集器在整个并发标记阶段使用一颗处理器处理并发。

一颗处理器用于整个并发扫描阶段。

对暂停时间有限制的应用,特别是在只有一或两颗处理器的系统上,这种处理器利用率可能会造成很大干扰,i-cms通过分解并发阶段任务解决此问题。

内存不足异常

如果GC花费太多时间,吞吐量收集器将抛出内存不足的异常。

如,JVM GC时间占用超过总时间98%,且正在恢复的堆少于2%,则会导致内存不足。

G1垃圾收集器

关于G1调整参考G1最佳实践部分。

案例分析

以下为具体案例分析。

Minor GC和Major GC频繁

通过加大新生代空间降低Minor GC频率,加大内存直接影响GC间隔周期,若一个对象生命周期,在下一个GC到来前结束,GC过程中需要搬运的存活对象就相对减少。

新生代大小参数,结合应用中对象生命周期特征,以及预期最大延迟时间,进行合理设置。

若应用中存在大量生命周期较短对象,合理加大新生代空间,以减少晋升到年老代数量,Major GC频率得到改善。

若存在较多长生命周期对象,需增加年老代空间。

对象晋升到年老代

Hotspot会动态计算晋升阀值,新生代对象经历若干次Minor GC后,新生代中的单个交换空间被消耗掉50%,Hotspot会从新生代对象中,挑出年龄最小的值做为晋升阀值。

如:当survivor空间就被对象消耗掉50%时,若此时survivor空间中的对象至少都经历5次或以上的Minor GC,那5就成为了晋升阀值。

Major GC时间过长

Major GC默认使用CMS收集器,CMS收集器GC分为四个阶段:

1、Init-mark初始标记(STW)

该阶段进行可达性分析,标记GC ROOT能直接关联到的对象,所以很快。

2、Concurrent-mark并发标记

由前阶段标记过的对象出发,所有可到达的对象都在本阶段中标记。

3、Remark重标记(STW)

暂停应用程序,重新扫描堆中对象,进行可达性分析,标记存活对象。

并发标记阶段应用程序没有停止,对象生命状态有可能会发生变化,这个阶段以新生代中对象为根,进行对象存活判断。

4、并发清理

并发垃圾清理工作。

以上四个阶段中第三阶段Remark标记,最影响应用程序响应时间,因为”跨代引用“问题,这个阶段需进行全“堆”扫描。

为减少Remark阶段中垃圾对象数量,在执行Remark前,会执行一次可中断的Minor GC,Minor GC的开始取决于Eden区使用情况,默认Eden区使用超过2M时,会启动Minor GC。

为防止Remark阶段前的Minor GC迟迟不肯到来,默认5s预清理时间一到,不管有没有发生Minor GC,都会中止此阶段,直接进入Remark阶段。

CMSMaxAbortablePrecleanTime用于设置预清理的时间。

对于没等到Minor GC就开始标记的情况,CMS提供CMSScavengeBeforeRemark参数,用于保证Remark前强制进行一次Minor GC。

这也是调整Major GC时间过长的关键。

JVM的card table用于防止年老代持有新生代对象引用时,导致Minor GC扫描全堆,使用card table记录此类引用,避免全堆扫描。

频繁Full GC

触发Full GC的两种情况,在Full GC时应用程序被暂停执行。

concurrent-mode-failure:

CMS收集垃圾过程中,对象从新生代晋级到年老代,年老代空间不足引发Full GC。

promotion-failed:

年轻代GC时,大量对象存活,空的交换区不能容纳新生代和另一个交换区中存活的对象,存活对象被迫晋升到年老代,此时年老代空间也无法容纳,引发Full GC。

Full GC表象源于年老代空间不足,引发年老代空间不足的原因,通常来自大量对象被晋升到年老代。

年老代空间占用达到80%,CMS收集器工作,期间又有大量新对象被晋升,出现concurrent mode failure引发Full GC。

可在年老代空间达到50%时就让CMS收集器工作,缓决CMS收集过程中发生大量失败:

-XX:CMSInitiatingOccupancyFraction=50

减少预清理阶段暂停时间过长

-XX:CMSMaxAbortablePrecleanTime=500

CMS收集器默认不会执行内存整理(compact),通过如下参数开启内存整理,并控制内存整理频率:

-XX:+UseCMSCompactAtFullCollection
-XX:CMSFullGCsBeforeCompaction=4

注:示例为经历4次 Full GC进行一次整理。 加大年老代空间,适当调小eden区可以缓解CMS在收集过程中,年老代拥入大量新对象。

通过指定内存占用比调整触发GC的阀值:

-XX:+UseCMSInitiatingOccupancyOnly
-XX:CMSInitiatingOccupancyFraction

若设置UseCMSInitiatingOccupancyOnly参数,年老代内存占用达到CMSInitiatingOccupancyFraction指定比例时触发CMS GC。

若未设置UseCMSInitiatingOccupancyOnly参数,JVM根据统计数据自行决定触发阀值,因此会发生明确设置了CMSInitiatingOccupancyFraction参数,但未见生效的情况。