Java性能调优09——垃圾回收优化(下)

本系列均取自《Java性能权威指南》这里只是一个个人备忘笔录

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垃圾收集的后台线程数

为了帮助 G1 赢得这场垃圾收集的比赛,可以尝试增加后台标记线程的数目(假设机器有足够的空闲 CPU 可以支撑这些线程的运行)。

调整 G1 垃圾收集线程的方法与调整 CMS 垃圾收集线程的方法类似:对于应用线程暂停运行的周期,可以使用 ParallelGCThreads 标志设置运行的线程数;对于并发运行阶段可以使用 ConcGCThreads 标志设置运行线程数。不过,ConcGCThreads 标志的默认值在 G1 收集器中不同于 CMS 收集器。它的计算方法如下:

ConcGCThreads = (ParallelGCThreads + 2) / 4

3CMS 收集器的 ConcGCThreads 计算公式为

ConcGCThreads = (3 + ParallelGCThreads) / 4

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

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

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

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

3. 调整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 收集器在更早的时候启动并发周期。

G1调优小结

  1. 作为 G1 收集器调优的第一步,首先应该设定一个合理的停顿时间作为目标。

  2. 如果使用这个设置后,还是频繁发生 Full GC,并且堆的大小没有扩大的可能,这时就需要针对特定的失败采用特定的方法进行调优。

     a. 通过 InitiatingHeapOccupancyPercent 标志可以调整 G1 收集器,更频繁地启动后台垃圾收集线程。

     b. 如果有充足的 CPU 资源,可以考虑调整 ConcGCThreads 标志,增加垃圾收集线程数。

     c. 减小 G1MixedGCCountTarget 参数可以避免晋升失败。

高级调优

晋升及Survivor空间

新生代垃圾收集时,有的对象可能还处于活跃期。这些对象中,有些是刚创建的新对象,这些对象还会存活相当长的一段时间,还有一些只有短暂的生命周期。我们以第 5 章中讨论过的计算 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=N 标志设定)之间选择一个合适的值。对于 Throughput 收集器和 G1 收集器,默认的最大晋升阈值为 15,对 CMS 收集器,最大的晋升阈值为 6。

两种窘境:一直“晋升”与从不“晋升”

晋升阈值总是在 1 到 MaxTenuringThreshold 之间取值。即使 JVM 启动时将初始晋升阈值设置为最大值,这个参数也不一定会一直保持,JVM 可能会在某个时刻减小这个阈值。

使用两个标志可以通过极端的方式避免出现这种情况。如果你确切地知道新生代垃圾收集存活下来的对象在之后很长的一段时间内都会存在,可以使用 -XX:+AlwaysTenure 标志(默认值为 false),开启这个标志的效果与将 MaxTenuringThreshold 设置为 0 的效果在本质上是一样的。然而,只有非常罕见的情况才需要开启这个标志,启用之后对象会直接晋升到老年代,不会再存放于 Survivor 空间。

第二个标志是 -XX:+NeverTenure(默认值也是 false)。这个标志有两方面的影响:设置参数后 JVM 会认为初始晋升阈值和最大晋升阈值都无限大;一旦设置了该参数,JVM 就不再调整晋升阈值,也不会将其降低。换句话说,开启 -XX:+NeverTenure 标志后只要 Survivor 空间有容量,就不会有对象被晋升到老年代。

我们已经学习了很多的参数,那么什么情况下应该使用哪些参数呢?观察晋升的统计信息能够帮助我们更好地做出决定。使用 -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 空间中所有对象的大小。由于需要晋升 37 MB 的数据,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 空间溢出,对象再次被直接晋升到老年代。

晋升及Survivor空间小结

  1. 设计 Survivor 空间的初衷是为了让对象(尤其是已经分配的对象)在新生代停留更多的 GC 周期。这个设计增大了对象晋升到老年代之前被回收释放的几率。

  2. 如果 Survivor 空间过小,对象会直接晋升到老年代,从而触发更多的老年代 GC。

  3. 解决这个问题的最好方法是增大堆的大小(或者至少增大新生代),让 JVM 来处理 Survivor 空间的回收。

  4. 有的情况下,我们需要避免对象晋升到老年代,调整晋升阈值或者 Survivor 空间的大小可以避免对象晋升到老年代。

