0
点赞
收藏
分享

微信扫一扫

第六章 垃圾收集算法

墨春 2022-02-20 阅读 111

第5章研究了所有垃圾收集器的一般行为,包括适用于所有GC算法的JVM标志:如何选择堆大小、分代大小、日志等等。垃圾收集的基本调优可在许多情况下都是足够的了。如果没有的话,那么就应该检查使用的GC算法的具体操作,以确定如何更改其参数,以最大限度地减少GC对应用程序的影响。

调优某个收集器所需的关键信息是启用该收集器时来自GC日志的数据。本章首先从日志输出的角度来看每个算法,这让我们了解GC算法如何工作,以及如何调整它以更好地工作。然后,每个部分都包含调优信息,以实现更好的性能。

本章还将详细介绍一些新的实验性收集器。在撰写本文时,这些收集器可能还不是100%可靠的,但到Java的下一个LTS版本发布时,可能会成为成熟的的收集器(就像G1 GC开始时是一个实验性收集器,现在是JDK 11中的默认值)。

一些不寻常的情况会影响所有 GC 算法的性能:分配非常大的对象、既不是短期对象也不是长期对象,等等。这些情况将在本章末尾说明。

Understanding the Throughput Collector

我们首先从吞吐量收集器开始。尽管我G1 GC 收集器通常是我们的首选,但吞吐量收集器的细节更容易理解,并为理解其工作原理奠定了更好的基础。

回想第5章,垃圾收集器必须执行三种基本操作:发现未使用的对象【mark】、释放其内存【sweep】以及压缩堆【compact】。吞吐量收集器在同一个GC周期内完成所有这些操作;这些操作统称为一个收集【collection】。这些收集器可以在单个操作中收集年轻代或老年代。

图6-1显示了年轻代收集前后的堆:

Figure 6-1. A throughput GC young collection

 

当伊甸区被填满时,就会触发一次年轻代收集。年轻代收集将所有对象移出伊甸区:一些对象移到其中一个幸存者空间(上图中的S0),一些对象移到现在包含更多对象的老年代。当然,许多对象被丢弃,因为它们不再被引用。

因为伊甸区在此操作后通常是空的,所以可以认为它已被压缩了。

在带有 PrintGCDetails 的 JDK 8 GC 日志中,吞吐量收集器的 Monor GC 如下所示:

17.806: [GC (Allocation Failure) [PSYoungGen: 227983K->14463K(264128K)]
             280122K->66610K(613696K), 0.0169320 secs]
	     [Times: user=0.05 sys=0.00, real=0.02 secs]

此 GC 发生在程序启动后 17.806 秒。年轻代中的对象现在占用 14463 KB (14MB,全部在幸存者空间中);在GC之前,它们占用了227,983 KB (227 MB)。此时,年轻代的总大小是264 MB。

同时,堆的整体入住率【occupancy 】(包括年轻代和老年代)从 280 MB 减少到 66 MB ,此时,整个堆的大小是 613 MB。这个操作花了不到 0.02 秒(实际输出是0.0169320)。由于年轻的收集是由多个线程(在此配置中是四个线程)完成的,因此程序的CPU时间比实时时间要多。

JDK 11中相同的日志看起来像这样:

[17.805s][info][gc,start       ] GC(4) Pause Young (Allocation Failure)
[17.806s][info][gc,heap        ] GC(4) PSYoungGen: 227983K->14463K(264128K)
[17.806s][info][gc,heap        ] GC(4) ParOldGen: 280122K->66610K(613696K)
[17.806s][info][gc,metaspace   ] GC(4) Metaspace: 3743K->3743K(1056768K)
[17.806s][info][gc             ] GC(4) Pause Young (Allocation Failure)
                                          496M->79M(857M) 16.932ms
[17.086s][info][gc,cpu         ] GC(4) User=0.05s Sys=0.00s Real=0.02s

这里的信息是一样的;只是格式不同而已。这段日志记录有好几行;前面的日志条目实际上是一行(但不以这种格式复制)。这个日志还打印出元空间【metaspace】的大小,但是在年轻的收集期间元空间【metaspace】的大小永远不会改变。这个元空间【metaspace】也不包括在这个示例的第 5 行报告的总堆大小中。

图6-2显示了 Full GC前后的堆。

Figure 6-2. A throughput full GC

 

老年代收集释放了年轻代中的一切。遗留在老代中的唯一对象是那些具有活跃引用【active references】的对象,所有这些对象都已被压缩,所以老年代的开始部分全部被占用,而其余部分则是空闲的。.

GC 日志是这样的:

64.546: [Full GC (Ergonomics) [PSYoungGen: 15808K->0K(339456K)]
          [ParOldGen: 457753K->392528K(554432K)] 473561K->392528K(893888K)
	  [Metaspace: 56728K->56728K(115392K)], 1.3367080 secs]
	  [Times: user=4.44 sys=0.01, real=1.34 secs]

