1个月前 (01-08)  jvm |   抢沙发  37 
文章评分 0 次,平均分 0.0

JVM的聪明把我们宠坏了。它在幕后做出了太多的决定,以至于我们很多人都放弃了去看里面的东西。与记忆相关的讨论可能更容易出现在会议或面试中,而不是“真正的”工作中。当然,这取决于你在做什么。

如今,Java应用程序通常在容器中运行。内置的容器感知使JVM尊重各种特定于容器的限制(例如CPU、内存)。这意味着,即使在使用伪java-jar app.jar运行应用程序时,一切都应该正常工作。这可能就是为什么提供的唯一与内存相关的选项通常是-Xmx标志(或其任何等价物)。换句话说,我们倾向于只限制最大堆大小,如下所示:

java -Xmx256m <hopefully few other options here> -jar app.jar 

看到这样的应用程序,我不禁想知道:当我们忽略其他与堆相关的标志时会发生什么?是否存在性能损失,尤其是在使用小堆运行时?引擎盖下面发生了什么?这里的容器有什么不同吗?

简单的答案是:JVM会为我们选择堆配置,但它的选择可能不是冰箱里最酷的啤酒。即使使用小堆,性能影响也可能是显而易见的,尤其是与有时默认的串行GC相结合。

让我们试着从一个实验开始解释它取决于什么。

注意:在本文中,术语“Java”和“JVM”指的是OpenJDK项目中最流行的HotSpot虚拟机。其他Java虚拟机实现(如Eclipse OpenJ9)的行为可能有所不同。所有的测试都是使用AmazonCorettoOpenJDK17发行版进行的。

试验

我创建了一个非常简单的Spring Boot 2.7应用程序,它公开了一个反应式REST端点和一组默认的执行器端点。选择这些依赖项只是为了让应用程序在启动时保持忙碌。已指示应用程序本身停止(通过调用System.exit(0);)在它完全初始化之后。它还采用以下配置进行了码头化:

FROM amazoncorretto:17.0.5-al2
COPY target/experiment-0.0.1-SNAPSHOT.jar app.jar
CMD ["java", "-Xmx128m", "-XX:+UseSerialGC", "-Xlog:gc", "-jar", "app.jar"]

然后,我继续运行应用程序,只更改容器的内存限制。其余参数(单CPU、最大堆大小为128m、启用串行GC和GC日志)保持不变:

❯ docker build -t heap-experiment:latest . >/dev/null 2>&1
❯ docker run --cpus=1 --memory=512m  heap-experiment:latest > logs-512m.txt
❯ docker run --cpus=1 --memory=1024m heap-experiment:latest > logs-1024m.txt
❯ docker run --cpus=1 --memory=1536m heap-experiment:latest > logs-1536m.txt
❯ docker run --cpus=1 --memory=2048m heap-experiment:latest > logs-2048m.txt
❯ docker run --cpus=1 --memory=4096m heap-experiment:latest > logs-4096m.txt

使用每次运行生成的GC日志,我能够计算出垃圾收集所花费时间的基本统计信息。单CPU与串行GC相结合,确保每次GC暂停实际上都在停止我们的应用程序。结果如下:

Container memory Pause Young events Pause Young total time Pause Full events Pause Full total time Total GC time
512m 103 69.627 ms 2 23.625 ms 93.252 ms
1024m 68 60.613 ms 1 15.540 ms 76.153 ms
1536m 49 54.170 ms 1 16.479 ms 70.649 ms
2048m 38 55.748 ms 1 14.935 ms 70.683 ms
4096m 18 40.504 ms 1 15.231 ms 55.735 ms

两种配置的GC总时间(512m与4096m)之间的最大差异接近37.5 ms。JVM将这段时间花在了额外的垃圾收集上,这显然是可以避免的。在某些用例中,启动时的这种差异实际上可能是显著的(甚至影响可靠性)!

那么,我们是否应该盲目地增加容器的内存限制呢?不是。相反,让我们看看这种差异是从哪里来的。

对于不耐烦的人:如果你想跳过关于JVM内部的部分,你可以直接跳到最后一段,再次讨论结果。

JVM人体工程学

JVM中负责许多默认配置选择的“神奇”部分被称为人机工程学。

人机工程学是Java虚拟机(JVM)和垃圾收集启发法(如基于行为的启发法)提高应用程序性能的过程。