分配大对象

这一节会详细介绍 JVM 是如何分配对象的。这是一些非常有趣的背景知识,了解这些对于调优需要频繁创建大量大型对象的应用尤其重要。这一节的上下文中,“大型”是一个相对的概念;正如我们后面会看到的,它取决于 JVM 内的“线程本地分配缓冲区”(Thread Local Allocation Buffer,TLAB)。

TLAB 的大小是各种垃圾收集算法进行垃圾收集时都要考虑的因素,除此之外,G1 收集器对超大型对象还有一些额外的考量(再次重申,大型是个相对的术语,对于 2 GB 的堆,对象大小如果超过 512 MB 就算大型对象)。G1 收集器在收集巨型对象时效果非常显著,对线程本地分配缓冲区的大小调整(使用一般的垃圾收集器,为了处理较大对象)则并不常见;不过,G1 收集器的分区大小调整(使用 G1 收集器时,为了处理巨型对象)就比较 司空见惯了。

(1)TLAB

前面讨论了对象是如何在 Eden 空间中分配的;Eden 空间让更快地进行对象分配成为可能(尤其是对于分配之后又被迅速回收的对象)。

结果表明,Eden 空间中对象分配速度更快的原因是每个线程都有一个固定的分区用于分配对象,即一个 TLAB。对象在一个共享的空间中分配,我们需要采用一些同步机制来管理空间内的空闲空间指针。每个线程有固定的分配区域,分配对象时,线程之间不需要进行任何的同步。通常,TLAB 的使用对程序员和终端用户而言是完全透明的:默认情况下 TLAB 就是开启的,JVM 管理着它们的大小及如何使用。我们需要意识到的最重要的事是 TLAB 都不大,因此大型对象无法在 TLAB 内进行分配。大型对象必须直接从堆上分配,由于需要同步,这会消耗额外的时间。

一旦 TLAB 空间用尽,特定大小的对象就无法再继续分配。这时,JVM 可以有不同的选择。一个选项是回收这块 TLAB,为该线程分配一块新的 TLAB。由于 TLAB 只是 Eden 空间中的一个区段,下一次新生代垃圾收集时这块 TLAB 整个都会被回收,并在之后的空间分配中重用。除此之外,JVM 还可以直接在堆上分配对象,保留当前的 TLAB 不动(至少在线程分配新的对象到 TLAB 之前保持不变)。假设发生下面这种情况,TLAB 的大小为 100 KB,其中 75 KB 都已经被占用。这时来了个新的空间分配请求,需要分配 30 KB 的空间,我们可以回收整个 TLAB,这种方式会浪费 25 KB 的 Eden 空间。或者直接在堆上分配这个对象,如果下一次的对象分配空间要求小于等于 25 KB,线程还可以将 TLAB 的空闲空间分配给这些对象。

JVM 提供了各种参数可以控制这些行为(本节后面会详细讨论这些参数),但这一切都取决于 TLAB 大小。默认情况下,TLAB 的大小由三个因素决定:应用程序的线程数、Eden 空间的大小以及线程的分配率。

因此两类的应用程序会受益于 TLAB 参数的调整:需要分配大量巨型对象的应用程序,以及相对于 Eden 空间的大小而言,应用程序线程数量过多的应用。默认情况下,TLAB 就是开启的;使用 -XX:-UseTLAB 可以关闭 TLAB,不过考虑到 TLAB 带来的性能提升,关闭这个功能不是个明智的决定。

由于 TLAB 空间大小的计算在一定程度上基于线程的分配率,我们不大可能准确预测应用程序的 TLAB 大小。我们能做的是监控 TLAB 的分配情况,看是否有任何对象的分配发生在 TLAB 之外。如果发现大量的对象分配发生在 TLAB 之外,我们有两种选择:减小分配对象的大小,或者调整 TLAB 的参数。

与其他的工具比较起来,JFR 在 TLAB 分配的监控方面要强大得多。图 6-9 展示了使用 JFR 记录 TLAB 分配的示例截屏。

JFR 中的 TLAB 视图

