6.1 理解Throughput收集器
我们会逐一分析各个垃圾收集器的行为,首先会介绍 Throughput 收集器。Throughput 收集器有两个基本的操作:其一是回收新生代的垃圾,其二是回收老年代的垃圾。
图 6-1 展示了堆在新生代回收之前和回收之后的情况。

图 6-1:Throughput 垃圾回收中的新生代
通常新生代的垃圾回收发生在 Eden 空间快用尽时。新生代垃圾收集会把 Eden 空间中的所有对象挪走:一部分对象会被移动到 Survivor 空间(即这幅图中的 S0 区域),其他的会被移动到老年代;正如你看到的,回收之后老年代中保存了更多的对象。当然,还有大量的对象因为没有任何对象引用而被回收。
开启了 PrintGCDetails 标志的 GC 日志中,Minor GC 形式如下:
17.806: [GC [PSYoungGen: 227983K->14463K(264128K)]280122K->66610K(613696K), 0.0169320 secs][Times: user=0.05 sys=0.00, real=0.02 secs]
这次 GC 在程序开始运行 17.806 秒后发生。现在新生代中对象占用的空间为 14 463 KB(约为 14 MB,位于 Survivor 空间内);GC 之前,新生代对象占用的空间为 227 983 KB(约为 227 MB)。(实际上,227 893 KB 严格折算只有 222 MB,为了便于讨论,本章中以 1000 为单位将它们折算到 KB。这里假设我是磁盘生产商。)新生代这时总的大小为 264 MB。
与此同时,堆的空间总的使用情况(包含新生代和老年代)从 280 MB 减少到了 66 MB,这个时刻整个堆的大小为 613 MB。完成垃圾回收操作耗时 0.02 秒(排在输出最后的 Real 时间是 0.0 169 320 秒——实际时间进行了归整)。程序消耗的 CPU 时间比 Real 时间往往更多,原因是新生代垃圾回收会使用多个线程(这个例子中,使用了 4 个线程)。
图 6-2 展示了 Full GC 之前及之后堆的使用情况。

图 6-2:使用 Throughput 收集器的 Full GC
老年代垃圾收集会回收新生代中所有的对象(包括 Survivor 空间中的对象)。只有那些有活跃引用的对象,或者已经经过压缩整理的对象(它们占据了老年代的开始部分)会在老年代中继续保持,其余的对象都会被回收。
Full GC 的日志输出示例如下:
64.546: [Full GC [PSYoungGen: 15808K->0K(339456K)][ParOldGen: 457753K->392528K(554432K)] 473561K->392528K(893888K)[PSPermGen: 56728K->56728K(115392K)], 1.3367080 secs][Times: user=4.44 sys=0.01, real=1.34 secs]
新生代的空间使用在经历 Full GC 之后变为 0 字节(新生代的大小为 339 MB)。老年代中的空间使用从 457 MB 减少到了 392 MB,因此整个堆的使用从 473 MB 减少到了 392 MB。永久代空间的使用没有发生变化;在多数的 Full GC 中,永久代的对象都不会被回收。(如果永久代空间耗尽,JVM 会发起 Full GC 回收永久代中的对象,这时你会观察到永久代空间的变化——这是永久代进行回收唯一的情况。这个例子使用的是 Java 7;在 Java 8 中,类似的信息可以在元空间中找到)。由于 Full GC 要进行大量的工作,所以消耗了约 1.3 秒的 Real 时间,4.4 秒的 CPU 时间(同样源于使用了 4 个并行的线程)。
快速小结
1. Throughput 收集器会进行两种操作,分别是 Minor GC 和 Full GC。
2. 通过 GC 日志中的时间输出,我们可以迅速地判断出 Throughput 收集器的 GC 操作对应用程序总体性能的影响。
堆大小的自适应调整和静态调整
Throughput 收集器的调优几乎都是围绕停顿时间进行的,寻求堆的总体大小、新生代的大小以及老年代大小之间平衡。
考虑 Throughput 收集器的调优方案时有两种取舍。首先比较经典的是编程技术上的取舍,即时间与空间的取舍。
第二个取舍与完成垃圾回收所需的时长相关。增大堆能够减少 Full GC 停顿发生的频率,但也有其局限性:由于 GC 时间变得更长,平均响应时间也会变长。类似地,为新生代分配更多的堆空间可以缩短 Full GC 的停顿时间,不过这又会增大老年代垃圾回收的频率(因为老年代空间保持不变或者变得更小了)。
图 6-3 展示了采用这些取舍的效果。图上显示的是运行在 GlassFish 实例上的股票 Servlet 应用,在使用不同大小的堆时,最大吞吐量的变化情况。使用 256 MB 的小堆时,应用服务器在垃圾回收上消耗了大量的时间(实际消耗的时间高达总时间的 36%);吞吐量因此受到限制,比较低。随着堆大小的增加,吞吐量迅速提升——直到堆的容量增大到 1500 MB。这之后吞吐量的增速迅速减缓,这时应用程序实际已经不太受垃圾回收的影响(垃圾回收消耗的时间仅仅只占总时间的 6% 左右)。收益递减规律逐渐凸显出来:虽然应用程序可以通过增加内存的方式提升吞吐量,不过其效果已经很有限了。
堆的大小达到 4500 MB 后,吞吐量开始出现少量下滑。这时,应用程序面临着第二个选择:增加的内存导致 GC 周期愈加冗长,虽然它们发生的频率小得多,但这些超长的 GC 周期也会影响系统整体的吞吐量。
这幅图中的数据取自关闭了自适应调整的 JVM;它的最大、最小堆的容量设置成了同样的大小。对任何一种应用,我们都可以通过实验确定堆和代的最佳大小,但是,让 JVM 自己来选择通常是更容易的方法(这也是最通常的做法,因为默认情况下自适应调整就是开启的)。

