JVM内存与GC(13)- GC调优

最后更新:2020-01-13

1. GC参数

内存调整参数

JIT调整参数

分代比例和并发

GC选择

G1

TLAB

可以参考的健康GC标准

  1. YoungGC频率不超过2秒/次;
  2. CMS GC频率不超过1天/次;
  3. 每次YoungGC的时间不超过15ms;
  4. FullGC频率尽可能完全杜绝;

调优基础

调整堆的大小

GC停顿消耗的时间取决于堆的大小,如果增大堆的空间,停顿的持续时间也会变长,这种情况下,停顿的频率会变得更少,但是它们持续的时间会让程序整体性能变慢。

使用超大堆还有另一个风险:操作系统使用虚拟内存机制管理机器的物理内存。

SWAP:操作系统在需要时会将程序运行时不活跃的数据有内存复制到磁盘。再次需这部分内存的内容时操作系统会再将它们由磁盘重新载入内存。(为了腾出空间,通常它会先将另一部分内存的内容复制到磁盘)

当系统中运行着大量不同的应用程序时,这个流程工作得很流程,因为大多数的应用程序不会同时处于活跃状态。但是对于Java应用,它的工作并不那么好。如果一个Java应用试验了这个系统上大约12G的堆,操作系统可能在RAM上分配了8G的堆空间,另外4G空间存在磁盘。这样就好导致严重的性能问题,因为操作系统需要将相当一部分数据由磁盘交换到内存。

更糟糕的是,这种原本期望一次性的内存交换操作在Full GC时一定会再次重演,因为JVM必须访问整个堆的内容。如果Full GC时发生内存交换,停顿时间会以正常停顿时间数个量级的方式增长。类似地,如果使用并发收集器,后台线程回收堆是,它的速度也可能会被拖慢,因为需要等待从磁盘复制数据到内存,结果导致发生代价昂贵的并发模式失败。

堆的大小由下面参数控制

  • -Xms 初始值
  • -Xmx最大值

一个经验法则:在完成Full GC后,应该释放出70%的空间

将堆的初始值和最大值直接设置成一样,能稍微提高GC的运行效率,因为它不再序号估算堆是否需要调整大小了。

在调优时,尽量考虑通过调整GC算法的性能模板,而非调整堆的大小来改善性能。

代空间的调整

一旦堆的大小确定下, JVM就需要决定分配多少堆给新生代,多少给老年代。

如果新生代分配的比较大,垃圾收集发生的频率就比较低,而从新生代晋升到老年代的对象也更少。任何事物都有两面性,采用这种分配方法,老年代就相对较小,比较容易被填满,会更频繁地触发Full GC。这里找到一个平衡点是解决问题的关键。

参数

  • -XX:NewRatio 新生代内存容量与老生代内存容量的比例 Old Size/New Size,通过年老代和年轻代的比例和Heap Size就可以算出年老代的大小。默认为2
  • -XX:NewSize 新生代的初始大小,没有默认值,默认不设置的情况由NewRatio决定
  • -XX:MaxNewSize 新生代的最大大小
  • -Xmn 新生代的大小,将NewSize和MaxNewSize设置成同一个值的快捷方法

使用NewSize设定的新生代大小优先级要高于NewRatio计算出来的新生代大小。

试图通过制定新生代的最大值和最小值的方式调优新生代的结果是十分困难的。如果堆的大小固定,通常推荐使用-Xmn将新生代也设为固定值。如果硬要程序需要动态调整堆的大小,就需要关注NewRatio的设定。

永久代和元空间的调整

永久代或元空间内并没有保存类实例的具体信息(及类对象),也没有反射对象(例如方法对象),这些内容都保持在常规的堆空间内,永久代和元空间保持的信息只对编译器或者JVM的运行时有用,这部分信息也称为“类的元数据”。

到目前为止没有一个能提前计算出程序的永久代或元空间需要多大空间的好算法。它们的大小与程序使用的类的数量成比率向相关。

JDK 1.8 之前通常通过下面这些参数来调节方法区大小

  • -XX:PermSize 方法区(永久代)初始大小
  • -XX:MaxPermSize 方法区(永久代)最大大小

JDK 1.8 彻底移除了永久代,取而代之是元空间,元空间使用的是直接内存。

  • -XX:MetaspaceSize=N 设置Metaspace的初始(和最小大小)
  • -XX:MaxMetaspaceSize=N 设置Metaspace的最大大小

