调试 RAM:Java 垃圾收集 – Java 堆深入探讨,第 1 部分

目录

有很多关于 Java 垃圾收集、Java 内存使用和一般 Java 堆的优秀文章。不幸的是,它们到处都是。他们将架构、概念和问题解决作为独立的部分混合在一起。许多材料已经过时或不包含解决垃圾收集器问题的实用信息。例如,暂停时间、堆空间使用等。

在这篇文章中,我不会讨论内存泄漏。它们很重要,但这是一个不同的主题,我想单独在一篇文章中讨论。

垃圾收集器权衡

GC 很神奇,未引用的对象是在自动过程中收集的。但是垃圾收集仍然需要权衡:

  • Memory Footprint内存占用
  • Pauses暂停
  • Performance表现

选择其中两个。垃圾收集器可以权衡 RAM 以提供更快的性能和更少的 GC 停顿。在这篇文章中,我将讨论选择和调整 GC 的策略。

通常,当我们想选择一个库时,我们只做一个基准测试。但是对 GC 进行基准测试要困难得多。如果我们重载 GC,我们最终可能会得到一个能够很好地处理压力但对于典型内存分配不是最佳的 GC。了解垃圾收集器的工作方式以及我们使用“真实世界”工作负载分析 GC 至关重要。

Not your Fathers Stop the World Mark Sweep
不是你的父亲阻止世界清除标记

自 Java 1.0 停止全球 GC 以来,Java GC 已经取得了长足的进步。虽然有许多类型的垃圾收集器,但大多数新的垃圾收集器是分代的和并行/并发的。在我们的本地机器上工作时,这似乎并不重要。但是当 GC 处理非常大的堆时,差异非常明显。

GC“无缝”检测未使用的对象以回收堆空间。但也有取舍。何时将对象视为“未使用对象”是核心内存管理权衡。

代际垃圾收集

大多数现代 GC 都假定对象生命周期符合世代范式。老一代空间物体寿命长,很少被收集,这个。他们不需要经常扫描。年轻一代的对象生死攸关。经常在一起。

分代垃圾收集(通常)更频繁地遍历年轻代,并特别注意代之间的连接。这很重要,因为在次要垃圾收集周期中要扫描的区域较少。较短周期的术语是增量 GC,而不是完整 GC 周期。GC 通常会尽量减少完整的 GC 周期。

并发与并行垃圾收集器

并行 GC 经常与并发 GC 混淆。更令人困惑的是,GC 既可以是并行 GC,也可以是并发 GC(例如 G1)。

区别很简单:

  • 并行 GC 有多个 GC 线程。GC 线程执行实际的垃圾回收。它们对于大规模收集至关重要
  • 并发 GC 允许 JVM 在标记阶段和可选的其他阶段做其他事情

直觉上,我们大多数人都希望一直拥有这两者并起诉应用程序线程。但这并不总是正确的选择。并发和多个应用程序线程会产生开销。此外,这些 GC 通常会做出权衡,即错过一些无法访问的对象,并使一些堆内存在较长时间内未被回收。需要明确的是,他们会在一个完整的 GC 循环中找到所有未使用的内存,但他们会尽量避免这样的循环,您可能会为此付出代价。

以下是从 JDK 17 开始的大单 GC。

串行收集器

这是一个单线程垃圾收集器。这意味着它比大多数 GC 快一点,但会导致更多的暂停。如果您要对性能进行基准测试,则打开此 GC 以减少差异是有意义的。由于几乎所有 CPU 都是多核的,因此该 GC 对于大多数实际部署来说没有用处,但它使调试某些行为变得更加容易。

在一种情况下,串行收集器可能在生产中具有很大的好处,那就是无服务器工作负载(例如 lambdas 等)。在这些情况下,最小/最快的解决方案获胜,这可能是在单核 VM 上使用有限物理内存的正确解决方案。

请注意,尽管它相对简单,但串行收集器是一个分代 GC。因此,它比旧的 Java GC 更现代。

您可以使用-XX:+UseSerialGC.

并行收集器又名吞吐量收集器

串行收集器的多线程等效项。这是一个很好的收集器,可以在生产中使用,但还有更好的。串行收集器更适合基准测试,ZGC/G1 通常提供更好的性能。