JVM为垃圾收集器、堆大小和运行时编译器提供了依赖于平台的默认选择。此外,基于行为的调优动态优化堆的大小,以满足应用程序的指定行为。

HotSpot虚拟机垃圾收集调整指南:https://docs.oracle.com/en/java/javase/17/gctuning/ergonomics.html#GUID-DB4CAE94-2041-4A16-90EC-6AE3D91EC1F1

人机工程学过程做出的决定取决于目标环境(平台)。像CPU的数量或可用内存的数量这样的东西真的很重要。人机工程学行为可能因计算机和容器而异,这使得预测变得不那么简单。

堆大小是JVM人机工程学控制的一个方面,除非直接配置。快速回顾:堆是存储应用程序实例化的所有对象和数组的地方。这也是我们在谈论内存消耗时最常看到的(尽管它比这更复杂)。简而言之,JVM为我们分配了一定量的内存,这样我们就可以将应用程序的数据保存在那里。

要查看人机工程学控制的一些堆相关选项,我们可以设置-XX:+PrintFlagsFinal选项:

❯ java -XX:+PrintFlagsFinal -version 2>&1 | grep ergonomic | grep Heap | tr -s ' '
 size_t G1HeapRegionSize = 4194304 {product} {ergonomic}
 size_t InitialHeapSize = 536870912 {product} {ergonomic}
 size_t MaxHeapSize = 8589934592 {product} {ergonomic}
 size_t MinHeapDeltaBytes = 4194304 {product} {ergonomic}
 size_t MinHeapSize = 8388608 {product} {ergonomic}
 uintx NonNMethodCodeHeapSize = 5839564 {pd product} {ergonomic}
 uintx NonProfiledCodeHeapSize = 122909338 {pd product} {ergonomic}
 uintx ProfiledCodeHeapSize = 122909338 {pd product} {ergonomic}
 size_t SoftMaxHeapSize = 8589934592 {manageable} {ergonomic}

涵盖所有这些选项对本文来说太多了。幸运的是,其中只有三个与我们的示例最相关:MinHeapSizeInitialHeapSizeMaxHeapSize

堆大小配置

我将在这里使用一种在运行时获取当前堆大小配置的替代方法。通过设置-Xlog:gc+init,JVM将在启动时记录一些与JVM相关的配置参数。

❯ java '-Xlog:gc+init' \
    -XX:MinHeapSize=16m \
    -XX:InitialHeapSize=32m \
    -XX:MaxHeapSize=100m \
    -jar app.jar 2>&1 | grep Capacity
    
[0.003s][info][gc,init] Heap Min Capacity: 16M
[0.003s][info][gc,init] Heap Initial Capacity: 32M
[0.003s][info][gc,init] Heap Max Capacity: 104M

这些配置值映射到java命令的特定选项:

  • 最小容量(-XX:MinHeapSize=size):内存分配池的最小大小(以字节为单位)
  • 初始容量(-XX:InitialHeapSize=size):内存分配池的初始大小(以字节为单位)
  • 最大容量(-XX:MaxHeapSize=size,简称-Xmx):内存分配池的最大大小(以字节为单位)

等一下…著名的-Xms在哪里?尽管经常混淆,-Xms将堆的最小大小和初始大小都设置为相同的值。让我们举一个例子来说明

❯ java '-Xlog:gc+init' -Xms32m -Xmx100m \
    -jar app.jar 2>&1 | grep Capacity
    
[0.003s][info][gc,init] Heap Min Capacity: 32M
[0.003s][info][gc,init] Heap Initial Capacity: 32M
[0.003s][info][gc,init] Heap Max Capacity: 104M

好的,但是如果我们不明确地设置这些值呢?这就是JVM人机工程学的用武之地。根据HotSpot Virtual Machine Garbage Collection Tuning Guide:https://docs.oracle.com/en/java/javase/17/gctuning/ergonomics.html#GUID-DA88B6A6-AF89-4423-95A6-BBCBD9FAE781,默认值为:

物理内存的1/64的初始堆大小

最大堆大小为物理内存的1/4

不幸的是,指南中没有提到最小堆大小。java命令引用声明:

默认值是在运行时根据系统配置选择的。

根据我使用Docker运行的一些测试,无论有多少内存可用,默认的最小堆大小很可能是8M。然而,我不能向你保证它总是这样。JVM人机工程学有很多优点,但可预测性肯定不是其中之一…

