【问题标题】:Java parallel GC: unnecessary Full GCJava 并行 GC:不必要的 Full GC
【发布时间】:2020-11-10 01:51:30
【问题描述】:

我有一项服务,它从源读取数据,对数据执行一些转换,然后将转换后的数据上传到目标。在选择 GC 算法时,我正在寻找具有高吞吐量的算法,这就是我选择并行 GC 的原因。让我很困惑的部分是为什么我看到了大量的 Full GC。服务的性质使大多数对象随着数据的来来去去而短暂存在。这是我的 GC 配置:

-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintTenuringDistribution -verbose:GC -XX:+UseParallelGC -XX:NewSize=21200m -XX:MaxNewSize=21200m -server -Xms31200m -Xmx31200m

基本上,我将总堆大小设置为 30GB,将新生代大小设置为 20GB。

这是一段 GC 日志:

2020-11-08T08:31:07.863+0000: 215.876: [Full GC (Ergonomics) [PSYoungGen: 1233347K->0K(18729472K)] [ParOldGen: 9065862K->6633660K(10240000K)] 10299209K->6633660K(28969472K), [Metaspace: 107588K->107588K(1144832K)], 1.1350824 secs] [Times: user=21.03 sys=0.00, real=1.14 secs]
2020-11-08T08:31:10.627+0000: 218.640: [GC (GCLocker Initiated GC)
Desired survivor size 2699034624 bytes, new threshold 1 (max 15)
[PSYoungGen: 15874560K->1274938K(19073024K)] 22513996K->7914375K(29313024K), 0.1073842 secs] [Times: user=3.10 sys=0.00, real=0.11 secs]
2020-11-08T08:31:12.319+0000: 220.331: [GC (GCLocker Initiated GC)
Desired survivor size 2587885568 bytes, new threshold 1 (max 15)
[PSYoungGen: 17602106K->1307000K(18962944K)] 24253865K->8618788K(29202944K), 0.2492961 secs] [Times: user=7.16 sys=0.00, real=0.25 secs]
2020-11-08T08:31:14.197+0000: 222.210: [GC (GCLocker Initiated GC)
Desired survivor size 2480930816 bytes, new threshold 1 (max 15)
[PSYoungGen: 17634168K->1333816K(19286016K)] 24952891K->9297010K(29526016K), 0.2524904 secs] [Times: user=7.07 sys=0.00, real=0.25 secs]
2020-11-08T08:31:16.165+0000: 224.178: [GC (GCLocker Initiated GC)
Desired survivor size 2386558976 bytes, new threshold 1 (max 15)
[PSYoungGen: 18092600K->1313137K(19181568K)] 26062932K->9992006K(29421568K), 0.2845171 secs] [Times: user=7.85 sys=0.00, real=0.29 secs]
2020-11-08T08:31:18.084+0000: 226.096: [GC (GCLocker Initiated GC)
Desired survivor size 2312110080 bytes, new threshold 1 (max 15)
[PSYoungGen: 18071921K->1242981K(19450880K)] 26751020K->10584632K(29690880K), 0.2523254 secs] [Times: user=6.79 sys=0.00, real=0.26 secs]
2020-11-08T08:31:18.336+0000: 226.349: [Full GC (Ergonomics) [PSYoungGen: 1242981K->0K(19450880K)] [ParOldGen: 9341651K->6896991K(10240000K)] 10584632K->6896991K(29690880K), [Metaspace: 107625K->107625K(1144832K)], 1.0198299 secs] [Times: user=18.34 sys=0.08, real=1.02 secs]
2020-11-08T08:31:21.049+0000: 229.062: [GC (GCLocker Initiated GC)
Desired survivor size 2221408256 bytes, new threshold 1 (max 15)
[PSYoungGen: 17120256K->1356565K(19378176K)] 24043241K->8279559K(29618176K), 0.1089915 secs] [Times: user=3.38 sys=0.00, real=0.11 secs]
2020-11-08T08:31:22.887+0000: 230.899: [GC (GCLocker Initiated GC)
Desired survivor size 2155872256 bytes, new threshold 1 (max 15)
[PSYoungGen: 18476821K->1265473K(19603456K)] 25426058K->8896652K(29843456K), 0.2524566 secs] [Times: user=7.14 sys=0.00, real=0.25 secs]
2020-11-08T08:31:24.888+0000: 232.901: [GC (GCLocker Initiated GC)
Desired survivor size 2092433408 bytes, new threshold 1 (max 15)
[PSYoungGen: 18699585K->1388375K(19539456K)] 26345045K->9562491K(29779456K), 0.2113546 secs] [Times: user=5.59 sys=0.00, real=0.21 secs]
2020-11-08T08:31:26.819+0000: 234.832: [GC (GCLocker Initiated GC)
Desired survivor size 2030043136 bytes, new threshold 1 (max 15)
[PSYoungGen: 18822487K->1308016K(19726336K)] 27003840K->10002863K(29966336K), 0.2078162 secs] [Times: user=6.10 sys=0.00, real=0.21 secs]
2020-11-08T08:31:28.868+0000: 236.881: [GC (GCLocker Initiated GC)
Desired survivor size 2030043136 bytes, new threshold 1 (max 15)
[PSYoungGen: 18990960K->1521040K(19665408K)] 27712283K->10780549K(29905408K), 0.2373748 secs] [Times: user=6.60 sys=0.00, real=0.23 secs]
2020-11-08T08:31:29.106+0000: 237.119: [Full GC (Ergonomics) [PSYoungGen: 1521040K->0K(19665408K)] [ParOldGen: 9259509K->7378423K(10240000K)] 10780549K->7378423K(29905408K), [Metaspace: 107653K->107653K(1144832K)], 1.0809680 secs] [Times: user=20.55 sys=0.00, real=1.09 secs]