元空间的默认大小是没有限制的,因此Java8的应用可能由于元空间被填满而耗尽内存。如果限制的元空间的大小,在元空间达到最大值后会触发Full GC

通常情况下元空间大小最大值会设置为128M、192M或更多

并发控制

除serial收集器外几乎所有的垃圾收集器使用的算法都基于多线程。启动的线程数由-XX:ParallelGCThreads控制,对下面这些操作,这个参数值会影响线程数目:

  • 使用-XX:+UseParNewGC收集新生代空间;
  • 使用-XX:+UseParallelGC收集新生代空间
  • 使用-XX:+UseParallelOldGC收集老年代空间
  • 使用-XX:+UseG1GC使用G1收集器
  • CMS收集器的“时空停顿”阶段(非Full GC)
  • G1收集器的“时空停顿”阶段(非Full GC)

由于GC会暂停所有的应用程序线程,JVM为了尽量缩短停顿时间就必须尽可能的利用更多的CPU资源。默认情况下,JVM会在机器的每个CPU上运行一个线程,最多同时运行8个,一旦达到这个上限,JVM会调整算法,没超出5/8个CPU启动一个线程。所以总的线程数就是

ParallelGCThreads = 8 + ((N - 8) * 5 / 8)

如果机器上同时运行了多个JVM实例,限制所有JVM使用的线程总数是个不错的主意,这时垃圾收集线程运行起来会更高效,每个线程都能100%地利用各CPU的资源。在运行了多JVM的机器上,通常出现的问题是有太多的垃圾回收线程在同时并发运行。

CMS调优

并发模式失效的调优

调优CMS最要紧的工作就是避免发生并发模式失效和晋升失败。发生并发模式失效往往是CMS不能以足够块的速度清理老年代空间:新生代需要进行垃圾回收时,CMS计算发现老年代没有足够的空间可以容纳这些晋升对象,不得不对老年代进行垃圾回收。我们可以想办法增大老年代空间,要么只移动部分新生代对象到老年代,要么增加更多的堆空间,但这不是我们关注的重点。

让后台线程更多的运行机会

CMS在老年代空间占比达60%时启动并发周期,和在70%时才启动相比,前者完成垃圾收集的几率更大。为了实现这种配置,最简单的方法是同时设置下面两个标志

  • UseCMSInitiatingOccupancyOnly 关闭CMS的动态检查机制,只通过预设的阈值来判断是否启动并发收集周期
  • CMSInitiatingOccupancyFraction 老年代空间占用到多少的时候启动并发收集周期,跟UseCMSInitiatingOccupancyOnly一起使用,如果开启了UseCMSInitiatingOccupancyOnly,那么默认值是70

同时使用这两个参数能够帮助CMS更容易进行决策:如果同时设置这两个标志,那么CMS就只依据设置的老年代空间占用率来决定何时启动后台线程。

默认情况下UseCMSInitiatingOccupancyOnly=false,CMS会使用更复杂的算法判断什么时候启动并行收集线程。

CMSInitiatingOccupancyFraction参数的调整可能需要多次迭代才能确定。对特定的程序,该标志的更优值可以根据GC日志中CMS周期首次启动失败时的值得到。具体方法是在垃圾回收日志中寻找并发模式失效,找到后再反向查找CMS周期最近的启动记录。日志中含有CMS-initial-mark信息的一行保护了CMS周期启动时,老年代空间的占用情况

2020-01-16T15:51:36.009+0800: 1.997: [GC (CMS Initial Mark) [1 CMS-initial-mark: 0K(707840K)] 33761K(1014528K), 0.0061043 secs] [Times: user=0.02 sys=0.00, real=0.01 secs] 

调整CMS后台线程

每个CMS后台线程都会100%的占用机器上的一颗CPU,如果应用程序发送并发模式失败,同时又有额外的CPU周期可用,可用设置-XX:ConcGCThreads标志,增加后台线程的数量。如果该标志未设置,JVM会根据并行收集器中的-XX:ParallelGCThreads参数的值来计算出默认的并行CMS线程数。

ConcGCThreads = (3 + ParallelGCThreads) / 4

-XX:ParallelGCThreads标志不仅影响“stop-the-world”垃圾收集阶段,还影响并发阶段。

