Java垃圾回收调优分步指南
什么是垃圾回收调优?
为什么垃圾回收机制的调优很重要?
何时进行垃圾回收优化?
垃圾回收调优流程:如何调优 Java GC
其他 JVM 选项
结论
使用 Java 应用程序有很多优势,尤其与 C/C++ 等语言相比。大多数情况下,Java 能够实现操作系统和各种环境之间的互操作性。您可以轻松地将应用程序从一个服务器迁移到另一个服务器,从一个操作系统迁移到另一个操作系统,而无需付出太多努力,或者在极少数情况下只需进行少量修改。
运行基于 JVM 的应用程序最吸引人的优势之一就是自动内存管理。当你在代码中创建一个对象时,它会被分配到堆内存中,并一直保留在那里,直到代码再次引用它。当不再需要它时,需要将其从内存中释放出来,以便为新对象腾出空间。在 C 或 C++ 等编程语言中,内存清理需要程序员在代码中手动完成。而在 Java 或 Kotlin 等语言中,我们无需操心——JVM 的垃圾回收器会自动完成这项工作。
什么是垃圾回收调优?
垃圾回收 (GC) 调优是指调整基于 JVM 的应用程序的启动参数,以达到预期结果的过程。仅此而已。它可以像调整堆大小(即-Xmx和-Xms参数)一样简单,而这恰恰是您应该首先着手的地方。它也可以像调整所有高级参数以调整不同的堆区域一样复杂。一切都取决于具体情况和您的需求。
为什么垃圾回收机制的调优很重要?
清理应用程序的 JVM 进程堆内存并非易事。我们需要为垃圾回收器分配资源才能使其正常工作。您可以想象,CPU 本来可以处理应用程序的业务逻辑,但却可能忙于从堆中移除未使用的数据。
因此,垃圾回收器的高效运行至关重要。垃圾回收过程可能非常耗费资源。在我们作为开发人员和顾问的工作中,我们曾遇到过这样的情况:在 60 秒的时间窗口内,垃圾回收器运行了 20 秒。这意味着应用程序有 33% 的时间没有执行其主要任务,而是在进行垃圾清理工作。
我们可以预料到线程会短暂停止运行。这种情况会不断发生:
2019-10-29T10:00:28.879-0100: 0.488: Total time for which application threads were stopped: 0.0001006 seconds, Stopping threads took: 0.0000065 seconds
然而,真正危险的是应用程序线程长时间完全停止运行——比如几秒钟,极端情况下甚至几分钟。这会导致用户完全无法正常使用应用程序。分布式系统也可能因为某些组件无法及时响应而崩溃。
为了避免这种情况,我们需要确保为 JVM 应用程序运行的垃圾回收器配置良好,并且能够尽可能地发挥其作用。
何时进行垃圾回收优化?
首先,你应该明白,调整垃圾回收机制应该是最后才考虑的操作之一。除非你完全确定问题出在垃圾回收机制上,否则不要轻易修改 JVM 的选项。坦白地说,在很多情况下,垃圾回收机制的异常工作方式反而会暴露出更大的问题。
如果你的 JVM 内存利用率良好,垃圾回收器运行正常,那么你无需花费时间去调整垃圾回收机制。你更有可能通过重构代码来提高效率,从而获得更高的效率。
那么,我们如何判断垃圾回收器是否有效呢?我们可以查看监控数据,例如我们自己的Sematext Cloud。它会提供有关 JVM 内存利用率、垃圾回收器工作情况以及应用程序整体性能的信息。例如,请查看以下图表:
在这张图中,你可以看到一种叫做“鲨鱼齿”的现象。通常,这是 JVM 堆内存健康的标志。内存中最大的部分,也就是老年代,会被填满,然后由垃圾回收器清理。如果我们把这个现象和垃圾回收器的运行时间关联起来,就能看到完整的情况。了解了所有这些信息,我们就可以判断垃圾回收机制是否运行良好,或者是否需要进行调优。
你还可以查看我们在“理解 Java GC 日志”博文中讨论过的垃圾回收日志。你也可以使用 jstat 或任何其他性能分析工具。它们会提供关于 JVM 内部运行情况的详细信息,尤其是在堆内存和垃圾回收方面。
在考虑垃圾回收性能调优时,还有一点需要考虑。Java 的默认垃圾回收设置可能并不完全适合您的应用程序。也就是说,与其增加硬件或使用更强大的机器,您或许应该研究一下内存管理方式。有时,调优可以降低运维成本,减少开支,并允许在不扩展环境的情况下实现增长。
一旦你确定是垃圾回收器的问题,并且你想开始优化它的参数,我们就可以开始着手处理 JVM 启动参数了。
垃圾回收调优流程:如何调优 Java GC
在讨论如何调优垃圾回收器时,需要记住JVM世界中存在多种垃圾回收器。对于较小的堆和较旧的JVM版本(例如版本7、8或9),您很可能使用经典的并发标记清除(CMS)垃圾回收器来处理老年代堆。对于较新的JVM版本(例如版本11),您可能使用的是G1GC。如果您喜欢尝试不同的垃圾回收器,则可能会使用最新的JVM版本以及ZGC。请记住,每种垃圾回收器的工作方式都不同,因此它们的调优方法也会有所不同。
使用不同的垃圾回收器运行基于 JVM 的应用程序是一回事,进行实验又是另一回事。Java 垃圾回收调优需要大量的实验和尝试。第一次尝试无法获得理想的结果是很正常的。你需要逐步引入更改,并观察应用程序和垃圾回收器在每次更改后的行为。
无论你进行垃圾回收器调优的动机是什么,我想先明确一点:要能够调优垃圾回收器,你需要了解它的工作原理。这意味着你需要能够查看垃圾回收器的指标或日志,或者两者都查看,后者是最佳方案。
开始GC调优
首先,观察应用程序的运行情况,哪些事件占用了内存空间,以及哪些空间被占用。请记住:
- 伊甸世代中分配的对象会被移至幸存者空间
- 如果计数器足够高或计数器增加,则幸存者空间中分配的对象将移至终身世代。
- 已分配的 Tenured 代对象将被忽略,不会被收集。
你需要确保自己了解应用程序堆内存内部的运作机制,并牢记垃圾回收事件的触发原因。这将有助于你了解应用程序的内存需求以及如何改进垃圾回收机制。
开始调音。
堆大小
您可能会惊讶于设置正确的堆大小竟然如此频繁地被忽略。作为咨询顾问,我们见过不少这样的例子,请相信我们。首先,请检查您的堆大小是否设置得当。
为应用程序设置堆内存时应该考虑哪些因素?这当然取决于许多因素。有些系统,例如 Apache Solr 或 Elasticsearch,对 I/O 的依赖性很高,并且会共享操作系统文件系统缓存。在这种情况下,您应该尽可能多地为操作系统分配内存,尤其是在数据量很大的情况下。如果您的应用程序需要处理大量数据或进行大量解析,则可能需要更大的堆内存。
总之,你应该记住,在堆内存大小达到32GB之前,你可以使用所谓的压缩普通对象指针(OOP) 。普通对象指针是指向内存的 64 位指针,它们指向内存,使 JVM 能够引用堆上的对象。至少在不深入了解其内部机制的情况下,它是这样工作的。
JVM 可以使用高达 32GB 的堆内存来压缩 OOP 操作,从而节省内存。你可以这样理解 JVM 世界中压缩后的普通对象指针:
前 32 位用于实际的内存引用,并存储在堆上。32 位足以寻址最大 32GB 堆上的所有对象。我们是如何计算的呢?我们有 2³²——即 32 位指针可以寻址的空间。由于指针尾部有三个零,所以 2³² + 3 = 2³⁵,也就是 32GB 的内存空间。这就是使用压缩的普通对象指针时,我们可以使用的最大堆大小。
堆内存超过32GB会导致 JVM 使用64 位指针。在某些情况下,堆内存从 32GB 增加到 35GB,可用空间可能基本不变。这取决于应用程序的内存使用情况,但您需要考虑这一点,并且可能需要将堆内存增加到 35GB 以上才能看到明显的区别。
最后,我该如何选择合适的堆大小呢?很简单,监控你的内存使用情况,观察堆内存的运行状况。你可以使用监控工具来实现这一点,例如我们的Sematext Cloud及其JVM 监控功能:
您可以查看 JVM 内存池大小和 GC 汇总图表。如图所示,JVM 堆大小呈鲨鱼齿状,这是一个健康的模式。根据第一张图表,我们可以看出该应用程序至少需要 500-600MB 的内存。在本例中,G1 垃圾回收器会在堆大小达到约 1.2GB 时开始释放内存。在这种情况下,垃圾回收器在 60 秒的时间段内运行了约 2 秒,这意味着 JVM 大约 2% 的时间用于垃圾回收。这是一个良好且健康的模式。
我们还可以查看平均垃圾收集时间以及第 99 和第 90 百分位数:
根据这些信息,我们可以看出不需要更高的堆内存。垃圾回收速度很快,能够高效地清除数据。
另一方面,如果我们知道应用程序正在被使用并处理数据,其堆内存占用量超过了我们设置的最大堆内存的 70% 到 80%,并且我们看到垃圾回收机制 (GC) 运行吃力,那么我们就知道情况不妙了。例如,看看这个应用程序的内存池:
你可以看到,一些问题开始出现,老年代内存使用率持续高于 80% 。这与垃圾回收器的工作情况相关:
您可以清楚地看到内存使用率过高的迹象。垃圾回收器在内存未被清理的情况下开始执行更多工作。这意味着即使 JVM 尝试清理数据,也无法成功。这是一个即将出现问题的征兆——堆上没有足够的空间来容纳新对象。但请记住,这也可能是应用程序内存泄漏的迹象。如果您发现内存使用量随时间增长,并且垃圾回收器无法释放内存,则可能是应用程序本身存在问题。这值得检查。
那么,我们如何设置堆大小呢?通过设置其最小和最大大小。最小大小使用JVM 参数 ` -Xms`设置,最大大小使用`-Xmx`参数设置。例如,要将应用程序的堆大小设置为2GB,我们需要在应用程序启动参数中添加`-Xms2g -Xmx2g`。大多数情况下,我还会将它们设置为相同的值,以避免堆大小调整。此外,我还会添加 ` -XX:+AlwaysPreTouch`标志,以便在应用程序启动时将内存页加载到内存中。
我们还可以使用-Xmn属性来控制新生代堆空间的大小,就像-Xms和-Xmx一样。这允许我们在需要时显式地定义新生代堆空间的大小。
串行垃圾回收器
串行垃圾回收器是最简单的单线程垃圾回收器。您可以通过在 JVM 应用程序启动参数中添加`-XX:+UseSerialGC`标志来启用串行垃圾回收器。我们不会重点讨论如何调优串行垃圾回收器。
并行垃圾回收器
并行垃圾回收器与串行垃圾回收器原理相似,但它使用多个线程来对应用程序堆进行垃圾回收。您可以通过在 JVM 应用程序启动参数中添加`-XX:+UseParallelGC`标志来启用并行垃圾回收器。要完全禁用它,请使用`-XX:-UseParallelGC`标志。
调整并行垃圾回收器
正如我们之前提到的,并行垃圾回收器使用多个线程来执行清理任务。垃圾回收器可以使用的线程数可以通过添加到应用程序启动参数中的`-XX:ParallelGCThreads`标志来设置。
例如,如果我们想用 4 个线程进行垃圾回收,可以在应用程序参数中添加以下标志:-XX:ParallelGCThreads=4。请记住,分配给清理任务的线程越多,速度就越快。但是,增加垃圾回收线程也有缺点。每个参与次要垃圾回收事件的 GC 线程都会预留一部分老年代堆空间用于晋升。这将造成空间分割和碎片化。线程越多,碎片化程度越高。如果碎片化问题严重,减少并行垃圾回收线程的数量并增加老年代的大小将有助于缓解碎片化。
第二个可用的选项是`-XX:MaxGCPauseMillis`。它指定两次连续垃圾回收事件之间的最大暂停时间目标,单位为毫秒。例如,使用标志`-XX:MaxGCPauseMillis=100`,我们告诉并行垃圾回收器,我们希望两次垃圾回收之间最大暂停时间为 100 毫秒。垃圾回收之间的间隔越长,堆上残留的垃圾就越多,下一次垃圾回收的成本也就越高。另一方面,如果该值太小,应用程序的大部分时间将花费在垃圾回收上,而不是执行业务逻辑。
可以使用`-XX:GCTimeRatio`标志设置最大吞吐量目标。它定义了垃圾回收(GC) 所花费的时间与GC 之外所花费的时间之比。该比率定义为1/(1 + GC_TIME_RATIO_VALUE),表示垃圾回收所花费时间的百分比。
例如,设置-XX:GCTimeRatio=9表示应用程序 10% 的工作时间可能用于垃圾回收。这意味着应用程序的实际工作时间应该是垃圾回收时间的 9 倍。
默认情况下,JVM 将-XX:GCTimeRatio标志的值设置为 99,这意味着应用程序将获得比垃圾回收多 99 倍的工作时间,这对服务器端应用程序来说是一个很好的权衡。
您还可以控制并行垃圾回收器的迭代次数调整。并行垃圾回收器的目标如下:
- 达到最大暂停时间
- 只有当暂停时间达到目标时,才能实现吞吐量。
- 只有实现前两个目标,才能实现足迹目标。
并行垃圾回收器通过增加和减少代数来实现上述目标。代数的增加和减少以固定百分比递增。默认情况下,代数以 20% 的增量增加,以 5% 的增量减少。每个代数都可以单独配置。代数的增长百分比由 ` -XX:YoungGenerationSizeIncrement`标志控制。老代的增长百分比由`-XX:TenuredGenerationSizeIncrement`标志控制。
可以通过-XX:AdaptiveSizeDecrementScaleFactor标志来控制收缩部分。例如,年轻一代的收缩增量百分比是通过将-XX:YoungGenerationSizeIncrement标志的值除以-XX:AdaptiveSizeDecrementScaleFactor的值来设置的。
如果暂停时间目标未达到,则各代线程将逐代缩减。如果两代线程的暂停时间均超过目标,则首先缩减导致线程暂停时间较长的那代线程。如果吞吐量目标未达到,则新生代和老代线程都将增长。
如果并行垃圾回收器在垃圾回收过程中花费的时间过长,可能会抛出OutOfMemory异常。默认情况下,如果超过 98% 的时间用于垃圾回收,而回收的堆内存不足 2%,则会抛出此异常。如果想要禁用此行为,可以添加`-XX:-UseGCOverheadLimit`标志。但请注意,垃圾回收器长时间运行且几乎不清理任何内存通常意味着堆大小过小或应用程序存在内存泄漏。
了解了这些信息后,我们就可以开始查看垃圾回收器日志了。日志会告诉我们并行垃圾回收器执行了哪些事件。这应该能让我们大致了解从哪里开始调优,以及堆的哪一部分不健康或需要改进。
并发标记清除垃圾回收器
并发标记清除(Concurrent Mark Sweep)垃圾回收器是一种主要在并发环境下运行的实现,它与应用程序共享用于垃圾回收的线程。您可以通过在 JVM 应用程序启动参数中添加`-XX:+UseConcMarkSweepGC`标志来启用它。
调整并发标记清除垃圾回收器
与 JVM 世界中其他可用的垃圾回收器类似,CMS 垃圾回收器是分代的,这意味着会发生两种类型的事件:次要回收和主要回收。其理念是,大部分工作会在应用程序线程并行执行,以防止老年代被填满。在正常情况下,大部分垃圾回收操作无需停止应用程序线程即可完成。CMS 仅在主要回收的开始和中间阶段短暂停止线程。次要回收的工作方式与并行垃圾回收器非常相似——所有应用程序线程都会在垃圾回收期间停止。
CMS 垃圾回收器需要调优的一个信号是并发模式故障。这表明并发标记清除垃圾回收器在老年代填满之前未能回收所有不可达对象,或者堆中老年代的碎片空间不足以提升对象。
但我们之前提到的并发性又该如何解释呢?让我们先回到暂停机制上来。在并发阶段,CMS 垃圾回收器会暂停两次。第一次称为初始标记暂停,用于标记那些可以直接从堆根节点以及堆中任何其他位置访问到的活动对象。第二次暂停称为重新标记暂停,在并发跟踪阶段结束时执行。它用于查找在初始标记暂停期间遗漏的对象,这些对象主要是因为在此期间被更新了。并发跟踪阶段在两次暂停之间进行。在此阶段,可能有一个或多个垃圾回收器线程正在运行以清除垃圾。整个周期结束后,并发标记清除垃圾回收器会等待下一个周期,期间几乎不消耗任何资源。但是,请注意,在并发阶段,您的应用程序可能会出现性能下降的情况。
在使用 CMS 垃圾回收器时,必须控制老年代堆的回收时机。由于并发模式故障的代价可能很高,我们需要适当调整老年代堆清理的启动时间,以避免触发此类事件。我们可以使用`-XX:CMSInitiatingOccupancyFraction`标志来实现这一点。该标志用于设置CMS 开始清理老年代堆的利用率百分比。例如,如果从 75% 开始,则应将上述标志设置为`-XX:CMSInitiatingOccupancyFraction=75`。当然,这只是一个参考值,垃圾回收器仍然会使用启发式算法,尝试确定启动老年代清理作业的最佳值。为了避免使用启发式算法,我们可以使用 ` -XX:+UseCMSInitiatingOccupancyOnly`标志。这样,我们就只会使用`-XX:CMSInitiatingOccupancyFraction`设置中的百分比。
因此,将 ` -XX:+UseCMSInitiatingOccupancyOnly`标志设置为较高的值会延迟堆上老年代空间的清理。这意味着您的应用程序可以运行更长时间而无需 CMS 启动来清理老年代空间。但是,当清理过程开始时,由于需要处理更多工作,因此可能会增加开销。另一方面,将`-XX:+UseCMSInitiatingOccupancyOnly`标志设置为较低的值会使 CMS 老年代清理更频繁,但速度可能会更快。具体选择哪种方式取决于您的应用程序,需要根据具体用例进行调整。
我们还可以指示垃圾回收器在标记暂停期间或执行 Full GC 之前回收新生代堆。前者可以通过在启动参数中添加 ` -XX:+CMSScavengeBeforeRemark`标志来实现。后者可以通过在应用程序启动参数中添加`-XX:+ScavengeBeforeFullGC`标志来实现。这样可以提高垃圾回收性能,因为它无需检查新生代堆和老年代堆之间的引用。
并发标记清除垃圾回收器的标记阶段有可能提升速度。默认情况下,它是单线程的,正如您所知,它会暂停所有应用程序线程。通过在应用程序启动参数中添加`-XX:+CMSParallelRemarkEnabled`标志,我们可以强制标记阶段使用多线程。然而,由于某些实现细节,并发版本的标记阶段并不总是比单线程版本更快。这需要您在自己的环境中进行检查和测试。
与并行垃圾回收器类似,并发标记清除垃圾回收器在垃圾回收耗时过长时也会抛出OutOfMemory异常。默认情况下,如果垃圾回收耗时超过 98%,而堆内存回收率低于 2%,则会抛出此异常。如果想要禁用此行为,可以添加`-XX:-UseGCOverheadLimit`标志。与并行垃圾回收器不同的是,并发标记清除垃圾回收器只有在应用程序线程停止时才会计算98%的时间。
G1 垃圾收集器
G1 垃圾回收器是最新 Java 版本中的默认垃圾回收器,专为对延迟敏感的应用程序而设计。您可以通过在 JVM 应用程序启动参数中添加`-XX:+G1GC`标志来启用它。
调校 G1 垃圾收集器
还有两点值得一提。G1 垃圾回收器尝试并行执行耗时较长的操作,而不会暂停应用程序线程。当应用程序线程暂停时,快速操作的执行速度会更快。因此,它也是另一种主要基于并发的垃圾回收算法。
G1 垃圾回收器主要以疏散的方式清理内存。它将一个内存区域中的活动对象复制到另一个区域,并在复制过程中进行压缩。复制完成后,对象所在的内存区域即可再次用于对象分配。
从宏观层面来看,G1GC 分为两个阶段。第一阶段称为“仅年轻代”(young-only),主要关注年轻代空间。在该阶段,对象会逐步从年轻代空间迁移到老代空间。第二阶段称为“空间回收”(space reclamation),在逐步回收老代空间的同时,也会兼顾年轻代空间。接下来我们将更详细地了解这两个阶段,因为我们可以调整其中的一些属性。
仅年轻代阶段开始时会进行几次年轻代回收,将对象提升到老年代。该阶段会一直持续到老年代空间达到某个阈值。默认情况下,该阈值为 45%,我们可以通过设置 ` -XX:InitiatingHeapOccupancyPercent`标志及其值来控制它。一旦达到该阈值,G1 会启动另一个年轻代回收,称为并发启动回收。` -XX:InitiatingHeapOccupancyPercent`标志控制初始标记回收,它是垃圾回收器进一步调整的初始值。要关闭此调整,请将`-XX:-G1UseAdaptiveIHOP`标志添加到 JVM 启动参数中。
并发启动除了执行正常的新生代回收之外,还会启动对象标记过程。它会确定老年代空间中所有存活且可达的对象,这些对象需要保留到下一个空间回收阶段。为了完成标记过程,引入了两个额外的步骤:标记和清理。这两个步骤都会暂停应用程序线程。标记步骤执行全局引用处理、类卸载、完全回收空区域并清理内部数据结构。清理步骤确定是否需要空间回收阶段。如果需要,则会以“准备混合新生代回收”结束仅新生代阶段,并启动空间回收阶段。
空间回收阶段包含多次混合垃圾回收,这些回收操作会同时作用于 G1GC 堆空间的新生代和老年代区域。当 G1GC 检测到释放更多老年代区域所获得的可用空间不足以抵消回收空间的成本时,空间回收阶段结束。该阶段的结束时间可以通过 ` -XX:G1HeapWastePercent`标志值进行设置。
我们还可以在一定程度上控制周期性垃圾回收是否运行。通过使用`-XX:G1PeriodicGCSystemLoadThreshold`标志,我们可以设置平均负载阈值,超过该阈值则不会运行周期性垃圾回收。例如,如果系统在过去一分钟的负载为 10,并且我们设置了 ` -XX:G1PeriodicGCSystemLoadThreshold=10`标志,则不会执行周期性垃圾回收。
除了`-Xmx`和`-Xms`标志之外, G1 垃圾回收器还允许我们使用一组标志来调整堆及其区域的大小。我们可以使用 ` -XX:MinHeapFreeRatio`标志来告诉垃圾回收器应该达到的空闲内存比例,并使用 ` -XX:MaxHeapFreeRatio`标志来设置堆上所需的最大空闲内存比例。我们还知道,G1GC 会尝试将新生代的大小保持在`-XX:G1NewSizePercent`和`-XX:G1MaxNewSizePercent`的值之间。这也决定了暂停时间。减小新生代的大小可能会加快垃圾回收过程,但代价是工作量减少。我们还可以使用`-XX:NewSize`和 ` -XX:MaxNewSize`标志来设置新生代的严格大小。
关于 G1 垃圾回收器调优的文档指出,通常情况下我们不应该对其进行任何改动。最终,我们应该只针对不同的堆大小调整所需的暂停时间。这当然没错。但是,了解我们可以调整哪些参数、如何调整以及这些参数如何影响 G1 垃圾回收器的行为也同样重要。
在优化垃圾回收器延迟时,我们应该尽可能缩短暂停时间。这意味着在大多数情况下,-Xmx和-Xms的值应该设置为相同的值,并且我们还应该使用-XX:+AlwaysPreTouch标志在应用程序启动期间加载内存页。
如果只回收年轻代阶段耗时过长,则表明降低`-XX:G1NewSizePercent`(默认值为 5)的值是一个好主意。在某些情况下,降低 ` -XX:G1MaxNewSizePercent`(默认值为 60)也有帮助。如果混合回收耗时过长,建议增加`-XX:G1MixedGCCountTarget`标志的值,以便将老年代垃圾回收分散到更多次回收中。增加 ` -XX:G1HeapWastePercent` 的值可以更早地停止老年代垃圾回收。您还可以更改 ` -XX:G1MixedGCLiveThresholdPercent` 的值——它默认值为 65,用于控制老年代堆的占用率阈值,超过该阈值后,老年代堆将被纳入混合回收。增加此值将指示垃圾回收在执行混合回收时忽略占用率较低的老年代空间区域。包含大量对象的区域需要更长的垃圾回收时间。通过使用上述标志,我们可以避免将这些区域设置为垃圾回收的候选区域。如果您发现更新和扫描 RS 时间过长,可以尝试降低 ` -XX:G1RSetUpdatingPauseTimePercent`标志的值,同时启用`-XX:-ReduceInitialCardMarks`标志,并提高`-XX:G1RSetRegionEntries`标志的值。此外,还有一个额外的标志 ` -XX:MaxGCPauseTimeMillis`(默认值为 250),用于定义最大所需的暂停时间。如果您希望减少暂停时间,降低该值也可能有所帮助。
在优化吞吐量时,我们希望垃圾回收器尽可能多地清理垃圾。这在处理和存储大量数据的系统中尤为重要。首先,您应该增加 ` -XX:MaxGCPauseMillis` 的值。这样做可以减轻垃圾回收器的负担,使其能够工作更长时间,从而处理堆上的更多对象。然而,这可能还不够。在这种情况下,增加 ` -XX:G1NewSizePercent`的值应该会有所帮助。有时,吞吐量可能受限于新生代区域的大小——在这种情况下,增加 ` -XX:G1MaxNewSizePercent`的值也会有所帮助。
我们还可以降低并行度,因为并行度会占用大量 CPU 资源。使用 ` -XX:G1RSetUpdatingPauseTimePercent`标志并增加其值,可以在应用程序线程暂停时执行更多操作,从而减少并发阶段所花费的时间。此外,与延迟调整类似,您可能需要将 `-Xmx` 和 `-Xms` 标志的值保持相同,以避免堆大小调整。使用 ` -XX:+AlwaysPreTouch`和 ` -XX:+UseLargePages`标志将内存页加载到内存中。但请记住,要逐一应用这些更改并比较结果,以便了解其作用。
最后,我们可以调整堆大小。这里只有一个选项可以考虑,即 ` -XX:GCTimeRatio`(默认值为 12)。它决定了垃圾回收所花费的时间与应用程序线程执行其工作的时间之比,计算公式为1/(1 + GCTimeRatio)。默认值会导致大约 8% 的应用程序工作时间用于垃圾回收,这比并行 GC 的效率更高。更长的垃圾回收时间可以释放更多堆空间,但这很大程度上取决于应用程序,很难给出通用的建议。请通过实验找到适合您需求的值。
G1 垃圾回收器还有一些可调参数。我们可以控制使用此垃圾回收器的并行化程度。方法是添加 ` -XX:+ParallelRefProcEnabled`标志并更改 ` -XX:ReferencesPerThread`标志的值。对于`-XX:ReferencesPerThread`标志定义的每 N 个引用,都会使用一个线程。将此值设置为 0 将指示 G1 垃圾回收器始终使用`-XX:ParallelGCThreads`标志值指定的线程数。要提高并行化程度,请减小`-XX:ReferencesPerThread`标志的值。这应该可以加快垃圾回收的并行部分。
Z 垃圾收集器
仍处于实验阶段,但可扩展性强,延迟低。如果您想体验 Z 垃圾回收器,必须使用 JDK 11 或更高版本,并在应用程序启动参数中添加 ` -XX:+UseZGC`和 ` -XX:+UnlockExperimentalVMOptions`标志,因为 Z 垃圾回收器仍处于实验阶段。
调整 Z 垃圾回收器
对于 Z 垃圾回收器,我们可以调整的参数并不多。正如文档所述,最重要的选项是最大堆大小,也就是 `-Xmx` 标志。由于 Z 垃圾回收器是并发回收器,堆大小必须经过调整,使其能够容纳应用程序的活动对象集,并留出足够的空间以允许在垃圾回收器运行时进行内存分配。这意味着与其他垃圾回收器相比,Z 垃圾回收器的堆大小可能需要更大,并且分配给堆的内存越多,垃圾回收器的性能就越好。
第二个可选项当然是 Z 垃圾回收器使用的线程数。毕竟,它是一个并发回收器,因此可以利用多个线程。我们可以使用 ` -XX:ConcGCThreads`标志来设置 Z 垃圾回收器使用的线程数。回收器本身使用启发式算法来选择合适的线程数,但通常情况下,线程数的选择高度依赖于应用程序,在某些情况下,将该值设置为固定值可能会带来更好的结果。然而,这需要进行测试,因为它与具体用例密切相关。不过,有两点需要记住。如果为垃圾回收器分配过多的线程,应用程序可能没有足够的计算能力来完成其工作。如果将垃圾回收器线程数设置得太少,垃圾可能无法被快速回收。在进行调优时,请务必考虑这一点。
其他 JVM 选项
我们已经讨论了很多关于垃圾回收参数及其对垃圾回收的影响。但是,这并非全部。其中还有很多内容。当然,我们不可能逐一讲解每个参数,那样没有意义。不过,还有一些你应该了解的知识。
JVM统计信息导致垃圾回收长时间暂停
一些用户报告称,在 Linux 系统中,当 I/O 利用率较高时,垃圾回收会导致线程长时间暂停。这可能是由于 JVM 使用了一个名为 hsperfdata 的内存映射文件造成的。该文件写入 /tmp 目录,用于保存统计信息和安全点。垃圾回收期间会更新该文件。在 Linux 系统中,修改内存映射文件的操作可能会被阻塞,直到 I/O 操作完成。可想而知,这样的操作可能需要很长时间,可能长达数百毫秒。
如何发现环境中的此类问题?您需要查看垃圾回收的耗时。如果在垃圾回收日志中发现 JVM 实际用于垃圾回收的时间远长于用户和系统指标的总和,则可能存在问题。例如:
[Times: user=0.13 sys=0.11, real=5.45 secs]
如果您的系统是 I/O 密集型系统,并且您遇到了上述问题,您可以将 GC 日志和 tmpfs 文件系统的路径移动到高速 SSD 驱动器上。在最新的 JDK 版本中,Java 使用的临时目录是硬编码的,因此我们无法使用 ` -Djava.io.tmpdir`参数来更改它。您还可以将 ` -XX:+PerfDisableSharedMem`标志添加到 JVM 应用程序参数中。需要注意的是,添加此选项会破坏使用 hsperfdata 文件中统计信息的工具。例如,`jstat` 将无法正常工作。
您可以在LinkedIn 工程团队的博客文章中了解更多相关信息。
内存不足异常导致堆转储
在处理内存溢出异常、诊断其原因以及调查内存泄漏等问题时,堆转储非常有用。堆转储本质上是一个将堆内容写入磁盘的文件。我们可以按需生成堆转储,但这需要时间,并且可能会导致应用程序卡顿,或者至少会降低其运行速度。但如果应用程序崩溃,我们就无法获取堆转储——它已经丢失了。
为了避免丢失有助于诊断问题的信息,我们可以指示 JVM 在发生 OutOfMemory 错误时创建堆转储。这可以通过添加-XX:+HeapDumpOnOutOfMemoryError标志来实现。我们还可以使用-XX:HeapDumpPath标志指定堆的存储位置,并将其值设置为要写入堆转储的位置。例如:-XX:HeapDumpPath=/tmp/heapdump.hprof。
请注意,堆转储文件可能非常大——甚至可能与堆大小一样大。因此,在设置文件写入路径时,您需要考虑到这一点。我们曾遇到过 JVM 无法将 64GB 的堆转储文件写入目标文件系统的情况。
要分析文件,您可以使用一些工具。例如,开源工具如MAT,以及商业工具如YourKit Java Profiler或JProfiler。此外,还有heaphero.io等服务可以帮助您进行分析,而旧版本的 Oracle JDK 发行版则自带jhat——Java 堆分析工具。选择您喜欢且符合您需求的工具即可。
使用 -XX:+AggressiveOpts
` -XX:+AgressiveOpts`标志会启用一些额外的选项,这些选项已被证明可以在一系列基准测试中提升性能。这些选项可能因版本而异,包括更大的自动装箱缓存和移除激进的自动装箱等。它还包括禁用有偏锁定延迟。是否应该使用此标志?这取决于您的用例和生产系统。与往常一样,请在您的环境中进行测试,比较启用和禁用此标志的实例,看看性能差异有多大。
结论
优化垃圾回收机制并非易事,它需要丰富的知识和深刻的理解。你需要了解你所使用的垃圾回收器,并且需要了解应用程序的内存需求。每个应用程序都各不相同,内存使用模式也不同,因此需要不同的垃圾回收策略。这也不是一蹴而就的,需要投入时间和资源,通过迭代不断改进,才能检验每一次更改是否朝着正确的方向发展。
请记住,我们只是触及了 JVM 垃圾回收器调优的冰山一角。我们只提到了有限数量的可用标志,您可以启用/禁用它们并进行调整。为了获得更多背景知识和学习,我建议您访问Oracle HotSpot VM 垃圾回收调优指南,并阅读您感兴趣的部分。查看您的垃圾回收日志,分析它们,并尝试理解它们。这将有助于您了解您的环境以及垃圾回收时 JVM 内部发生的情况。此外,还要进行大量的实验!在您的测试环境、开发人员机器上进行实验,在一些生产或预生产实例上进行实验,并观察行为差异。
希望本文能帮助您在基于 JVM 的应用程序中实现健康的垃圾回收机制。祝您好运!
文章来源:https://dev.to/sematext/a-step-by-step-guide-to-java-garbage-collection-tuning-2m1g







