Java性能权威指南-总结11

Java性能权威指南-总结11

  • 垃圾收集算法
    • 理解G1垃圾收集器
      • G1垃圾收集器调优
    • 高级调优
    • 晋升及Survivor空间

垃圾收集算法

理解G1垃圾收集器

G1垃圾收集器调优

G1垃圾收集器调优的主要目标是避免发生并发模式失败或者疏散失败,一旦发生这些失败就会导致Full GC。 避免Full GC的技巧也适用于频繁发生的新生代垃圾收集,这些垃圾收集需要等待扫描根分区完成才能进行。其次,调优可以使过程中的停顿时间最小化。下面所列的这些方法都能够避免发生Full GC:

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

这里有很多的调优可以做,不过G1垃圾收集器调优的目标之一是尽量简单。为了达到这个目标,G1收集器最主要的调优只通过一个标志进行:这个标志跟Throughput收集器的标志一致,也是-XX:MaxGCPauseMillis=N

使用G1垃圾收集器时,该标志有一个默认值:200毫秒(这一点跟Throughput收集器有所不同)。如果G1收集器发生时空停顿(stop-the-world)的时长超过该值,G1收集器就会尝试各种方式进行弥补——譬如调整新生代与老年代的比例,调整堆的大小,更早地启动后台处理,改变晋升阈值,或者是在混合式垃圾收集周期中处理更多或更少的老年代分区(这是最重要的方式)。

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

如果设置停顿时间目标无法避免Full GC,可以进一步针对不同的方面逐一调优。对G1垃圾收集器而言,调整堆大小的方法与其他的垃圾收集算法并没有什么不同。

  1. 调整G1垃圾收集的后台线程数

可以尝试增加后台标记线程的数目(假设机器有足够的空闲CPU可以支撑这些线程的运行)。调整G1垃圾收集线程的方法与调整CMS垃圾收集线程的方法类似:对于应用线程暂停运行的周期,可以使用ParallelGCThreads标志设置运行的线程数;对于并发运行阶段可以使用ConcGCThreads标志设置运行线程数。不过,ConcGCThreads标志的默认值在G1收集器中不同于CMS收集器。它的计算方法如下:

ConcGCThreads = (ParallelGCThreads + 2)/ 4³

这个算法依然是基于整数的;G1收集器与CMS收集器的计算方法相差无几。

  1. 调整G1垃圾收集器运行的频率

如果G1收集器更早地启动垃圾收集,也能达到调优目标。G1垃圾收集周期通常在堆的占用达到参数-XX:InitiatingHeapoccupancyPercent=N设定的比率时启动,默认情况下该参数的值为45。注意,跟CMS收集器不太一样,这个参数值的依据是整个堆的使用情况,不单是老年代的。

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

  1. 调整G1收集器的混合式垃圾收集周期

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

混合式垃圾收集要处理的工作量取决于三个因素。第一个因素是有多少分区被发现大部分是垃圾对象。目前没有标志能够直接调节这个因素:混合式垃圾收集中,如果分区的垃圾占用比达到35%,这个分区就被标记为可以进行垃圾回收。(这个因素在将来的某个时刻可能也能调整,在开源的实验版本中已经有名为-XX:G1MixedGCLiveThresholdPercent=N的参数可以对其进行调整)。

第二个因素是G1垃圾收集回收分区时的最大混合式GC周期数,通过参数-XX:G1MixedGCCountTarget=N可以进行调节。这个参数的默认值为8;减少该参数值可以帮助解决晋升失败的问题(代价是混合式GC周期的停顿时间会更长)。另一方面,如果混合式GC的停顿时间过长,可以增大这个参数的值,减少每次混合式GC周期的工作量。不过调整之前我们需要确保增大值之后不会对下一次G1并发周期带来太大的延迟,否则可能会导致并发模式失败。

最后,第三个影响因素是GC停顿可忍受的最大时长(通过MaxGCPauseMillis参数设定)。MaxGCPauseMillis标志设定的混合式周期时长是向上规整的,如果实际停顿时间在停顿最大时长以内,G1收集器能够收集超过八分之一标记的老年代分区(或者其他设定的值)。增大MaxGCPauseMillis能在每次混合式GC中收集更多的老年代分区,而这反过来又能帮助G1收集器在更早的时候启动并发周期。