动态调整堆大小

启动时,JVM为堆分配一定量的内存(初始容量)。在应用程序生命周期中,人机工程学过程可以根据确定的应用程序需求来决定缩小或扩大堆。然而,堆的大小必须始终介于最小容量和最大容量之间,这给了我们一个简单的公式:

Min Capacity <= Initial Capacity <= Max Capacity

JVM是如何做出这样的决定的?再一次,我们可以在HotSpot虚拟机垃圾收集调优指南:https://docs.oracle.com/en/java/javase/17/gctuning/factors-affecting-garbage-collection-performance.html#GUID-B0BFEFCB-F045-4105-BFA4-C97DE81DAC5B 中找到一些提示:

默认情况下,虚拟机会增加或缩小每个集合的堆,以尝试将每个集合的可用空间与活动对象的比例保持在特定范围内。

默认情况下,JVM的目标是在一代中保持40%到70%的可用空间。相应的配置选项为-XX:MinHeapFreeRatio-XX:MaxHeapFreeRatio

听起来很简单?让我让你失望吧。在同一指南中,我们可以看到JVM还可以尝试“优先满足”两个目标之一:最大暂停时间(-XX:MaxGCPauseMillis)和吞吐量(理解为未用于垃圾收集的时间百分比,-XX:GCTimeRatio)。当没有达到首选目标时,JVM将尝试达到另一个目标。如果同样失败,则可能会调整堆的大小。

更不清楚的是,所选的垃圾收集器也可能影响此堆大小调整策略。根据-XX:MaxGCPauseMillis选项文档:

默认情况下,其他几代收集器不使用暂停时间目标。