日志中有几件事让我很困惑:

  1. JVM 如何计算所需的幸存者大小?为什么它大约是 2.5 GB?为什么每次软 GC 都会有一点点变化?为什么老一代的总大小从未改变(10240000K),而年轻一代的总大小却一直在变化?
  2. 为什么 *新阈值 始终为 1?这不是太激进了,不能将东西转移到老一代吗?
  3. 在每次软 GC 之后,young gen 很可能有大约 1.3GB 的数据,并且一些数据被移动到 old gen。这导致 old gen 逐渐变满,Full GC 最终碰巧清理了 old gen。为什么每次软 GC 都会有一部分数据被移到老年代?看起来幸存者空间足够大。
  4. 如何避免不必要的 Full GC,从而提高整体吞吐量?

【问题讨论】:

  • 我将只解决第 4 点,因为下面的答案涉及前 3 点。您选择了一个 GC,它在真正需要之前不会执行 old gen(或 full)gc。 Full gc 是最昂贵的,cpu 周期专门用于您的工作。使用并发 gc:s 将删除部分或全部完整 gc:s 但您将失去 cpu 周期。因此,不能保证并发 gc 实际上会更快。
  • 几件事: 1. 我看到即使使用 Full GC,JVM 也无法从老年代清除太多空间。这表示长引用的对象。看起来您的应用程序正在保持 ~6.5GB 对象“活动”。然后我注意到你为老一代留下了大约 10GB。我认为这是一个非常紧张的情况。我不确定你为什么要设置一个巨大的年轻一代,它是老一代的两倍。这可能是您经常看到 Full GC 的原因。 2. 根据这篇博客[blogs.oracle.com/poonam/…,Parallel GC不使用年龄表。
  • contd...所以我认为在这种情况下对象年龄并不重要。当你在探索不同的 GC 算法时,我建议从基础开始。首先,检查“开箱即用”不同 GC 策略的执行情况。之后,您可以开始从很多中调整速度更快的那个。在这种情况下,我建议删除那些 NewSize 参数。您还可以添加 -XX:+UseParallelOldGC 以使用并行线程更快地收集旧代。
  • 还可以使用 -XX:+PrintAdaptiveSizePolicy 来获取年轻代中存活的数据量以及每次年轻 GC 提升到老年代的数据量信息。它将帮助您进一步调查。
  • @suv3ndu “看起来您的应用程序正在保持约 6.5GB 对象“活动”,这是一个很好的观点,这绝对是出乎意料的。应用程序从一个源读取数据,对数据进行一些转换,然后然后将它们上传到目的地。内存占用不应该是这样的。我需要弄清楚为什么有些对象的寿命比预期的要长。

标签: java garbage-collection


【解决方案1】:

我将仅解决第 4 点,因为 Sachith 的答案涉及前 3 点。您选择了一个 GC,它在真正需要之前不会执行 old gen(或 full)gc。 Full gc 是最昂贵的,cpu 周期专门用于您的工作。使用并发 gc:s 将删除部分或全部完整 gc:s 但您将失去 cpu 周期。因此,不能保证并发 gc 实际上会更快。 另外,根据您的标志-Xms31200m -Xmx31200m。您将堆的最小和最大大小设置为相同,这意味着 VM 不会在堆上执行任何人体工程学(适应)。 根据您的应用程序性能的重要性以及您拥有一个不错的测试环境,我建议您测试不同的 gc:s 并查看您获得的性能。除了最大堆之外,我还将对所有内容都使用出厂设置,看看能走多远。

【讨论】:

  • 1) 所以你认为如果你在31GB 附近指定那些,但只使用1GB 并且你将目标指向200ms 默认暂停,JVM 不会释放内存( even with G1GC before java-12) 而不是调整区域大小?你有一个惊喜。 2)让一个没有指定最大堆的应用程序只是一个糟糕的主意——听说过容器吗?
  • 如果您阅读了我实际写的内容,我特别说过应该设置最大堆。我也没有提到将内存释放回操作系统。我假设 OP 实际上需要 31 GB 并且对他得到完整的 gc:s 感到不高兴。事实上,重新阅读您的评论我认为您根本没有解决我的回复。
  • ...不会在堆上执行任何人体工程学(适应),这不正是我想说的:memory back ...而不是调整区域大小..?最大堆部分,我承认,我看错了,并为此道歉。
  • 当使用并行收集器时,收集器将尝试调整老一代和年轻一代区域的大小,但仅限于 MS 和 MX 给定的边距内,换句话说,是自适应的。这通常被认为是一件好事。您付出的代价是启动时间,并且需要更长的时间才能达到某种稳定状态。我不知道它是否包括将堆返回到操作系统。如果设置 MX==MS 则自适应状态关闭。
  • Erik,你是对的,我不知道我是如何在我的 both 中错过了这一点。将XmxXms 设置为相同值的事实并不一定意味着自适应已关闭。这实际上意味着对于 ParallelGC 和 G1GC目前,但是没有什么可以阻止任何其他 GC 或未来的 java 版本来缩小堆(并因此调整区域大小)低于Xms。不过,我只想说清楚,目前情况就是这样。