在这段 5 秒钟的记录中,49 个对象分配发生在 TLAB 之外;这些对象的最大值为 48 字节。由 TLAB 的最小值为 1.35 MB 我们得知,这些对象被分配到堆上的原因是空间分配时 TLAB 已经耗尽:它们不是由于对象大小的原因被直接分配到了堆上。这种情况通常发生在新生代垃圾回收之前(由于 Eden 空间耗尽,而 TLAB 是 Eden 空间切出来的一部分)。

这段时间内,对象分配的总大小为 1.59 KB,这个例子中无论是分配的数量,还是分配的大小都不是问题。总会有一些对象在 TLAB 之外分配,尤其是当 Eden 空间的使用接近新生代收集的边缘时。我们可以对比这个例子跟图 6-10 中的情况,下图中大量的分配发生在 TLAB 之外。

大量的分配发生在 TLAB 之外

这段记录中,TLAB 中内存分配的总数是 952.96 MB,TLAB 之外对象分配使用的总内存数是 568.32 MB。对于这个例子,无论是改变应用程序,使用更小的对象,还是调整 JVM 将更大的 TLAB 分配给这些对象,都能取得不错的效果;我们甚至可以得到这些对象分配时的堆栈。如果 TLAB 分配中发生了问题,使用 JFR 能迅速地定位出到底什么地方出现了问题。

对于开源版本的 JVM(不附带 JFR),要监控 TLAB 的分配情况,最好的途径就是在命令行中添加 -XX:+PrintTLAB 标志。这样,每次新生代垃圾收集时,GC 日志中就同时包含了两种类型的信息:每个线程都有一行描述该线程的 TLAB 使用情况,以及一行摘要信息,描述 JVM 整体的 TLAB 使用情况。

每个线程一行的日志如下所示:

TLAB: gc thread: 0x00007f3c10b8f800 [id: 18519] desired_size: 221KB
    slow allocs: 8  refill waste: 3536B alloc: 0.01613    11058KB
    refills: 73 waste  0.1% gc: 10368B slow: 2112B fast: 0B

输出中的 gc 表明这一行日志是在垃圾回收时输出的;线程自身是一个常规的应用线程。线程的 TLAB 大小是 221 KB。从上次新生代收集开始,已经有八个对象在堆上分配(slow allocs);占线程分配对象总量的 1.6%(0.01613),总计大小为 11 058 KB。TLAB 空间的 0.1% 被“浪费”了,主要的源头是三件事:并发垃圾收集启动时 TLAB 中的 10 336 字节空间被回收释放,其他(释放的)TLAB 中 2112 字节被释放,以及“快速”分配器分配的空间大小为“0”字节。

每个线程的 TLAB 数据输出后,JVM 还会输出一条概略日志,如下所示:

TLAB totals: thrds: 66  refills: 3234 max: 105
        slow allocs: 406 max 14 waste:  1.1% gc: 7519856B
        max: 211464B slow: 120016B max: 4808B fast: 0B max: 0B

在这个例子中,从上次新生代垃圾收集起,66 个线程进行了各种形式的内存分配。这些线程总共填充了 TLAB 3234 次,最活跃的线程填充了它的 TLAB 105 次。总共在堆上分配了 406 次对象(一个线程最多分配了 14 次对象),并且 TLAB 中有 1.1% 的空闲空间由于 TLAB 空间的释放被浪费。

在每个线程的日志中,如果发现线程有大量的对象分配发生在 TLAB 之外,就应该考虑对 TLAB 进行调整了。

(2)调整TLAB的大小

对于花费大量时间在 TLAB 之外分配对象的应用程序,将分配移动到 TLAB 之内能有效提升应用程序的性能。如果只有极少数对象的分配发生在 TLAB 之外,提升性能最好的方案是修改应用程序。

如果不可能变更应用程序代码,你还可以尝试通过调整 TLAB 的大小来适配应用程序的需要。由于 TLAB 的大小基于 Eden 空间,通过参数调整(增大)Eden 空间会自动增大 TLAB 的大小。