快速小结

  1. 作为G1收集器调优的第一步,首先应该设定一个合理的停顿时间作为目标。
  2. 如果使用这个设置后,还是频繁发生Full GC,并且堆的大小没有扩大的可能,这时就需要针对特定的失败采用特定的方法进行调优。
  3. 通过InitiatingHeapOccupancyPercent标志可以调整G1收集器,更频繁地启动后台垃圾收集线程。
  4. 如果有充足的CPU资源,可以考虑调整ConcGCThreads标志,增加垃圾收集线程数。
    c.减小G1MixedGCCountTarget参数可以避免晋升失败。

高级调优

晋升及Survivor空间

新生代垃圾收集时,有的对象可能还处于活跃期。这些对象中,有些是刚创建的新对象,这些对象还会存活相当长的一段时间,还有一些只有短暂的生命周期。以第中讨论过的计算BigDecimal的循环为例。如果JVM在循环的中段启动垃圾回收,这些超短寿(very-short-lived)的BigDecimal对象面临的局面就变得非常尴尬:它们刚被创建,因此不能被回收释放;但是它们的生命周期又非常短,无法满足晋升到老年代的条件。

这就是新生代被划分成一个Eden空间和两个Survivor空间的原因。这种布局让对象在新生代内有更多的机会被回收,不再局限于只能晋升到老年代(最终填满老年代)

新生代垃圾收集时,如果JVM发现对象还十分活跃,会首先尝试将其移动到Survivor空间,而不是直接移动到老年代。首次新生代垃圾收集时,对象被从Eden空间移动到Survivor空间0。紧接着的下一次垃圾收集中,活跃对象会从Survivor空间0和Eden空间移动到Survivor空间1。这之后,Eden空间和Survivor空间0被完全清空。下一次的垃圾回收会将活跃对象从Survivor空间1和Eden空间移回Survivor空间0,如此反复。(Survivor空间也被称为“To”空间和“From”空间;每次回收,对象由“From”空间移出,移入到“To”空间。“From”和“To”只是简单地表示两个Survivor空间之间的指向,每次垃圾回收时,方向都会互换。)

显而易见,这种状况不会一直持续下去,否则没有任何对象会进入老年代。两种情况下,对象会被移动到老年代。第一,Survivor空间的大小实在太小。新生代垃圾收集时,如果目标Survivor空间被填满,Eden空间剩下的活跃对象会直接进入老年代。第二,对象在Survivor空间中经历的GC周期数有个上限,超过这个上限的对象也会被移动到老年代。 这个上限值被称为晋升阈值(Tenuring Threshold)。

这些影响垃圾收集的因素都有对应的调优标志。Survivor空间是新生代空间的一部分,跟堆内的其他区域一样,JVM可以对它进行动态的调节。Survivor空间的初始大小由-XX:InitialSurvivorRatio=N标志决定。这个参数值在下面的这个公式中使用:

survivor_space_size = new_size /(initial_survivor_ratio + 2)

初始Survivor空间的占用比率(initial_survivor_ratio)默认为8,由此可以计算出每个Survivor空间会占用大约10%的新生代空间。

JVM可以增大Survivor空间的大小直到其最大上限,这个上限可以通过-XX:MinSurvivorRatio=N参数设置。MinSurvivorRatio标志在下面这个公式中使用:

maximum_survivor_space_size = new_size /(min_survivor_ratio + 2)

这个参数值默认为3,意味着Survivor空间的最大值为新生代空间的20%。这个参数值是个分母,分母值最小时,Survivor空间的容量最大。这样说起来,这个参数的名字的确有些不直观。

为了保持Survivor空间的大小为某个固定值,可以使用SurvivorRatio参数,将其设定为期望的值,同时关闭UseAdaptiveSizepolicy标志(然而,需要注意一点,即关闭自适应大小调整会同时影响新生代和老年代)。

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

最后,还有一个问题,即对象在移动到老年代之前,需要在Survivor空间之间来回移动多少个GC周期。这个问题取决于晋升阈值的设定。JVM会持续地计算,寻找它认为最合适的晋升阈值。通过-XX:InitialTenuringThreshold=N标志可以设置初始的晋升阈值(对于Throughput收集器和G1收集器,默认值是7,对于CMS收集器默认值为6)。JVM最终会在1和最大晋升阈值(由-XX:MaxTenuringThreshold=v标志设定)之间选择一个合适的值。对于Throughput收集器和G1收集器,默认的最大晋升阈值为15,对CMS收集器,最大的晋升阈值为6。