比如value=4意味着CMS周期的所有阶段都以4个线程来执行。尽管更多的线程会加快并发CMS过程,但其也会带来额外的同步开销。因此,对于特定的应用程序,应该通过测试来判断增加CMS线程数是否真的能够带来性能的提升。

调整这个标志的要点在于判断是否有可用的CPU周期。如果ConcGCThreads设置的偏大,垃圾收集会占用本来能运行应用线程的CPU周期,最终效果上这种配置会导致应用程序些微的停顿,因为应用程序需要等待再次在CPU上继续运行的机会。

在一个配备了大量CPU的系统上,ConcGCThreads的默认值可能偏大,如果没有频繁遇到并发模式失败,可以考虑减少后台线程数,释放这部分CPU周期用于应用线程的运行。

永久代调优

使用CMS的时候如果永久代需要进行垃圾收集,就会发送FULL GC(如果元空间的大小需要调整也会发送同样的情况)。这往往发生在程序员频繁部署(或重新部署)应用的服务器上,或者发现在需要频繁定义(或者回收)类的应用中

默认情况下CMS线程不会处理永久代中的垃圾,如果永久代空间用尽,CMS会发起一次FullGC来回收其中的垃圾对象,除此之外还可以开启-XX:+CMSPermGenSweepingEnable (默认false),开启后永久代中的垃圾使用与老年代同样的方式进行垃圾收集:通过一组后台线程并发地回收永久代的垃圾对象。

注意:触发永久代垃圾回收是治标与老年代的指标是相互独立的。使用-XX:CMSInitiatingPermOccupancyFraction=N参数可以知道CMS收集器在永久代空间占用比达到设定值时启动永久代垃圾回收线程,这个参数的默认值是80%。

不过开启永久代垃圾回收只是整个流程中的异步,为了真正释放不再被引用的类,我们还需要设置-XX:+CMSClassUnloadingEnabled标志,否则即使启用了永久代垃圾回收也只能释放少量的无效对象,类的元数据并部分被释放。

在Java8中,CMS收集器默认会收集元空间中不再载入的类,如果由于某些原因,你希望关闭这个功能,可以通过XX:-CMSClassUnloadingEnabled标志进行关闭。(默认是开启)

这里列举一些可能导致FullGC的原因。

  • 没有配置 -XX:+DisableExplicitGC情况下System.gc()可能会触发FullGC;
  • Promotion failed;
  • concurrent mode failure;
  • Metaspace Space使用达到MaxMetaspaceSize阈值;
  • 执行jmap -histo:live或者jmap -dump:live;

G1调优

G1垃圾收集器调优的主要目标是避免发生并发模式失败或者疏散失败,一旦发生这些就会导致Full GC,避免Full GC的技巧也适用于频繁发生的新生代垃圾收集,这些垃圾收集需要等待扫描根分区完成才能进行。

其次调优可以使过程中的停顿时间最小化。下面的方法都能够避免发生Full GC

  • 通过增加总的堆大小或者调整老年代、新生代的比例来增加老年代空间的大小
  • 增加后台线程的数目(加上我们有足够的CPU运行这些线程)
  • 以更高的频率进行G1的后台垃圾收集获得
  • 在混合式垃圾回收周期中完成更多的垃圾收集工作

G1最主要的调优只通过一个标志进行-XX:MaxGCPauseMillis=N,使用G1时,该标志有个默认值:200毫秒,如果G1发送了时空停顿(STW)的时长超过了该值,G1就会尝试各种方式弥补——譬如调整新生代和老年代的比例,调整堆大小,更早地启动后台处理、改变晋升阈值,或者是在混合式垃圾收集周期里处理更多或更少的老年代分区(这是最重要的方式)

通常的取舍发生在这里:如果减小参数值,为了达到停顿时间的模板,新生代的大小会相应减小,不过新生代垃圾收集的频率会更加频繁。除此之外,为了达到停顿时间的目标,混合式GC收集的老年代分区数也会减少,而这会增大并发模式失败发送的机会。

如果设置停顿时间无法避免Full GC,我们看进一步真的不同的方面逐一调优,对G1来说调整堆大小的方法和其他垃圾收集算法没有什么不同

调整G1的后台线程数