使用 -XX:TLABSize=N 标志可以显式地指定 TLAB 的大小(默认为 0,表示使用前面介绍的方法动态计算得出)。这个标志只能设置 TLAB 的初始大小;为了避免在每次 GC 时都调整 TLAB 的大小,可以使用 -XX:-ResizeTLAB 标志(大多数的平台上,这个参数的默认值都是 true)。这是通过调整 TLAB,充分提升对象分配性能最简单的方法(坦率地说,通常这也是最有效的方法)。

一个新的对象无法适配到当前的 TLAB 中(但是可以容纳于一个新的、空闲的 TLAB 中)时,JVM 就需要做一些抉择:到底是在堆上分配这个对象,还是要回收当前的 TLAB,重新分配一个新的 TLAB 来完成这次对象分配,这个决策取决于几个参数。TLAB 日志的输出中,refill waste 的值代表了决策的当前阈值:如果 TLAB 无法容纳新对象的大小超过这个阈值,那么就会在堆上分配新的对象。如果有问题的对象的大小比这个阈值小,就回收老的 TLAB 空间。

这个值是动态计算得出的,但是默认的初始值是 TLAB 大小的 1%,或者是由参数 -XX:TLABWasteTargetPercent=N 特别设定的值。每当发生堆上的分配,这个值就增大一笔,增量值由参数 -XX:TLABWasteIncrement=N 设定(默认值为 4)。这种设计能够避免线程达到 TLAB 空间占用的阈值,从而持续地在堆上分配对象:随着目标百分比(Target Percentage)的增大,TLAB 空间被回收的几率也在增加。调整 TLABWasteTargetPercent 参数的结果往往同时伴随着 TLAB 空间大小的调整,所以,虽然可以调整这个参数,但是效果往往不那么确定。

最终,TLAB 空间调整生效时,其容量的最小值可以使用 -XX:MinTLABSize=N 参数设置(默认为 2 KB)。TLAB 空间的最大容量略小于 1 GB(使用整型数组可以用到 TLAB 空间的最大上限,由于对象对齐的原因,最大上限会向下圆整),并且不能修改。

调整TLAB的大小小结

对需要分配大量大型对象的应用,TLAB 空间的调整就变得必不可少(不过,通常情况下,我们更推荐在应用程序中使用小型对象的做法)。

(3)巨型对象(Humongous Objects)

对 TLAB 空间中无法分配的对象,JVM 会尽量尝试在 Eden 空间中进行分配。如果 Eden 空间无法容纳该对象,就只能在老年代中分配空间。而这种内存布局打乱了该对象正常的垃圾回收周期,如果它是一个短期存在的对象,还会对垃圾收集造成负面的影响。对于这种情况,除非修改应用程序,放弃使用那些短期存在的巨型对象,否则别无它法。

G1 收集器使用不同的方法处理巨型对象,不过如果对象的大小超过了 G1 收集器的分区,这些对象也会被分配到老年代。因此,对于使用大量巨型对象的应用程序,即使使用 G1 收集器还是需要特别的调优才能弥补这部分的性能损失。

(4)G1分区的大小

G1 收集器将堆划分成了一定数量的分区,每个分区的大小都是固定的。分区的大小不是动态变化的,具体的值是启动时,依据堆大小的最小值(即 Xms 的值)得出的。分区大小的最小值是 1 MB。如果堆的最小值超过 2 GB,分区的大小会依据下面的公式计算得出(使用基数为 2,取 log 的算法):

分区大小 = 1 << log(初始堆的大小 / 2048);

简言之,初始划分堆时,分区的大小是 2 的最小的 N 次幂,使其结果最接近于 2048 个分区。这里还有一些最小、最大值的限制;分区的大小最小是 1 MB,最大不能超过 32 MB。表中列出了所有的可能性。

堆的大小默认G1分区的大小
小于 4 GB1 MB
介于 4 GB 到 8 GB 之间2 MB
介于 8 GB 到 16 GB 之间4 MB
介于 16 GB 到 32 GB 之间8 MB
介于 32 GB 到 64 GB 之间16 MB
大于 64 GB32 MB

G1 分区的大小可以通过 -XX:G1HeapRegionSize=N 标志设置(正常情况下,默认值是 0,意味着使用刚才描述的动态算法计算)。设定的参数值应该是 2 的幂(譬如:1 MB 或者 2 MB);否则,这个值会向下圆整到最接近 2 的幂。