根据指南(https://docs.oracle.com/en/java/javase/17/gctuning/ergonomics.html#GUID-034BAF7C-2F2E-483D-8606-0BF2B8710BC9),即使在一些相当稳定的条件下,我们也可能预计堆大小会发生变化:

通常情况下,堆的大小会随着垃圾收集器试图满足竞争目标而波动。即使应用程序已达到稳定状态,也是如此。实现吞吐量目标(可能需要更大的堆)的压力与最大暂停时间和最小占地面积(两者都可能需要小堆)的目标相竞争。

最小堆大小限制GC攻击性。即使根据人体工程学,堆应该进一步缩小,也不能低于这个值。我们自己选择错误的值可能会阻止JVM实现上述目标。将其保留为默认值(很可能是8MB),允许进行人体工程学过程的实验。

每一个次优JVM决策都会降低应用程序的速度。如果选定的值太小,GC压力可能会增加。如果它太大,GC暂停时间可能会比实际时间长。然而,对于我们的许多应用程序来说,这可能已经足够好了。这也绝对比猜测要好。然而,如果您为每一毫秒而奋斗,您可能想要限制JVM人机工程学的自由度。

此外,JVM标识的“稳定状态”可能因起点而异。仅增加我的一个实际应用程序的初始堆大小,就显著减少了平均GC时间和垃圾收集频率。重要的是,这种比较是在相同的受控负载下进行的。

观察JVM的人机工程学可以告诉你很多关于你的应用程序的信息。根据JVM确定的稳定状态选择堆配置选项感觉是一个非常好的起点。通过这种方式,我们可以尝试制作当前设置的“快照”,然后将其转换为-Xms-Xmx等配置参数。

试验重访

由于我们现在对自动化堆大小有了更多的了解,因此很容易解释实验中发现的差异。唯一对内存限制敏感的默认值是初始堆大小,这在应用程序运行时有所不同。让我们更新结果表以更好地说明这一点。

Initial heap size Container memory equivalent Pause Young events Pause Young total time Pause Full events Pause Full total time Total GC time
8m 512m 103 69.627 ms 2 23.625 ms 93.252 ms
16m 1024m 68 60.613 ms 1 15.540 ms 76.153 ms
24m 1536m 49 54.170 ms 1 16.479 ms 70.649 ms
32m 2048m 38 55.748 ms 1 14.935 ms 70.683 ms
64m 4096m 18 40.504 ms 1 15.231 ms 55.735 ms

初始堆大小越小,观察到的GC暂停就越多。从理论上讲,如果应用程序生成的所有对象都符合初始堆大小(假设有适当的可用空间缓冲区),那么我们在启动时就不需要一个GC Pause。

有趣的是,前两次运行的最终堆使用量(在停止应用程序之前测量)接近2200万。在8m/512m的情况下,堆在到达那里之前已经调整了3次大小。在16m/1024m的版本中,只需要调整一次大小。这就解释了这两者在GC时间上的显著差异。它还证明了动态调整大小是有代价的。

对于更大(更忙)的应用程序,我预计启动时的差异会更大。由于他们的初始化过程要复杂得多,这也会给GC带来更多的工作。这就是为什么选择正确的初始堆大小可能如此重要。

在启动时,初始堆大小似乎比最小堆大小更重要。由于应用程序通常会生成大量对象,因此减少堆的可能性相对较低。如果内存压力下降,那么最小堆大小稍后可能会产生更大的影响。

即使使用非单线程GC和更多可用的CPU“核心”,完整GC暂停也可能很痛苦。由于它们是最昂贵的GC操作,我们应该尽可能地限制它们的数量。根据结果,过低的初始堆大小会使应用程序启动期间的Full-GC暂停更加频繁。

我选择观察应用程序启动而不是长时间应用程序操作的原因是可重复性。后者在很大程度上取决于产生的(人为的)负荷,这可能与“真实的”负荷非常不同。启动一个Spring Boot应用程序感觉就像是一个典型的、真实的用例。

总结

当您仅限制最大堆大小时,JVM人机工程学将选择最小和初始大小。初始堆大小默认为可用内存的1/64。因此,当在容器中运行时,最好显式设置它。

根据我的实验,太小的初始堆大小可能会增加GC压力,甚至影响应用程序的启动时间。你越关心整体延迟和吞吐量,就越有可能需要介入。自行定义与堆大小相关的限制可能会对这里产生重大影响。

JVM人机工程学是一门真正的艺术,但它也很难预测。JVM将尽力在运行时调整设置,但这并不能确保其选择是最佳的。即便如此,从性能的角度来看,走向这些选择的道路有时可能是不可接受的。然而,通过观察所选择的值可以作为更高级调整的良好起点。

旁注

  • 前面提到的java命令选项并不是调整堆大小的唯一方法-XX:MinRAMPercentage-XXX:InitialRAMPercentation-XXX:MaxRAMPercentage。然而,他们的行为并不总是像我们想象的那样。有些人将这些标志宣传为更好的标志,因为它们允许将堆与容器的内存一起缩放。然而,在特定的危机情况下,盲目增加两者可能会使情况变得更糟。可以说我过时了,但我个人更喜欢明确地设置尺寸。
  • 无论您选择哪种最大堆大小标志,请记住:限制最大堆大小没有错误的方法。在我能想到的大多数情况下,这是一个值得拥有的安全网。只需选择其中一个并了解它是如何工作的。
  • 在某些场景中,可能值得调整更复杂的方面,如堆的Young和Old代的大小(当使用像G1这样的收集器时)。如果你的应用程序产生的短命对象明显多于长寿对象(或者相反),你可能想尝试一下。然而,对于大多数应用程序来说,这可能有点太多了。
  • 提高自动调优可预测性的最广为人知的方法可能是禁用自动堆大小调整:
  • -Xms-Xmx设置为相同的值可以从虚拟机中删除最重要的大小调整决定,从而提高可预测性。但是,如果您选择不当,虚拟机将无法进行补偿。
  • 前面提到的HotSpot虚拟机垃圾收集调整指南是一个很好的知识来源。尽管它不能回答我们所有的问题,但我仍然建议阅读它:https://docs.oracle.com/en/java/javase/17/gctuning/introduction-garbage-collection-tuning.html#GUID-326EB4CF-8C8C-4267-8355-21AB04F0D304
  • 在Kubernetes上运行时,配置其他JVM参数可能很棘手。幸运的是,Bruno Borges有一个名为“Kubernetes上性能调优Java的秘密”的精彩演示:https://vimeo.com/748031919,涵盖了其中的许多内容。相信我,看完之后,你再也不会用同样的方式看你的容器了!

原文地址:https://mikemybytes.com/2022/11/15/what-happens-when-you-only-limit-the-maximum-heap-size/

 

除特别注明外,本站所有文章均为老K的Java博客原创,转载请注明出处来自https://javakk.com/2950.html

关于

发表评论

表情 格式

暂无评论

登录

忘记密码 ?

切换登录

注册