如果机器有足够的空闲CPU可以支撑这些线程的运行,可以尝试增加后台标记线程的数目。调整方式和CMS类似,对于应用程序暂停运行周期可以使用ParallelGCThreads标志设置运行的线程数,对于并发运行节点可以使用ConcGCThreads标志设置运行线程数。不过ConcGCThreads的默认值在G1中不同于CMS

ConcGCThreads = (2 + ParallelGCThreads) / 4

调整G1运行的频率

G1通常在堆的占用达到参数-XX:InitiatingHeapOccupancyPercent=N设定的比率时启动,默认值是45,注意:这个值和CMS不太一样,这个值是根据整个堆的使用情况,而不单是老年代。

InitiatingHeapOccupancyPercent是常数,G1不会为了达到停顿时间目标而修改这个参数值。如果该参数值设置的过高,应用程序会陷入FullGC的泥潭之中,因为并发节点没足够的时间在剩下的堆空间被填满之前完成垃圾收集。如果这个值设置的过低,应用程序又会以超过实际需要的节奏进行大量的后台处理。在CMS我们讨论过,必须要有能支撑后台处理的CPU周期,因此消耗额外的CPU就不那么重要。然而这可能带来非常严重的后果,因为并发节点会出现越来越多的短暂应用线程停顿,而这些停顿迅速积累起来,因此使用G1要避免频繁的进行后台清理。并发周期结束后检查下堆的大小,确保InitiatingHeapOccupancyPercent的值大于此时堆的大小

调整混合式垃圾收集周期

并发周期之后,老年代的标记分区回收完成之前,G1无法启动新的并发周期。因此让G1更早启动标记周期的另一个方法就是在混合式垃圾回收周期中尽量处理更多的分区(如此一来最终的混合式GC周期就变少了)

混合式垃圾收集器要处理的工作取决于3个因素:

  1. 有多少分区被发现大部分是垃圾对象。在混合式垃圾收集中,如果分区的垃圾占比高达35%,这个分区就被标记为可以进行垃圾回收.可以通过G1MixedGCLiveThresholdPercent调整
  2. G1回收分区时的最大混合式GC周期数,通过G1MixedGCCountTarget可以调节,默认值是8,减少该参数值可以帮助解决晋升失败问题,代价是混合式GC周期的停顿时间更长。如果混合式GC的停顿周期过程,可以增大的这个参数的值,减少每次混合式GC周期的工作量。不过调整之前我们需要确保增大值之后不会对下一次G1并发周期带来太大的延迟,否则可能会导致并发模式失败
  3. GC停顿可忍受的最大时长(通过MaxGCPauseMillis设置),MaxGCPauseMillis标志设定的混合式周期时长是向上规整的,如果实际停顿时间在停顿最大时长以内,G1能够收集超过八分之一标记的老年代分区(或者其他设定值),增大MaxGCPauseMillis能在每次混合式GC中收集更多的老年代分区,而这反过来又能帮助G1在更早的时候启动并发周期。

# 高级调优

调整survivor

survivor空间的初始大小由-XX:InitialSurvivorRatio=N决定,默认为8,这个参数值在下面的公式中使用

survivor_space_size = new_size / (initial_survivor_ratio + 2)

可以通过-XX:SurvivorRatio=N保持survivor空间大小为固定值,同时关闭UseAdaptiveSizePolicy标志关闭自适应分代大小

书上说MinSurvivorRatio可以设置最大值,默认为3

JVM依据垃圾回收后survivor空间的占用情况判断是否需要增加或者减少survivor空间的大小,默认情况下survivor空间调整之后要能保证垃圾回收后有50%的空间是空闲的,通过-XX:TargetSurvivorRatio=N可以设置这个值。

查看GC日志时,最重要的是要观察在MinorGC中是否存在由于survivor空间过小,对象直接晋升到老年代的情况。如果大量的短期对象最终填满老年代会导致频繁的Full GC.

如果survivor空间经过调整后不再发生溢出,对象只有在经历的GC周期达到MaxTenuringThreshold的设定值才会晋升到老年代,我们可以增大MaxTenuringThreshold让对象在survivor空间停留的时间越长,将来的新生代收集中,survivor空闲空间就会越少,越有可能发生survivor空间溢出,对象再次被晋升到老年代。

分配大对象

后续再补充

参考资料

《Java性能权威指南》

Edgar

Edgar
一个略懂Java的小菜比