年轻代现在占用 0 个字节(它的大小为 339 MB)。请注意,图中的幸存者空间也被清除了。老年代的数据量从 457 MB 减少到 392 MB,因此整个堆的使用量从 473 MB 减少到 392 MB。元空间的大小不变;在大多数 Full GC 期间,它是不会被收集的。(如果元空间空间不足,JVM 将运行一个 Full GC 来收集它,您会看到元空间的大小发生了变化;后面我将进一步展示它。)因为在一个 Full GC 中有更多的工作要做,所以它花费了1.3 秒的实时时间和 4.4 秒的CPU时间(同样是四个并行线程)。

类似的 JDK 11日志如下:

[63.205s][info][gc,start       ] GC(13) Pause Full (Ergonomics)
[63.205s][info][gc,phases,start] GC(13) Marking Phase
[63.314s][info][gc,phases      ] GC(13) Marking Phase 109.273ms
[63.314s][info][gc,phases,start] GC(13) Summary Phase
[63.316s][info][gc,phases      ] GC(13) Summary Phase 1.470ms
[63.316s][info][gc,phases,start] GC(13) Adjust Roots
[63.331s][info][gc,phases      ] GC(13) Adjust Roots 14.642ms
[63.331s][info][gc,phases,start] GC(13) Compaction Phase
[63.482s][info][gc,phases      ] GC(13) Compaction Phase 1150.792ms
[64.482s][info][gc,phases,start] GC(13) Post Compact
[64.546s][info][gc,phases      ] GC(13) Post Compact 63.812ms
[64.546s][info][gc,heap        ] GC(13) PSYoungGen: 15808K->0K(339456K)
[64.546s][info][gc,heap        ] GC(13) ParOldGen: 457753K->392528K(554432K)
[64.546s][info][gc,metaspace   ] GC(13) Metaspace: 56728K->56728K(115392K)
[64.546s][info][gc             ] GC(13) Pause Full (Ergonomics)
                                            462M->383M(823M) 1336.708ms
[64.546s][info][gc,cpu         ] GC(13) User=4.446s Sys=0.01s Real=1.34s

QUICK SUMMARY

  • 吞吐量收集器有两个操作:Minor GC 和 Full GC ,每个 GC 都对目标分代进行标记、释放和压缩
  • 从 GC 日志中获取的时间是确定GC对使用这些收集器的应用程序的总体影响的一种快速方法

Adaptive and Static Heap Size Tuning

吞吐量收集器的调优与暂停时间有关,并在总体堆大小与老代和新代的大小之间取得平衡。

这里有两个点需要权衡考虑。

首先,我们有时间与空间的经典编程权衡。更大的堆将消耗机器上更多的内存,使用这些内存的好处是(至少在一定程度上)应用程序将拥有更高的吞吐量。

第二个权衡关系到执行 GC 所需的时长。可以通过增加堆大小来减少 Full GC 暂停次数,但是由于 GC 时间变长了,这可能会产生平均响应时间增加的不利影响。类似地,可以通过将更多的堆分配给年轻代而不是老一代来缩短 Full GC 暂停时间,但这反过来又会增加老年代 GC 收集的频率。

这些权衡的效果如图 6-3 所示。此图显示了以不同堆大小运行的普通 REST 服务器的最大吞吐量。由于只有 256 MB 的堆,服务器在 GC 上花费了大量时间(实际上占总时间的 36%);结果,吞吐量受到限制。随着堆大小的增加,吞吐量迅速增加——直到堆大小设置为 1,500 MB。在那之后,吞吐量增长的速度不那么快了:此时应用程序并没有受到GC的限制(大约 6% 的时间在 GC )。收益递减规律已经悄然出现:应用程序可以使用额外的内存来获得吞吐量,但收益变得更加有限。

堆大小达到 4,500 MB 后,吞吐量开始略有下降。此时,应用程序已经达到了第二个权衡:额外的内存导致了更长的 GC 周期,而这些更长的周期——即使频率不那么频繁——也会降低整体吞吐量。

该图中的数据是通过禁用JVM中的自适应调整大小参数获得的;最小和最大堆大小设置为相同的值。可以在任何应用程序上运行实验,并确定堆和代的最佳大小,但通常更容易让JVM做出这些决定(这通常是发生的,因为默认情况下启用了自适应大小)。

此图中的数据是通过禁用 JVM 中的自适应参数获得的;最小和最大堆大小设置为相同的值。可以在任何应用程序上运行实验并确定堆和代的最佳大小,但让 JVM 做出这些决定通常更容易(这通常会发生,因为KVM默认启用自适应)。

Figure 6-3. Throughput with various heap sizes

 

吞吐量收集器中的自适应将调整堆(以及代)的大小,以满足其暂停时间目标。这些目标是用这些标志设置的:

  • -XX:MaxGCPauseMillis=N 设置最大GC暂停时间
  • -XX:GCTimeRatio=N 设置GC时间占比