-XX:+UseParallelGC您可以使用该选项显式打开此 GC 。

并行 GC 的一大好处是它的可配置性。您可以使用以下 JVM 选项对其进行调整:

  • -XX:ParallelGCThreads=ThreadCount– 收集器使用的 GC 线程数
  • -XX:MaxGCPauseMillis=MaxDurationMilliseconds– 以毫秒为单位限制 GC 暂停。这默认为无限制
  • -XX:GCTimeRatio=ratio– 将专用于 GC 的时间设置为1/(ratio + 1)a 值,91 / (9 + 1)10%。所以10%CPU 时间会花在 GC 上。默认值是99这意味着1%

G1 垃圾收集器

G1 垃圾收集器是一种重型 GC,专为在具有大堆大小(大约 6GB 或更高)的机器上处理大量工作负载而设计。它试图适应给定机器的工作条件。您可以使用 JVM 选项显式启用它-XX:+UseG1GC

G1 是一个并发 GC,它在后台工作并最大限度地减少暂停。它的一个更酷的功能是字符串重复数据删除,它减少了 RAM 中字符串的开销。您可以使用 激活该功能-XX:+UseStringDeduplication

Z 垃圾收集器 (ZGC)

ZGC 是实验性的,直到最近的 JVM 版本。它设计用于比 G1 更大的堆大小,并且也是并发 GC。它确实支持较小的环境,并且可用于小至 8mb 到 16TB 的最大堆大小!

它最大的特点之一是它不会暂停应用程序的执行超过 10 毫秒。成本是吞吐量的降低。

可以使用-XX:+UseZGCJVM 选项启用 ZGC。

选择和调试垃圾收集器

Java 8 使用-verbose:gcflag 来生成 GC 日志和-XX:+PrintGCDetailsflag。

较新的 JDK 使用-Xlog:gc:file.log它将 GC 详细信息打印到给定文件。通过启用这些功能并正常运行您的应用程序,您可以跟踪 GC 行为并适当地调整您的代码/部署。

不久前,我遇到了 GCeasy,这是一个可以很好地分析 GC 日志的网站。还有其他几个类似的工具,它们可以为您提供一些有趣的信息。但是,日志文件也可以直接读取,您可以从阅读中学到很多东西。

此外,您可以使用 JVM 选项获得更详细的 GC 信息:

-Xlog:gc*=debug:file=gc-verbose.log

请注意,在 Linux/Unix 上,您需要将此命令括在引号中,这样 shell 就不会尝试扩展它。

通过详细输出,您可以更深入地了解 GC 的内部工作原理,并可以跟进调整 JVM 堆。事实上,我建议任何 JVM 开发人员至少尝试一次此标志,以了解 Java 堆空间的内部工作原理。

基准/测量

正如我之前提到的,GC 对应用程序性能基准测试很糟糕。如果我们只是使用常规基准测试,串行 GC 通常会获胜,即使它不应该是我们大多数人的首选。诀窍是使用实际负载,然后查看 GC 日志。

然后我们可以根据每个 GC 提供的统计数据来决定我们愿意接受的权衡。请注意,我们还可以将自己限制为仅使用外部指标,例如 CPU 和 RAM 使用情况。这可能是一个非常好的方法。但是,进行重负载测试可能不是 GC 性能的最佳表现。需要明确的是,您仍然应该进行重负载测试。

GC 调整

几乎所有 Java 开发人员在调整内存时要做的第一件事就是定义最大大小和最小大小。初始堆大小很容易使用-Xmx和 –Xms我们使用了几十年的 JVM 参数来确定。

一种常见的方法是将两者设置为相同的大小。这不一定是坏事。它简化了 GC 中的内存管理逻辑,现在只关注一个值。不过,这确实存在风险。这意味着几乎没有出错的余地。

您可以做的最重要的性能改进是通过应用程序代码。性能问题属于 GC 标志调整的情况很少见(尽管它可能发生)。

减少内存使用