G1 收集的分区调整及大堆

通常情况下,G1 收集器分区的大小调整只有在处理巨型对象分配时才需要进行,但是也有一种例外的情形。

如果应用程序设定了一个非常大的堆区间,譬如 -Xms2G -Xmx32G,这种情况下分区的大小是 1 MB。当堆充分扩张时,G1 收集器的分区数可以高达 32 000 个。这是一个数量巨大的待处理分区,G1 收集算法最初的设计并没有针对这样大量的分区,它期望的分区数是 2048 个左右。这个例子中,增大 G1 收集器分区的大小能提高 G1 垃圾收集的效率;我们需要依据堆的大小选择合适的分区大小,让分区的数量尽量接近 2048 个。

(5)使用G1收集器分配巨型对象

如果 G1 收集器的分区大小是 1 MB,应用程序分配了个 2 百万字节的数组,这个数组是没有办法在一个 G1 分区中存放的。但是这些巨型对象又必须被保存在连续的 G1 分区内。如果 G1 分区的大小是 1 MB,程序分配了个 3.1 MB 的数组,G1 收集器必须在老年代内找到 4 个连续的分区才能完成这次分配工作(最后一个分区的剩余部分会保持空闲,导致 0.9 MB 的空间浪费)。这种做法成功地打败了 G1 收集器传统的收集方式,即压缩,它能够依据分区的满溢程度自主地选择回收哪些分区。通常,为了找到连续的分区,G1 收集器还不得不启动 Full GC。

由于巨型对象直接在老年代中分配,它们不会被新生代垃圾收集所回收。因此,如果短寿型对象采用这种方式分配,收集器的分代机制就不再生效。巨型对象只能在 G1 收集器的并发周期中回收。好消息是,巨型对象的回收会更迅速,因为它是所在分区唯一的对象。巨型对象会在并发周期中的清理阶段(而不是混合式 GC 阶段)被回收释放。

增大 G1 分区的大小,让其能够在一个分区内分配应用需要的所有对象能够提升 G1 收集的效率。为了判断应用的 Full GC 是否源于巨型对象的分配,我们需要开启自适应大小调整(Adaptive Size Policy)GC 的日志记录。应用程序分配巨型对象时,G1 收集器首先会尝试启动一个并发周期。

5.349: [G1Ergonomics (Concurrent Cycles) request concurrent cycle initiation,
    reason: occupancy higher than threshold, occupancy: 483393536 bytes,
    allocation request: 524304 bytes, threshold: 483183810 bytes (45.00 %),
    source: concurrent humongous allocation]
  ...