MaxGCPauseMillis 参数指定应用程序愿意容忍的最大暂停时间。将其设置为 0 或 50 ms 之类的小值可能很诱人。请注意,这个目标同时适用于 Minor GC 和 Full GC。如果使用了一个非常小的值,应用程序最终将得到一个非常小的老年代:例如,一个可以在50毫秒内清理完的老年代。这将导致JVM非常频繁地执行 Full GC,并且性能将会很差。把这个值设为可以实现的目标。缺省情况下,没有设置此参数。

GCTimeRatio标志指定了您希望应用程序花费在GC上的时间(与应用程序级线程应该运行的时间相比)。它是一个比率,所以 N 的值需要一些思考。这个值被用在下面的等式中,用来确定应用程序线程在理想情况下应该运行的时间百分比:

GCTimeRatio 参数的默认值为99。将该值代入方程可以得到0.99,这意味着我们的目标是将 99% 的时间用于应用程序处理,而仅将1%的时间用于GC。但不要被这些数字在默认情况下是如何排列的所迷惑。GCTimeRatio为95并不意味着GC应该运行高达5%的时间:它意味着GC应该运行达1.94%的时间。

确定你想让应用程序执行工作的最小时间百分比(比如95%),然后从这个方程计算GCTimeRatio的值,这是很容易的:

对于95%(0.95)的吞吐量目标,该方程生成的GCTimeRatio为19。

JVM使用这两个参数在由初始堆大小(-Xms)和最大堆大小(-Xmx)建立的边界内设置堆的大小。MaxGCPauseMillis参数优先:如果设置了它,将调整年轻代和年老代的大小,直到达到暂停时间目标。一旦发生这种情况,堆的总体大小就会增加,直到满足时间比目标为止。一旦这两个目标都得到满足,JVM将尝试减少堆的大小,以便最终得到满足这两个目标的最小堆。

因为默认情况下没有设置暂停时间目标,所以自适应的通常效果是堆(和代)大小将增加,直到满足 GCTimeRatio 目标。但实际上,该标志的默认设置是乐观的。当然,您的经验会有所不同,但我更习惯于看到应用程序花费 3% 到 6% 的时间在GC上,并且运行得很好。有时我甚至在内存严重受限的环境中工作;这些应用程序最终会花费10%到15%的时间在GC上。GC对这些应用程序的性能有很大的影响,但总体性能目标仍然得到了满足。

因此,最佳设置会根据应用程序的目标而有所不同。在没有其他目标的情况下,我从19的时间比率开始(GC占用 5%)。

表 6-1 显示了这种动态调优对于需要小堆和很少进行GC的应用程序的效果(普通 REST 服务器很少有长期存在的对象)。

GC settingsEnd heap sizePercent time in GCOPS

Default

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(因为机器有 8 GB 的物理内存)。在这种情况下,GCTimeRatio 就像预期的那样工作:堆动态调整为 649 MB,此时应用程序在 GC 中花费了大约 1% 的总时间。

在本例中,设置 MaxGCPauseMillis 参数将开始减少堆的大小,以满足暂停时间目标。因为垃圾收集器在这个例子中要执行的工作很少,所以它成功了,并且仍然可以只花费总GC时间的1%,同时保持9.2 OPS的吞吐量。

最后,请注意,堆更多并不总是更好的。一个完整的 2 GB 堆确实意味着应用程序可以在 GC 上花费更少的时间,但 GC 并不是这里的主要性能因素,因此吞吐量不会增加。像往常一样,花时间优化应用程序的错误区域并没有帮助。

如果更改了相同的应用程序,使每个用户的前 50 个请求保存在全局缓存中(例如,JPA 缓存就会这样做),垃圾收集器就必须更加努力地工作。表 6-2 显示了这种情况下的权衡取舍。

Table 6-2. Effect of heap occupancy on dynamic GC tuning
GC settingsEnd heap sizePercent time in GCOPS

Default

1.7 GB

9.3%

8.4

MaxGCPauseMillis=50ms

588 MB

15.1%

7.9

Xms=Xmx=2048m

2 GB

5.1%

9.0

Xmx=3560MMaxGCRatio=19

2.1 GB

8.8%

9.0

在 GC 中花费大量时间的测试中,GC 行为是不同的。在这个测试中,JVM 永远无法满足 1% 的吞吐量目标;它尽最大努力适应默认目标并使用 1.7 GB 空间完成合理的工作。

当给出不切实际的暂停时间目标时,应用程序的行为会变得更糟。为了实现 50 毫秒的收集时间,堆保持在 588 MB,但这意味着 GC 现在变得过于频繁。因此,吞吐量显着下降。在这个场景中,通过将初始堆大小和最大堆大小都设置为 2GB 来指示 JVM 利用整个堆,可以获得更好的性能。

最后,表格的最后一行显示了当堆的大小合理,并且我们将实际的时间比目标设置为 5% 时,会发生什么。JVM自己确定大约2 GB是最优的堆大小,并且它实现了与手工调优的情况相同的吞吐量。

Understanding the G1 Garbage Collector

举报

相关推荐

第六章:接口

第六章总结

第六章 容器

PTA第六章

第六章 BOM

【Flink】【第六章 Window】

第六章 7.0 LinkList

0 条评论