什么情况下应该使用哪些参数呢?观察晋升的统计信息能够帮助我们更好地做出决定。使用-XX:+PrintTenuringDistribution标志可以在GC日志中增加这部分信息(默认情况下,-XX:+PrintTenuringDistribution的值为false)。

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

使用Throughput收集器时,判断发生了这种情况的唯一线索是下面这几行GC日志:

Desired survivor size 39059456 bytes, new threshold 1(max 15)
	[PSYoungGen: 657856K->35712K(660864K)]
	1659879K->1073807K(2059008K),0.0950040 secs]
	[Times: user=0.32 sys=0.00,real=0.09 secs]

从日志中看到,例子中一个Survivor空间期望的大小是39 MB,新生代的总大小为660 MB:JVM据此计算出两个Survivor空间大约要占用11%的新生代空间。不过这又出现一个问题:这部分空间是否已经足够大,是否能避免发生新生代到老年代的溢出。垃圾收集日志无法直接回答这个问题,但是从JVM将晋升阈值调整到1这个事实,可以判断JVM会直接晋升大部分对象到老年代,并据此将晋升阈值减小到1。这个应用极可能在Survivor空间还未完全填满时就将对象直接晋升到老年代。

使用G1收集器或CMS收集器时,可以从垃圾收集日志中获取更多的信息:

Desired survivor size 35782656 bytes, new threshold 2(max 6)
- age 1: 33291392 bytes,  33291392 total
- age 2:  4098176 bytes,  37389568 total

期望的Survivor空间与上一个例子很相似,大约是35 MB,但是能看到更多信息,包括Survivor空间中所有对象的大小。由于需要晋升37MB的数据,Survivor空间的确会发生溢出。

这种情况能否通过调优改善取决于应用程序自身的特性。如果对象的生命周期很长,跨越多个垃圾收集周期,无论怎样调整它们最终都会移动到老年代,在这种情况下,调整Survivor空间和晋升阈值不会有太大的帮助。但是,如果对象经过几个GC周期就会被回收,合理安排Survivor空间更高效地加以利用,能够提升一定的程序性能。

如果(通过减小生存比率的方式)增大Survivor空间的大小,内存由新生代的Eden空间划分到Survivor空间。不过对象的分配都发生在Eden空间,这意味着在Minor GC之前能分配的对象数目会更少。因此,不推荐采用这种方式。

另一种可能是增大新生代的大小。采用这种方式的效果可能事与愿违:虽然对象晋升到老年代的频率降低了,但是老年代空间变得更小,应用程序可能会更频繁地发生Full GC。

如果堆的大小可以同时增加,那么新生代和老年代都能获得更多的内存,这是最好的解决方案。推荐的流程是增大堆的大小(或者至少增大新生代),同时减小存活率。采用这种方法Survivor空间增大的值会比Eden空间的增长更大。应用程序最终的新生代垃圾收集次数与调节之前基本持平。不过Full FC的次数会更少,因为晋升到老年代的对象数更少了(再次重申,这种调优适用的应用程序,其大多数对象在几个GC周期之后就不再存活)。

如果Survivor空间经过调整后不再发生溢出,对象只有在经历的GC周期数达到MaxTenuringThreshold的设定值时才会晋升到老年代。可以增大MaxTenuringThreshold值,让对象在Survivor空间中停留更多的周期。但是,也要注意,晋升阈值增大,对象在Survivor空间停留的时间越长,将来的新生代收集中,Survivor空闲空间就会越少:越有可能发生Survivor空间溢出,对象再次被直接晋升到老年代。

快速小结

  1. 设计Survivor空间的初衷是为了让对象(尤其是已经分配的对象)在新生代停留更多的GC周期。这个设计增大了对象晋升到老年代之前被回收释放的几率。
  2. 如果Survivor空间过小,对象会直接晋升到老年代,从而触发更多的老年代GC。
  3. 解决这个问题的最好方法是增大堆的大小(或者至少增大新生代),让JVM来处理Survivor空间的回收。
  4. 有的情况下,需要避免对象晋升到老年代,调整晋升阈值或者Survivor空间的大小可以避免对象晋升到老年代。

你可能感兴趣的:(java,jvm,算法)