图 6-3:使用不同大小的堆时吞吐量的变化
为了达到停顿时间的指标,Throughput 收集器的自适应调整会重新分配堆(以及代)的大小。使用这些标志可以设置相应的性能指标:-XX:MaxGCPauseMillis=N 和 -XX:GCTimeRatio=N。
MaxGCPauseMillis 标志用于设定应用可承受的最大停顿时间。我们可以将其设置为 0 或者一些非常小的值,譬如 50 毫秒。请注意,这个标志设定的值同时影响 Minor GC 和 Full GC。如果设置的值非常小,那么应用的老年代最终就会非常小:譬如,你设定该参数希望应用在 50 毫秒内完成垃圾回收,这将会触发非常频繁的 Full GC,对应用程序的性能而言将是灾难性的。因此,设定该值时,请尽量保持理性,将该值设定为可达到的合理值。缺省情况下,我们不设定该参数。
GCTimeRatio 标志可以设置你希望应用程序在垃圾回收上花费多少时间(与应用线程的运行时间相比较)。它是一个百分比,因此 N 值的计算稍微有些复杂。将 N 值代入下面的公式可以计算出理想情况下应用线程的运行时间所占的百分比:

GCTimeRatio 的默认值是 99。将该值代入公式能得到 0.99,这意味着应用程序的运行时间占总时间的 99%,只有 1% 的时间消耗在垃圾回收上。但是,不要被列出的默认值搞糊涂。譬如,GCTimeRatio 设置为 95 并不意味着会使用总时间的 5% 去做垃圾回收;它表示的是最多会使用总时间的 1.94% 去做垃圾回收。
先确定你期望应用程序线程工作的时间(譬如 95%),再根据下面这个公式计算 GCTimeRatio 是一个更容易操作的方法。