5.350: [GC pause (young) (initial-mark) 0.349: [G1Ergonomics
    (CSet Construction) start choosing CSet, _pending_cards:
    1624, predicted base time: 19.74 ms, remaining time: 180.26 ms,
    target pause time: 200.00 ms]

这行日志表明发生了巨型对象分配,触发了并发 G1 周期。在这个例子中,对象分配成功,没有堆垃圾收集的其他方面造成影响(G1 收集器刚好找到了需要的连续分区)。

如果 G1 收集器没有找到连续的空闲分区,就会启动一次 Full GC:

25.270: [G1Ergonomics (Heap Sizing) attempt heap expansion,
    reason: allocation request failed, allocation request: 48 bytes]
25.270: [G1Ergonomics (Heap Sizing) expand the heap,
    requested expansion amount: 1048576 bytes,    attempted expansion amount: 1048576 bytes]
25.270: [G1Ergonomics (Heap Sizing) did not expand the heap,
    reason: heap expansion operation failed]
25.270: [Full GC 1535M->1521M(3072M), 1.0358230 secs]
      [Eden: 0.0B(153.0M)->0.0B(153.0M)
       Survivors: 0.0B->0.0B Heap: 1535.9M(3072.0M)->1521.3M(3072.0M)]
      [Times: user=5.24 sys=0.00, real=1.04 secs]

由于堆无法为适配新的巨型对象而进行扩展,因此为了给分配请求提供连续的分区,G1 收集器只能进行 Full GC。在这种情况下,一旦发生问题,即便是开启 PrintAdaptiveSizePolicy 标志也无法提供更多的垃圾回收日志,标准的 G1 垃圾收集日志也无法提供足够的信息,以诊断问题的根源。

为了避免发生这种 Full GC,首先是要确定导致问题的巨型对象大小(本例中,从垃圾收集日志中定位的对象大小是 524 304 字节)。接下来,需要判断是否有办法减少程序中这些对象的大小。下下策才是针对这些对象,调整 JVM。如果无法减少对象的大小,就需要计算容纳这些对象所需要的分区大小。如果对象占用的空间达到分区容量的 50% 以上,G1 收集器就认为这是个巨型对象。因此,这个例子中,如果被质疑的对象大小为 524 304 字节,G1 分区的大小至少应该是 1.1 MB。由于 G1 收集算法中,分区的大小总是 2 的幂,所以 G1 分区的大小应该为 2 MB,才能保持在标准的 G1 分区中完成这些对象的分配。

分配大对象小结

  1. G1 分区的大小是 2 的幂,最小值为 1 MB。

  2. 如果堆的初始大小跟最大值相差很大,这种堆会有大量的 G1 分区,在这种情况下,应该增大 G1 分区的大小。

  3. 如果要分配的对象大小超过了 G1 收集器分区容量的一半,对于这种应用程序,我们应该增大 G1 分区的容量,让 G1 分区能更好地适配这些对象。遵循这个原则,应用程序分配对象的大小至少应是 512 KB(因为 G1 分区的最小值为 1 MB)。

AggressiveHeap标志

AggressiveHeap 在非常早期的 Java 版本中就已经引入(默认为 false)。那个时候,配置了大量内存的大型服务器也只运行单一的 JVM,使用 AggressiveHeap 标志能更方便地设置各种命令行参数。这个标志只适用于 64 位的 JVM。

虽然这个标志已经更换了很多个版本,到目前依然存在,但是我们不推荐使用该标志(虽然这个标志暂时还没有被官方正式弃用)。这个标志的问题在于它隐藏了很多实际采用的调优工作,让我们很难了解 JVM 实际运行时的设置。它的一些参数值设置是依据运行 JVM 中物理机的情况动态调整的,因此,开启这个标志有时对性能的影响是负面的。我经常看到使用这个标志的命令行,在之后实际运行时标志被动态修改的情况。(对于历史数据,这个标志可能会有效:不过,之后的运行时命令行标志值会改写之前设定的值。这种行为是不确定的。)

下表列出了开启 AggressiveHeap 标志后会被自动调整的参数。

标志
Xmx所有内存的 1/2;或者所有的内存;或者 160 MB
Xms与 Xmx 相同
NewSizeXmx 设定值的 3/8
UseLargePagestrue
ResizeTLABfalse
TLABSize256 KB
UseParallelGCtrue
ParallelGCThreads与当前的默认值相同
YoungPLABSize256 KB(默认为 4 KB)
OldPLABSize8 KB(默认为 1 KB)
CompilationPolicyChoice0(当前默认值)
ThresholdTolerance100(默认为 10)
ScavengeBeforeFullGCfalse(默认为 true)
BindGCTaskThreadsToCPUstrue(默认为 false)

PLAB 的大小

  PLAB 的全称是晋升本地分配缓冲(Promotion-Local Allocation Buffer),是垃圾回收清理代数据时基于线程分配的分区。每个线程都能晋升对象到自己的 PLAB 中,避免了相互同步的需求(工作原理与 TLAB 很相似)。

编译策略

  JVM 发布时配备了多种 JIT 编译算法。当前默认的算法也曾经一度是实验性的,但是随着时间的打磨,它们不断完善成熟,变成了现在推荐的策略。

关闭 Full GC 之前的新生代垃圾收集

  将 ScavengeBeforeFullGC 标志设置为 false 意味着 Full GC 发生时,JVM 不会对 Full GC 之前的新生代垃圾进行收集。通常这不是件好事儿,因为这意味着新生代中的垃圾对象(针对的是能够进行收集的垃圾对象)会妨碍老年代的对象被回收。显然,这些设置总有其存在的理由,不论过去或者现在(至少是在进行某些基准测试时可能有需要),但是,我们通常的建议是不要修改该标志。

将 GC 线程绑定到特定的 CPU

  对上述列表中的最后一个标志进行设置意味着将各个并行垃圾收集线程分别绑定到特定的 CPU(通过操作系统相关的调用)。有些非常极端的情况,譬如垃圾收集线程是运行于该机器上的唯一任务,同时堆的容量又足够大时,使用该标志是有意义的。通常情况下,最好允许垃圾收集线程在所有可用的 CPU 上运行。

跟所有的调优一样,各个方法虽有不同,不过最终都是殊途同归,如果经过认真的测试,发现使用 AggressiveHeap 标志的确改善了程序性能,那就尽量使用该标志。唯一要留意的是,我们需要了解标志的背后到底发生了什么样的调优。应意识到,一旦 JVM 进行了升级,使用该标志带来的所有性能提升都需要重新评估。

AggressiveHeap标志小结

  1. AggressiveHeap 是个历史悠久的调优标志,设计初衷是为了在强大的机器上运行单一 JVM 时调整堆的各种参数。

  2. 这个标志设定的值并没有随着 JVM 技术的发展同步调整,因此它的有效性从长远来看是值得质疑的(虽然到目前为止,这个标志还常常被使

全盘掌控堆空间的大小

本节会将堆空间计算的内幕一一展现在你眼前。我们要介绍的内容包括非常底层的调优标志;在有的情况下,直接调整堆空间的计算方法(而不只是设置堆的大小)可能更直观。譬如下面这个例子:你希望采用通用的堆大小(而不是针对每个虚拟机单独调优的堆)来运行多个 JVM。本节大多数部分的目标还是介绍堆的各个默认选择的前 世今生,让大家能有个全局性的了解。

堆的默认大小依据机器的内存配置确定,不过也可以通过参数 -XX:MaxRAM=N 设置。通常情况下,这个值是由 JVM 检测机器的物理内存计算得出。不过,JVM 同时设置了一些限制,譬如对于 32 位的 client 编译器,MaxRam 最大只能是 1 GB,对于 32 位的 server 编译器,MaxRam 可达 4 GB,如果是 64 位编译器,上限最大可以达到 128 GB。堆的最大容量是 MaxRAM 值的四分之一。这就是为什么堆的默认大小在不同的机器上会有不同的原因:如果机器的物理内存比 MaxRAM 的值小,默认堆的大小就是物理内存的 1/4。但是,相反的规则并不适用,即使机器配置了数百 GB 的内存,JVM 能使用的最大堆容量也不会超过默认值 32 GB,即 128 GB 的 1/4。

默认最大堆的计算实际采用下面的公式:

Default Xmx = MaxRAM / MaxRAMFraction

因此,默认最大堆的大小也可以通过 -XX:MaxRAMFraction=N 标志值进行调整,MaxRAMFraction 的默认值为 4。最后,为了让堆的默认值调整更加完备,JVM 还提供了另一个参数调整最大堆的默认值,这个参数是 -XX:ErgoHeapSizeLimit=N。该参数默认值为 0(表示忽略该标志);否则,如果设置的限制值比 MaxRAM/MaxRAMFraction 还小,就使用该参数指定的值。

另一方面,如果机器配置的物理内存非常少,JVM 还要确保预留足够的内存给操作系统使用。这就是为什么在内存只有 192 MB 的机器上,JVM 会限制最大堆的大小为 96 MB 甚至更少。这个值的计算是基于 -XX:MinRAMFraction=N 参数,默认值为 2。

if ((96 MB * MinRAMFraction) > Physical Memory) {
    Default Xmx = Physical Memory / MinRAMFraction;
}

计算堆的初始大小与此类似,不过相对简单一些,影响因素更少。堆的初始大小计算采用下面的公式:

Default Xms = MaxRAM / InitialRAMFraction

计算默认最小堆的大小的方法同样也适用,InitialRAMFraction 参数的默认值为 64。不过,这个参数也不能完全控制堆的初始值,如果该参数值小于 5 MB,或者更确切地说,指定的 InitialRAMFraction 小于 -XX:OldSize=N 参数的设定(该参数默认为 4 MB)时会采用另外的处理方式。这种情况下,堆的初始大小等于新生代和老年代大小之和。

全盘掌控堆空间的大小小结

  1. 大多数的机器上堆的初始空间和最大空间的默认值计算是比较直观的。

  2. 达到堆大小的临界情况时,需要考虑的因素更多,计算也更加复杂。

垃圾回收小结

你的应用能够忍受 Full GC 的停顿吗?

如果答案是肯定的,选择 Throughput 收集器能获得最高的性能,同时,使用的 CPU 和堆的大小也比其他的垃圾收集器少。如果答案是否定的,你需要依据可用的堆大小做选择,如果可用的堆较小,你可以选择并发收集器,譬如 CMS 收集器或者 G1 收集器;如果可用的堆比较大,推荐使用 G1 收集器。

使用默认设置能达到你期望的性能目标吗?

尽量首先使用默认的设置。因为垃圾收集技术在不断发展成熟,自动调优大多数情况下取得的效果是最好的。如果使用默认设置没有达到你需要的性能目标,请确认垃圾收集是否是性能瓶颈。查看垃圾收集日志能帮我们了解 JVM 在垃圾收集上花费了多长时间、垃圾收集发生的频率是多少。对于负荷较高的应用,如果 JVM 花在垃圾收集上的时间不超过 3%,即使进行垃圾调优也不会得到太大的性能提升(不过,如果那些指标是你关注的方面,你仍然可以尝试通过调优缩短某些指标)。

应用的停顿时间与你预期的目标接近吗?

如果停顿时间与你预期的目标很接近,调整最大停顿时间的设定可能是你需要做的。如果不是,你需要进行其他的调整。如果停顿时间太长,但是应用的吞吐量正常,你可以尝试减小新生代的大小(如果瓶颈是 Full GC 的停顿,就减小老年代的大小);调整之后,停顿的频率会增加,但是单次停顿的时长会变短。

虽然 GC 的停顿时间已经非常短了,但应用的吞吐量依旧上不去?

这种情况下你需要增大堆的大小(至少要增大新生代)。但是,这并不意味着堆越大越好:更大的堆会导致更长的停顿时间。即便是并发收集器,默认情况下,增大堆也还是意味着增大新生代,因此你会发现新生代的停顿时间变长了。即便是这样,如果有可能,还是应该增大堆的大小,或者增大对应代的大小。

你使用并发收集器吗?是否发生了由并发模式失败引起的 Full GC ?

如果你有足够的 CPU 资源,可以尝试增加并发 GC 线程的数量,或者通过调整 InitiatingHeapOccupancyPercent 参数在更早的时候启动后台清理线程。对于 G1 收集器,如果有混合式垃圾收集尚未完成,并发周期就不会启动。在这个时候,可以尝试降低混合式 GC 的回收目标(Mixed GC count target)。

你使用并发收集器吗?是否发生了由晋升失败引起的 Full GC ?

在 CMS 收集器中,发生晋升失败意味着堆发生了碎片化。这种情况下,我们能做的事情不多:使用更大的堆,或者尽早地启动后台回收都能在一定程度上缓解堆的碎片化。处理这种情况,更好的解决方法可能是使用 G1 收集器。G1 收集器中,疏散失败(To 空间溢出)表明遭遇了同样的情况,但是 G1 收集器能解决碎片化的问题,如果它的后台线程在更早的时候启动,且混合式 GC 的速度更快的话。你可以尝试通过增大并发 G1 收集线程的数目,调整 InitiatingHeapOccupancyPercent,或者降低混合式 GC 的目标来解决 G1 收集器中堆碎片化的问题。

更新时间:2020-03-11 18:37:05

本文由 寻非 创作,如果您觉得本文不错,请随意赞赏
采用 知识共享署名4.0 国际许可协议进行许可
本站文章除注明转载/出处外,均为本站原创或翻译,转载前请务必署名
原文链接:https://www.zhouning.group/archives/java性能调优09垃圾回收优化下
最后更新:2020-03-11 18:37:05

评论

Your browser is out of date!

Update your browser to view this website correctly. Update my browser now

×