【解决方案2】:

嗯,简单的解释无法回答你的问题;

  1. JVM 使用-XX:SurvivorRatio 参数来定义幸存者代大小。默认值为-XX:SurvivorRatio=8。这是一个比率,这个平均幸存者空间是伊甸园空间大小的八分之一。对于您的情况,这会给出您的幸存者空间大小 - 1/8 * 20GB。根据this 文件,这通常对性能并不重要。由于您为年轻一代设置了固定的大尺寸,因此老一代保持不变。为 ParallelGC 使用 -XX:+UseAdaptiveSizePolicy 可能有助于调整年轻/旧边界周围的大小。此外,年轻代越大,GC 次要收集发生的频率就越低。似乎这些小收藏品会是您看到生存空间略微缩小和增长的情况。

  2. threshold 已被 JVM 选择用于 ParallelGC。按照这个article

如果幸存者空间太小,复制集合溢出 直接进入终身代。如果幸存者空间太 大,它们将是无用的空。每次垃圾回收时, 虚拟机选择一个阈值数,即次数 一个对象可以在它被终身复制之前被复制。选择此阈值 让幸存者保持半满。

这似乎是一种攻击性行为。但是次要收集周期明显不同,而且如果需要,阈值似乎也可以更改为最多 15 个。

  1. 如果某些对象在年轻代中存活了所需数量的垃圾回收周期,按照 ParallelGC 的设计,它们注定会移动到老年代。你无法保证,年轻代有多大,存活时间长的对象会永远留在年轻代中。年轻代用于快速分配和释放对象,而不是长期存在的对象。因此,正如您所观察到的,最终老年代会被填充和清理。

  2. 假设您使用的是 Java 8 或更高版本,为了提高程序的吞吐量,我想说,使用 G1GC 而不是 ParallelGC。由于您的堆非常大,因此 G1GC 将是理想的选择。 G1GC 算法旨在以最小的暂停时间在非常大的 terra 字节 (TB) 堆空间上执行。 G1GC 建议在大于 6GB 的堆上使用 (Garbage First Garbage Collector Tuning)。使用 G1GC 时,如果您的程序使用大型 String 对象,-XX:+UseStringDeduplication 将有很大帮助。此 GC 将整个堆空间划分为多个小区域,并使用并行和并发线程执行收集过程。

还有另外两个实验性 GC(ZGCShenandoah)分别随 Java 11 和 Java 12 发布。这些 GC 通过更多垃圾回收显着减少了暂停时间。

更新: ZGC 和 Shenandoah 稳定版本随 2020 年 9 月发布的 Java 15 一起提供。

【讨论】:

  • 1) 根本没有暂停时间错误,它们总是会暂停,即使是很短的时间。 2)它们不再是实验性的 3)UseAdaptiveSizePolicy 将取消SurvivorRatio (默认情况下启用)4)如果你让年轻一代变大 - 你的次要集合也会变得更大,即使在时间上分散......你的答案中有更多的点让你很困惑。
  • 我同意你关于第一点的观点,ZGC 和 Shenandoah 的暂停时间确实很少。我认为并发线程使这些 GC 不会暂停 GC,但这是错误的。关于第二点,它们在 Java 15 之前都是实验性的(至少在特定平台上不支持)。所以,是的,现在它们不是实验性的。而且我并不是说UseAdaptiveSizePolicySurvivorRatio 两个标志应该一起使用,显然它们做了一些相反的操作。如果还有更多令人困惑的地方,我很高兴您能指出它们,这将对我和 OP 都有帮助。谢谢!
猜你喜欢
  • 1970-01-01
  • 1970-01-01
  • 2012-11-18
  • 1970-01-01
  • 1970-01-01
  • 2013-05-16
  • 1970-01-01
  • 1970-01-01
  • 2018-10-15
相关资源
最近更新 更多