对于 95%(0.95)的吞吐量目标,利用该公式计算出的 GCTimeRatio 是 19。
JVM 使用这两个标志在堆的初始值(-Xms)和最大值(-Xmx)之间设置堆的大小。MaxGCPauseMillis 标志的优先级最高:如果设置了这个值,新生代和老年代会随之进行调整,直到满足对应停顿时间的目标。一旦这个目标达成,堆的总容量就开始逐渐增大,直到运行时间的比率达到设定值。这两个目标都达成后,JVM 会尝试缩减堆的大小,尽可能以最小的堆大小来满足这两个目标。
由于默认情况不设置停顿时间目标,通常自动堆调整的效果是堆(以及代空间)的大小会持续增大,直到满足设置的 GCTimeRatio 目标。不过,在实际操作中,该标志的默认设置已经相当优化了。每个人的使用经验各有不同,但是根据我以往的经验,如果应用程序在垃圾回收上消耗总时间的 3% 至 6%,其效果会是相当不错的。有些时候,我甚至会在内存严重受限的环境中调优应用程序的性能;这些应用通常会在垃圾回收上消耗 10% 至 15% 的时间。垃圾回收对这些应用程序的性能影响巨大,不过整体的性能目标还是能够达到的。
因此,依据应用程序的性能目标,最佳的配置也有所不同。本例没有其他的性能目标,我从时间百分比 19(垃圾收集时间占整个时间的 5% 左右)开始。
表 6-1 展示的是一个应用,仅需要一个容量较小的堆,也很少做 GC(这是一个运行在 GlassFish 服务器上的股票 Servlet 程序,它不保持会话状态,几乎没有长期活跃的对象),使用动态调整后的效果。
表6-1:使用动态GC调整的效果
| 垃圾收集参数 | 最终堆的大小 | 垃圾收集时间所占百分比 | OPS |
|---|---|---|---|
| 默认值 | 649 MB | 0.9% | 9.2 |
MaxGCPauseMillis=50ms
| 560 MB | 1.0% | 9.2 |
Xms=Xmx = 2048m
| 2 GB | 0.04% | 9.2 |
默认情况下,堆容量的最小值是 64 MB,最大为 2 GB(因为这台机器配备了 8GB 的物理内存)。这时,GCTimeRatio 就如我们预期的那样工作得很好:堆的容量动态地调整到了 649 MB,这时应用程序在垃圾回收上只花费了大约 1% 的总运行时间。
在这个例子中,如果设置了 MaxGCPauseMillis 参数,JVM 为了达到停顿时间的目标,这之后就开始逐步减小堆的大小。由于本例中垃圾收集器不需要做太多的工作,堆的调整进行得很顺利,调整之后的堆仍能维持只消耗 1% 的时间在垃圾回收上,同时保持跟之前同样 9.2 次每秒(OPS)的吞吐量。
最后你会发现堆并不是越大越好——使用大小为 2 GB 的堆可以减少应用程序在垃圾回收上消耗的时间,但是这个例子中垃圾回收并不是影响性能的决定性因素,因此也无法提高程序的吞吐量。正如之前提到的,在错误的方向上投注精力进行优化无法提升应用的性能。
同样的应用程序,如果改变了行为,需要保持每个用户前 50 个请求的会话状态,垃圾收集器的工作量就会大大增加。表 6-2 展示了这种情况下的取舍。
表6-2:动态调整的效果
| 垃圾收集参数 | 最终堆的大小 | 垃圾收集时间所占百分比 | OPS |
|---|---|---|---|
| 默认值 | 1.7 GB | 9.3% | 8.4 |
MaxGCPauseMillis=50ms
| 588 MB | 15.1% | 7.9 |
Xms=Xmx=2048m
| 2 GB | 5.1% | 9.0 |
Xms=3560m;MaxGCRatio=19
| 2.1 GB | 8.8% | 9.0 |
如果测试中应用程序消耗了大量的时间在垃圾回收上,情况就不一样了。这时,JVM 将无法达到设定的吞吐量目标,即只花总运行时间的 1% 在垃圾回收上,它会拼命地尝试各种途径来达到设定的目标,最终使用的堆空间为 1.7 GB。
如果设定的停顿时间目标不切实际,情况会更糟。为了让垃圾收集的时间控制在 50 毫秒以内,我们需要将堆的大小保持在 588 MB 以下,但这又意味着垃圾收集的频率变得过于频繁。最终,应用程序的吞吐量会因此显著降低。这种情况下,让 JVM 使用整个堆的容量,即将堆的初始值和最大值都设置成 2 GB 能获得更好的性能。
最终,我们通过努力将堆的大小设置得比较合理,将时间比率目标也设置得比较现实(5%),表的最后一行展示了这时的结果。JVM 通过自身的计算确定了 2 GB 是最优的堆大小,达到了设定的吞吐量目标,这与手工调整的效果几乎一致。
快速小结
1. 采用动态调整是进行堆调优极好的入手点。对很多的应用程序而言,采用动态调整就已经足够,动态调整的配置能够有效地减少 JVM 的内存使用。
2. 静态地设置堆的大小也可能获得最优的性能。设置合理的性能目标,让 JVM 根据设置确定堆的大小是学习这种调优很好的入门课程。