大多数开发人员更喜欢减少垃圾收集时间,但对于某些内存消耗是一个更大的问题。如果您在受限环境中运行,例如微服务或无服务器容器。您可以使用以下 Java 标志来处理内存不足:

  • 减少-XX:MaxHeapFreeRatio哪些默认为70%-XX:MinHeapFreeRatio哪些默认为的值40%。最大值可以减少到10%
  • 您可以使用-XX:-ShrinkHeapInSteps哪个会更频繁地触发 GC 并以牺牲性能为代价来降低内存使用量

世代优化

通常,当您添加 RAM 时,大多数 Java 应用程序的性能会更好。但有时大量的 RAM 会触发很长的 GC 停顿,有时甚至会触发超时。

如果 GC 未能为年轻/幸存者构建正确的内存池,则可能会出现完整的 GC 周期,从而导致停顿。例如,如果您有一个快速创建和丢弃对象的应用程序,您可能需要比老一代更大的年轻一代。您可以调整使用-XX:NewRatio它来定义老年代和年轻代之间的配给。这默认为 2,这意味着新生代的大小是老年代的两倍。

您还可以调整 -XX:NewSize 值,该值指定专用于新一代的 RAM 量。

元空间、永久代、堆栈大小等

这些在技术上不是 GC 的一部分,但它们经常与 Java 堆内存相关的问题混在一起,所以这是讨论它们的好地方。

如果您有一个线程繁重的应用程序,您可能需要考虑减少堆栈大小(如果适用)。通常会调整堆栈大小以允许更大的大小,这使我们能够支持深度递归算法。

随着 Java 8 PermGen(又名永久代)最终被杀死。PermGen 是一个存储类文件和元数据的特殊内存空间。我们有时不得不为动态生成字节码的应用程序调整它,因为如果有太多类文件会触发内存错误。新的 Metaspace 具有自动内存管理功能,并解决了 PermGen 中的大部分问题。

我们仍然可以使用提示设置元空间的大小-XX:MaxMetaspaceSize

将应用程序正确调整到 GC 非常重要。减少本机代码(和终结器)、弱引用、软引用和幻像引用的使用。所有这些特性都会给 GC 带来开销。尽管公平地说,在服务器上的大多数情况下,这些都不是决定因素。

瓦尔哈拉的未来

GC 非常棒,但在 Java 堆内存性能方面存在一些边缘情况。

例如,如果我们用 C 和 Java 编写本机内存处理,我们可以获得与本机代码大致相当的性能,例如:

int[] myArray = new int[2000];

在某些情况下,由于 Java 的快速分配器代码和原始支持,这可以比 C 执行得更快。

情况并非如此:

Integer myArray = new Integer[2000];

或为:

Point[] myPointArray = new Point[2000];

在 C++ 中,我们可以定义一个堆栈对象,其内存直接连接到其父对象。无论是堆栈帧还是包含它的对象。这有其缺点,因为需要复制数据,它不再只是一个指针。但是数据位于同一页面甚至寄存器中,因此不存在内存碎片并且开销非常低。

这是瓦尔哈拉试图解决的巨大问题的一部分。它将增加将对象定义为值或原语的能力,这将使我们能够将它们定义为其他事物的一部分。这将有效地消除Optional原始包装器的开销。它还将使可空性更加细致入微。

对 GC 的影响也将是巨大的。想象一下作为单个操作收集的 2000 个 Point 对象的数组……

这是 Java SE 在语言和虚拟机方面的重大变化。因此,我曾经对此持观望态度。但在阅读了一些 与该项目相关的材料后, 我持谨慎乐观的态度。

TL;博士

垃圾收集器线程很少是您的应用程序性能不佳甚至内存不足的原因。对于99.9%这些情况,原因可能在应用程序代码中。不要指望命令行选项会神奇地解决问题。

但是,这些工具可以让您从 GC 的角度跟踪应用程序的内部工作。当您在 JVM 进程中遇到内存问题时,您可以使用这些工具来缩小范围。

说了这么多,GC 暂停是一个真正的问题,可能导致生产失败。特别是在非常大的堆中。同样,您需要先查看内存中的对象,但了解虚拟机在内存方面做出的权衡至关重要。

debugging-ram-java-garbage-collection-java-heap-deep-dive-part-1