JVM性能优化(调优)

Java HotSpot为满足不同应用场景提供了多种垃圾收集器,并选择一些通用手段优化JVM。这种选择往往不具针对性,难以发挥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 >

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

堆大小

若未明确设置,会根据物理内存的大小计算堆的初始值和最大值。如果平台上物理内存的大小为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。

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

应用程序性能为第一优先级,且对暂停时间没要求(或1秒或更长)让VM自行选择或选择并行收集器-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频率得到改善。如果存在较多的长生命周期的对象,需增加年老代空间。

新生代对象经历多少次Minor 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参数,但未见生效的情况。