Java-高性能指南-二-

157 阅读1小时+

Java 高性能指南(二)

原文:zh.annas-archive.org/md5/075370e1159888d7fd67fe4f209e6d1e

译者:飞龙

协议:CC BY-NC-SA 4.0

第五章:垃圾收集简介

本章介绍了 JVM 中垃圾收集的基础知识。除了重写代码外,调整垃圾收集器是提高 Java 应用程序性能最重要的事情。

因为 Java 应用程序的性能严重依赖垃圾收集技术,所以有相当多的收集器可供选择。OpenJDK 有三个适合生产环境的收集器,另一个在 JDK 11 中已经弃用但在 JDK 8 中仍然非常流行,并且一些实验性的收集器将来会(理想情况下)成为生产就绪版本的一部分。其他 Java 实现如 Open J9 或 Azul JVM 有它们自己的收集器。

所有这些收集器的性能特征都是非常不同的;我们只会关注 OpenJDK 提供的那些。每个收集器在下一章中都会进行深入讲解。然而,它们共享基本概念,因此本章提供了收集器操作的基本概述。

垃圾收集概述

在 Java 编程中最具吸引力的功能之一是开发人员无需显式管理对象的生命周期:对象在需要时创建,在对象不再使用时,JVM 会自动释放对象。如果像我一样,你花费了大量时间优化 Java 程序的内存使用,那么整个方案可能看起来像是一个弱点而不是一个功能(我花在垃圾收集上的时间似乎支持了这种观点)。当然,这可以被认为是一种两面性的祝福,但我仍然记得在其他语言中追踪空指针和悬空指针的困难。我强烈认为调整垃圾收集器比追踪指针错误更容易(也更省时)。

在基本水平上,GC 的工作包括找出正在使用的对象并释放剩余对象(即那些未被使用的对象)所关联的内存。有时候,这被描述为找出不再具有任何引用的对象(暗示引用是通过计数来跟踪的)。然而,仅仅依靠引用计数是不够的。假设有一个对象链表,列表中的每个对象(除了头部)都会被列表中的另一个对象指向,但是如果没有任何东西指向列表的头部,整个列表就不再被使用并且可以被释放。如果列表是循环的(例如,列表的尾部指向头部),列表中的每个对象都有一个引用指向它,即使列表中没有任何对象实际可用,因为没有对象引用列表本身。

所以引用不能通过计数动态跟踪;相反,JVM 必须定期搜索堆中未使用的对象。它通过从 GC 根对象开始搜索来实现这一点,GC 根对象是从堆外部可访问的对象。这主要包括线程堆栈和系统类。这些对象总是可达的,因此 GC 算法扫描所有通过一个根对象可达的对象。通过 GC 根可达的对象是活动对象;其余的不可达对象是垃圾(即使它们保持对活动对象或彼此的引用)。

当 GC 算法找到未使用的对象时,JVM 可以释放这些对象占用的内存,并用于分配其他对象。然而,简单地跟踪这些空闲内存并将其用于将来的分配通常是不够的;在某些时刻,必须压缩内存以防止内存碎片化。

考虑一个程序的情况,它分配了一个 1,000 字节的数组,然后是一个 24 字节的数组,并在循环中重复这个过程。当这个过程填满堆时,它会看起来像图 5-1 中的顶行:堆已满,并且这些数组大小的分配交错进行。

当堆满时,JVM 将释放未使用的数组。假设所有的 24 字节数组不再使用,而 1,000 字节数组仍然全部使用:这将产生图 5-1 中的第二行。堆内有空闲区域,但不能分配大于 24 字节的任何内容,除非 JVM 将所有 1,000 字节数组移动到连续位置,使所有空闲内存留在一个区域,以便根据需要进行分配(图 5-1 中的第三行)。

实现稍微详细些,但 GC 的性能主要受这些基本操作的影响:查找未使用的对象,使它们的内存可用,并压缩堆。不同的收集器对这些操作采取不同的方法,尤其是压缩:一些算法推迟压缩直到绝对必要时,一些一次性压缩堆的整个部分,还有一些通过逐步重定位少量内存来压缩堆。这些不同的方法是不同算法具有不同性能特征的原因。

jp2e 0501

图 5-1. 在收集期间理想化的 GC 堆

当垃圾收集器运行时,如果没有应用程序线程在运行,则执行这些操作会更简单。Java 程序通常是重度多线程的,而垃圾收集器本身通常也会运行多个线程。本讨论考虑了两个逻辑线程组:执行应用逻辑的线程(通常称为变异线程,因为它们正在变异对象作为应用逻辑的一部分)和执行 GC 的线程。当 GC 线程跟踪对象引用或在内存中移动对象时,它们必须确保应用程序线程不在使用这些对象。当 GC 移动对象时尤其如此:对象在该操作期间的内存位置会发生变化,因此在此期间不应用程序线程可以访问该对象。

当所有应用程序线程停止时的暂停称为停止-世界暂停。这些暂停通常对应用程序的性能产生最大影响,减少这些暂停是调整 GC 时的一个重要考虑因素。

分代垃圾收集器

尽管细节略有不同,但大多数垃圾收集器的工作方式是将堆分成代。这些被称为老(或终身)代年轻代。年轻代进一步分为称为伊甸园幸存者空间的部分(尽管有时,伊甸园被错误地用来指代整个年轻代)。

单独分代的原因是许多对象仅被使用很短的时间。例如,在股票价格计算中的循环中,该循环对平均价格的价格差的平方进行求和(标准差计算的一部分):

sum = new BigDecimal(0);
for (StockPrice sp : prices.values()) {
    BigDecimal diff = sp.getClosingPrice().subtract(averagePrice);
    diff = diff.multiply(diff);
    sum = sum.add(diff);
}

像许多 Java 类一样,BigDecimal类是不可变的:该对象表示特定数字,不能更改。对对象执行算术运算时,会创建一个新对象(通常情况下,之前的对象及其先前的值随后会被丢弃)。当这个简单循环被执行一年的股票价格(大约 250 次迭代)时,就在这个循环中创建了 750 个用于存储中间值的BigDecimal对象。这些对象在循环的下一次迭代中被丢弃。在add()和其他方法中,JDK 库代码甚至创建了更多的中间BigDecimal(和其他)对象。最终,在这段小代码中会快速创建和丢弃很多对象。

这种操作在 Java 中很常见,因此垃圾收集器设计得充分利用了许多(有时是大多数)对象仅用于临时的事实。这就是代际设计的用武之地。对象首先分配在年轻代中,这是整个堆的子集。当年轻代填满时,垃圾收集器会停止所有应用线程并清空年轻代。不再使用的对象被丢弃,仍在使用的对象则被移动到其他地方。这个操作称为minor GCyoung GC

这种设计有两个性能优势。首先,因为年轻代只是整个堆的一部分,处理速度比整个堆快得多。应用线程停止的时间要比一次性处理整个堆时短得多。尽管这意味着应用线程会更频繁地停止,而不是等到整个堆填满再执行 GC,这个权衡将在本章后面更详细地探讨。但是目前来看,即使更频繁,有更短的暂停时间几乎总是一个巨大的优势。

第二个优势来自对象在年轻代分配的方式。对象首先分配在 Eden 区(它占据了年轻代的绝大部分)。当进行垃圾收集时,年轻代被清空:Eden 区中的所有对象要么被移动,要么被丢弃:不再使用的对象可以被丢弃,而仍在使用的对象则移动到其中一个幸存区或老年代。由于所有幸存下来的对象都会被移动,因此当年轻代被收集时,它会自动压缩:在收集结束时,Eden 区和一个幸存区为空,而仍留在年轻代中的对象则在另一个幸存区内被紧凑地排列。

常见的 GC 算法在收集年轻代时会有停止应用线程的暂停。

当对象被移动到老年代时,最终老年代也会填满,JVM 需要找到老年代中不再使用的对象并丢弃它们。这是 GC 算法差异最大的地方。简单的算法会停止所有应用线程,找到未使用的对象,释放它们的内存,然后压缩堆。这个过程称为full GC,通常会导致应用线程相对较长的暂停。

另一方面,当应用程序线程正在运行时,可以找到未使用的对象,尽管这可能会更加计算复杂。因为扫描未使用对象的阶段可以在不停止应用程序线程的情况下进行,所以这些算法被称为并发收集器。它们也被称为低暂停(有时不正确地称为无暂停)收集器,因为它们最大程度地减少了停止所有应用程序线程的需求。并发收集器还采用不同的方法来压缩老年代。

使用并发收集器时,应用程序通常会经历更少(且更短)的暂停。最大的权衡是应用程序总体上将使用更多的 CPU。并发收集器也可能更难调整以获得最佳性能(尽管在 JDK 11 中,调整像 G1 GC 这样的并发收集器要比以前的版本更容易,这反映了自并发收集器首次引入以来所取得的工程进展)。

在考虑哪种垃圾收集器适合您的情况时,请考虑必须满足的总体性能目标。在每种情况下都存在权衡。在度量单个请求的响应时间的应用程序(如 REST 服务器)中,请考虑以下几点:

  • 单个请求将受暂停时间的影响,尤其是全局 GC 的长暂停时间。如果最小化暂停对响应时间的影响是目标,则并发收集器可能更合适。

  • 如果平均响应时间比异常值(即 90th%)响应时间更重要,那么非并发收集器可能会产生更好的结果。

  • 使用并发收集器避免长暂停时间的好处是需要额外的 CPU 使用。如果您的计算机缺乏并发收集器所需的空闲 CPU 循环,则非并发收集器可能是更好的选择。

同样,在批处理应用程序中选择垃圾收集器的选择受以下权衡的指导:

  • 如果有足够的 CPU 可用,使用并发收集器来避免全局 GC 暂停将有助于更快地完成任务。

  • 如果 CPU 受限,那么并发收集器的额外 CPU 消耗将导致批处理作业需要更多时间。

快速总结

  • GC 算法通常将堆分为老年代和年轻代。

  • GC 算法通常采用停止-世界方法来清除来自年轻代的对象,这通常是一个快速操作。

  • 最小化在老年代执行 GC 的影响是暂停时间和 CPU 使用之间的权衡。

GC 算法

OpenJDK 12 提供了各种 GC 算法,在早期版本中的支持程度各不相同。Table 5-1 列出了这些算法及其在 OpenJDK 和 Oracle Java 发布版中的状态。

表 5-1. 各种 GC 算法的支持级别^(a)

GC 算法JDK 8 中的支持JDK 11 中的支持JDK 12 中的支持
串行 GCSSS
吞吐量(并行)GCSSS
G1 GCSSS
并发标记-清除(CMS)SDD
ZGC-EE
ShenandoahE2E2E2
Epsilon GC-EE
^(a) (S: 完全支持 D: 已弃用 E: 实验性 E2: 实验性;在 OpenJDK 版本中但不在 Oracle 版本中)

接下来是每种算法的简要描述;第六章提供了有关单独调优它们的更多详细信息。

串行垃圾收集器

串行垃圾收集器是所有收集器中最简单的一种。如果应用程序在客户端类机器上运行(Windows 上的 32 位 JVM)或单处理器机器上运行,则默认使用此收集器。曾经,串行收集器似乎注定要被丢弃,但容器化技术改变了这一点:具有一个核心(甚至是看起来像两个 CPU 的超线程核心)的虚拟机和 Docker 容器使得这种算法再次变得更为重要。

串行收集器使用单个线程处理堆。在处理堆时(无论是进行部分 GC 还是完全 GC),它将停止所有应用程序线程。在完全 GC 期间,它将完全压缩老年代。

使用-XX:+UseSerialGC标志可以启用串行收集器(尽管通常在可能使用它的情况下它是默认的)。请注意,与大多数 JVM 标志不同,串行收集器不会通过将加号改为减号(即指定-XX:-UseSerialGC)来禁用。在串行收集器是默认收集器的系统上,可以通过指定不同的 GC 算法来禁用它。

吞吐量收集器

在 JDK 8 中,吞吐量收集器是任何具有两个或更多 CPU 的 64 位机器的默认收集器。吞吐量收集器使用多个线程来收集年轻代,这使得部分 GC 比使用串行收集器更快。它也使用多个线程来处理老年代。由于使用多个线程,吞吐量收集器通常被称为并行收集器

吞吐量收集器在部分 GC 和完全 GC 期间停止所有应用程序线程,并在完全 GC 期间完全压缩老年代。由于在大多数使用场景中,它是默认的收集器,因此不需要显式启用。如有必要,在需要时可以使用标志-XX:+UseParallelGC来启用它。

请注意,JVM 的旧版本允许在年轻代和老年代分别启用并行收集,因此您可能会看到关于标志-XX:+UseParallelOldGC的引用。尽管这个标志已经过时(虽然它仍然有效,并且出于某种原因,如果您真的想要,您可以禁用此标志以仅并行收集年轻代),但是可以禁用它以仅在年轻代并行收集。

G1 GC 收集器

G1 GC(或垃圾优先垃圾收集器)使用并发收集策略来在尽可能小的暂停时间内收集堆。对于具有两个或更多 CPU 的 64 位 JVM,它是 JDK 11 及更高版本的默认收集器。

G1 GC 将堆划分为多个区域,但仍然将堆视为具有两个代。其中一些区域组成年轻代,年轻代仍然通过停止所有应用程序线程并将所有存活对象移动到老年代或存活区域来进行收集。(这是使用多个线程进行的。)

在 G1 GC 中,老年代由后台线程处理,这些线程不需要停止应用程序线程来执行大部分工作。因为老年代被划分为区域,所以 G1 GC 可以通过从一个区域复制到另一个区域来清理老年代的对象,这意味着它(至少部分地)在正常处理中压缩堆。这有助于保持 G1 GC 堆不会变得碎片化,尽管这仍然可能发生。

避免完全 GC 周期的折衷是 CPU 时间:G1 GC 使用的(多个)后台线程在应用程序线程运行时必须有可用的 CPU 周期来处理老年代。

通过指定标志 -XX:+UseG1GC 启用 G1 GC。在 JDK 11 中,它通常是默认的,在 JDK 8 中也可以使用——特别是在 JDK 8 的后期版本中,它包含了从较新版本中后移植的许多重要的错误修复和性能增强。但是,当我们深入探讨 G1 GC 时,您会发现 JDK 8 中缺少的一个主要性能特性,这可能使其不适合该版本。

CMS 收集器

CMS 收集器 是第一个并发收集器。与其他算法一样,CMS 在执行次要 GC 时会停止所有应用程序线程,并使用多个线程执行。

CMS 在 JDK 11 及更高版本中已正式弃用,并且在 JDK 8 中不建议使用。从实际的角度来看,CMS 的主要缺陷是它在后台处理过程中没有一种方法来压缩堆。如果堆变得碎片化(这在某些时候很可能发生),CMS 必须停止所有应用程序线程并压缩堆,这违背了并发收集器的初衷。鉴于这一点和 G1 GC 的出现,CMS 不再推荐使用。

CMS 是通过指定标志 -XX:+UseConcMarkSweepGC 启用的,默认情况下为 false。历史上,CMS 还需要设置 -XX:+UseParNewGC 标志(否则,年轻代将由单个线程收集),尽管这已经过时。

实验性收集器

垃圾收集在 JVM 工程师中仍然是一个富有成效的领域,而最新版本的 Java 提供了前面提到的三种实验性算法。在下一章中我将详细介绍它们;现在,让我们继续看看在生产环境中选择三种支持的收集器之间的差异。

快速总结

  • 支持的 GC 算法采用不同的方法来最小化 GC 对应用程序的影响。

  • 当只有一个 CPU 可用且额外的 GC 线程会干扰应用程序时,串行收集器是合理的(并且是默认的)。

  • 吞吐量收集器是 JDK 8 的默认选项;它最大化应用程序的总吞吐量,但可能会使个别操作出现长时间的暂停。

  • G1 GC 是 JDK 11 及更高版本的默认选项;它在应用程序线程运行时并发地收集老年代,有可能避免全局垃圾回收。其设计使得它比 CMS 更不太可能经历全局垃圾回收。

  • CMS(Concurrent Mark-Sweep)垃圾收集器可以在应用程序线程运行时并发地收集老年代。如果有足够的 CPU 可用于后台处理,这可以避免应用程序的全局垃圾回收周期。它已被 G1 GC 所取代。

选择 GC 算法

选择 GC 算法部分取决于可用的硬件,部分取决于应用程序的外观,部分取决于应用程序的性能目标。在 JDK 11 中,G1 GC 通常是更好的选择;在 JDK 8 中,选择将取决于你的应用程序。

我们将从一个经验法则开始,即 G1 GC 是更好的选择,但每个规则都有例外。在垃圾回收的情况下,这些例外涉及到应用程序相对于可用硬件需要的 CPU 循环数,以及后台 G1 GC 线程需要执行的处理量。如果你使用 JDK 8,G1 GC 避免全局垃圾回收的能力也将是一个关键考虑因素。当 G1 GC 不是更好的选择时,吞吐量和串行收集器之间的选择取决于机器上的 CPU 数量。

何时使用(以及何时不使用)串行收集器

在只有一个 CPU 的机器上,JVM 默认使用串行收集器。这包括只有一个 CPU 的虚拟机,以及被限制为一个 CPU 的 Docker 容器。如果你在 JDK 8 的早期版本中将 Docker 容器限制为一个 CPU,它仍将默认使用吞吐量收集器。在这种环境中,你应该考虑使用串行收集器(即使你需要手动设置)。

在这些环境中,串行收集器通常是一个不错的选择,但有时 G1 GC 会产生更好的结果。这个例子也是理解选择 GC 算法所涉及的一般权衡的一个很好的起点。

G1 GC 和其他收集器之间的权衡包括为 G1 GC 后台线程提供可用的 CPU 循环,所以我们先从一个 CPU 密集型的批处理作业开始。在批处理作业中,CPU 将长时间地处于 100% 忙碌状态,这种情况下串行收集器有明显的优势。

表 5-2 列出了一个单线程计算 10 万只股票在三年内历史记录所需的时间。

表 5-2. 不同 GC 算法在单个 CPU 上的处理时间

GC 算法总经过时间用于 GC 暂停的时间
串行434 秒79 秒
吞吐量503 秒144 秒
G1 GC501 秒97 秒

单线程垃圾收集的优势最明显的是当我们将串行收集器与吞吐量收集器进行比较时。用于实际计算的时间是总经过时间减去用于 GC 暂停的时间。在串行和吞吐量收集器中,这段时间基本相同(大约 355 秒),但串行收集器胜出的原因是它在进行垃圾收集时的暂停时间要少得多。具体来说,串行收集器进行一次完全 GC 的平均时间为 505 毫秒,而吞吐量收集器则需要 1,392 毫秒。吞吐量收集器在其算法中有相当多的开销——当两个或更多线程处理堆时,这种开销是值得的,但当只有一个线程可用时,它只会妨碍操作。

现在将串行收集器与 G1 GC 进行比较。如果我们在使用 G1 GC 时消除暂停时间,应用程序进行计算需要 404 秒,但我们知道从其他示例中,实际只需要 355 秒。其他的 49 秒来自于什么?

计算线程可以利用所有可用的 CPU 周期。同时,后台的 G1 GC 线程需要 CPU 周期来完成它们的工作。因为没有足够的 CPU 来满足两者的需求,它们最终会共享 CPU:计算线程会运行一段时间,而后台的 G1 GC 线程会运行一段时间。最终的效果是,计算线程因为一个“后台”G1 GC 线程占用 CPU 而无法运行 49 秒。

这就是我说当你选择 G1 GC 时,需要足够的 CPU 供其后台线程运行的意思。对于长时间运行的应用程序线程占用唯一可用的 CPU 的情况,G1 GC 并不是一个好选择。但如果换成一些不同的情况,比如在受限硬件上运行简单的 REST 请求的微服务呢?表格 5-3 展示了一个 Web 服务器处理大约每秒 11 个请求,使用单个 CPU,大约占用了可用 CPU 周期的 50%的响应时间。

表格 5-3. 使用不同 GC 算法的单个 CPU 的响应时间

GC 算法平均响应时间90th% 响应时间99th% 响应时间CPU 利用率
串行0.10 秒0.18 秒0.69 秒53%
吞吐量0.16 秒0.18 秒1.40 秒49%
G1 GC0.13 秒0.28 秒0.40 秒48%

默认(串行)算法仍然具有最佳的平均时间,比其他算法快 30%。同样,这是因为串行收集器对于年轻代的收集通常比其他算法快,因此平均请求由串行收集器延迟较少。

一些不幸的请求会被串行收集器的完整 GC 打断。在这个实验中,串行收集器进行完整 GC 的平均时间为 592 毫秒,最长的甚至达到了 730 毫秒。结果是 1%的请求几乎花费了 700 毫秒。

这仍然优于吞吐量收集器的表现。吞吐量收集器的完整 GC 平均为 1,192 毫秒,最大为 1,510 毫秒。因此,吞吐量收集器的第 99th%响应时间是串行收集器的两倍。而且平均时间也因这些异常值而偏差。

G1 GC 位于中间某处。就平均响应时间而言,它比串行收集器更差,因为更简单的串行收集器算法更快。在这种情况下,主要适用于小 GC,串行收集器平均需要 86 毫秒,而 G1 GC 则需要 141 毫秒。因此,在 G1 GC 的情况下,平均请求会被延迟更长时间。

尽管如此,G1 GC 的 99th%响应时间明显低于串行收集器。在这个示例中,G1 GC 能够避免完整 GC,因此没有了串行收集器超过 500 毫秒的延迟。

这里有一个优化的选择:如果平均响应时间是最重要的目标,那么(默认的)串行收集器是更好的选择。如果你想要优化第 99th%响应时间,G1 GC 胜出。这是一个判断的问题,但对我来说,平均时间的 30 毫秒差异不如第 99th%时间的 300 毫秒差异重要—因此在这种情况下,G1 GC 比平台的默认收集器更合理。

这个示例对 GC 的消耗很大;特别是非并发收集器需要执行大量的完整 GC 操作。如果我们调整测试,使得所有对象都可以在不需要完整 GC 的情况下被收集,那么串行算法可以与 G1 GC 相匹配,如表 5-4 所示。

表 5-4. 单 CPU 使用不同 GC 算法的响应时间(无完整 GC)

GC 算法平均响应时间第 90th%响应时间第 99th%响应时间CPU 利用率
串行0.05 秒0.08 秒0.11 秒53%
吞吐量0.08 秒0.09 秒0.13 秒49%
G1 GC0.05 秒0.08 秒0.11 秒52%

因为没有完整 GC,串行收集器相对于 G1 GC 的优势被消除了。当 GC 活动较少时,所有数字都在同一范围内,并且所有收集器的表现几乎相同。另一方面,没有完整 GC 的情况相当罕见,这是串行收集器表现最佳的情况。在足够的 CPU 周期下,G1 GC 通常会比默认的串行收集器更好。

单超线程 CPU 硬件

那么单核机器或 Docker 容器如何? 在这种情况下,CPU 是超线程的(因此在 JVM 看来是一个双 CPU 的机器),JVM 不会默认使用串行收集器——它认为有两个 CPU,因此在 JDK 8 中会默认使用吞吐量收集器,在 JDK 11 中会使用 G1 GC。 但事实证明,串行收集器在这种硬件上通常也是有利的。 表 5-5 显示了在单个超线程 CPU 上运行前一批次实验时发生的情况。

表 5-5. 在单个超线程 CPU 上使用不同 GC 算法的处理时间

GC 算法经过时间垃圾回收暂停时间
串行432 秒82 秒
吞吐量478 秒117 秒
G1 GC476 秒72 秒

串行收集器不会运行多个线程,因此其时间与我们之前的测试基本上没有变化。 其他算法有所改进,但并不像我们希望的那样多——吞吐量收集器将运行两个线程,但暂停时间并没有减半,而是减少了约 20%。 同样,G1 GC 仍然无法为其后台线程获取足够的 CPU 周期。

所以至少在这种情况下——长时间运行的批处理作业并频繁进行垃圾收集时——JVM 的默认选择是错误的,应用程序最好使用串行收集器,尽管存在“两个”CPU。 如果有两个实际的 CPU(即两个核心),情况会有所不同。 吞吐量收集器仅需要 72 秒来完成操作,这比串行收集器所需的时间少。 在这一点上,串行收集器的实用性就会减弱,所以我们将在未来的示例中放弃它。

串行收集器有一个额外的要点:即使应用程序的堆非常小(比如 100 MB),使用串行收集器仍然可能表现更好,而与可用的核心数量无关。

何时使用吞吐量收集器

当一台机器有多个可用的 CPU 时,GC 算法之间可能会发生更复杂的交互,但在基本水平上,G1 GC 和吞吐量收集器之间的权衡与我们刚刚看到的相同。 例如,表 5-6 显示了我们的样本应用程序在具有四个核心的机器上运行两个或四个应用程序线程时的工作情况(其中核心不是超线程的)。

表 5-6. 使用不同 GC 算法进行批处理的时间

应用程序线程G1 GC吞吐量
410 秒(60.8%)446 秒(59.7%)
513 秒(99.5%)536 秒(99.5%)

表中的时间是运行测试所需的秒数,并显示了机器的 CPU 利用率。当有两个应用程序线程时,G1 GC 比吞吐量收集器显著更快。主要原因是吞吐量收集器花了 35 秒暂停进行完全 GC。G1 GC 能够避免这些收集,虽然会(相对轻微地)增加 CPU 时间。

即使有四个应用程序线程,G1 在这个例子中仍然胜出。在这里,吞吐量收集器总共暂停了应用程序线程 176 秒。而 G1 GC 仅暂停了应用程序线程 88 秒。G1 GC 后台线程确实需要与应用程序线程竞争 CPU 周期,这让应用程序线程少了大约 65 秒。这仍意味着 G1 GC 快了 23 秒。

当应用程序的经过时间至关重要时,吞吐量收集器将比 G1 GC 更有优势,因为它暂停应用程序线程的时间少于 G1 GC。这种情况发生在以下一种或多种情况:

  • 没有(或很少)进行完全 GC。完全 GC 暂停很容易支配应用程序的暂停时间,但如果它们根本不发生,那么吞吐量收集器就不再处于劣势。

  • 老年代通常是满的,导致后台的 G1 GC 线程工作更多。

  • G1 GC 线程饥饿于 CPU。

在接下来详细介绍各种算法如何工作以及这些要点背后的原因的章节中,将更清楚地解释这些内容(以及围绕它们调整收集器的方法)。现在,我们将看一些例子来证明这一点。

首先,让我们看一下表格 5-7 中的数据。这个测试与我们之前用于长计算批处理作业的代码相同,尽管有一些修改:多个应用程序线程正在进行计算(在这种情况下为两个),老年代以对象为种子保持 65%满,几乎所有对象都可以直接从年轻代收集。这个测试在一个具有四个 CPU(非超线程)的系统上运行,以确保后台的 G1 GC 线程有足够的 CPU 资源运行。

表 5-7。带有长寿命对象的批处理

指标G1 GC吞吐量
经过时间212 秒193 秒
CPU 使用率67%51%
年轻代 GC 暂停30 秒13.5 秒
完全 GC 暂停0 秒1.5 秒

由于只有少量对象被晋升到老年代,吞吐量收集器仅暂停了应用程序线程 15 秒,其中只有 1.5 秒用于收集老年代。

尽管老年代没有许多新对象被晋升进去,但测试种子会为老年代添加垃圾以便 G1 GC 线程扫描。这会给后台 GC 线程增加更多工作量,并导致 G1 GC 为了补偿老年代的填充而在收集年轻代时做更多工作。最终结果是,在两线程测试中,G1 GC 使应用程序暂停了 30 秒——比吞吐量收集器更多。

另一个例子:当 G1 GC 后台线程没有足够的 CPU 运行时,吞吐量收集器的表现会更好,正如 表 5-8 所示。

表 5-8. 忙碌 CPU 下的批处理

指标G1 GC吞吐量
经过时间287 秒267 秒
CPU 使用率99%99%
年轻代 GC 暂停80 秒63 秒
全 GC 暂停0 秒37 秒

实际上,这与单 CPU 的情况没有什么不同:G1 GC 后台线程与应用程序线程之间的 CPU 周期竞争意味着,即使没有 GC 暂停发生,应用程序线程也会被有效暂停。

如果我们更关心交互处理和响应时间,那么吞吐量收集器要比 G1 GC 更难胜过。如果您的服务器缺少 CPU 周期,以至于 G1 GC 和应用程序线程争夺 CPU,那么 G1 GC 的响应时间将更差(与我们已经看到的情况类似)。如果服务器调优得没有全 GC,则 G1 GC 和吞吐量收集器通常会产生类似的结果。但是吞吐量收集器有更多的全 GC,G1 GC 的平均、90th% 和 99th% 响应时间就会更好。

快速总结

  • 目前对于大多数应用程序来说,G1 GC 是更好的算法选择。

  • 在单 CPU 机器上运行 CPU 密集型应用程序时,串行收集器是有道理的,即使该单 CPU 是超线程的。对于那些不是 CPU 密集型的作业,G1 GC 在这样的硬件上仍然更好。

  • 对于 CPU 密集型作业在多 CPU 机器上,吞吐量收集器是合理的选择。即使对于不是 CPU 密集型的作业,如果它相对较少进行全 GC 或者老年代通常是满的,吞吐量收集器可能也是更好的选择。

基本 GC 调优

尽管 GC 算法在处理堆的方式上有所不同,它们共享基本的配置参数。在许多情况下,这些基本配置就足以运行一个应用程序。

调整堆大小

GC 的第一个基本调优是应用程序堆大小。高级调优会影响堆的代大小;作为第一步,本节将讨论设置整体堆大小。

像大多数性能问题一样,选择堆大小是一个权衡问题。如果堆太小,程序将花费太多时间执行 GC,而不是足够的时间执行应用程序逻辑。但是仅仅指定一个非常大的堆也不一定是答案。GC 暂停的时间取决于堆大小,因此随着堆大小的增加,这些暂停的持续时间也会增加。暂停的频率会减少,但其持续时间将使整体性能下降。

当使用非常大的堆时,还会出现第二个危险。计算机操作系统使用虚拟内存管理机器的物理内存。一台机器可能有 8GB 的物理 RAM,但操作系统会使其看起来可用的内存量要多得多。虚拟内存的量取决于操作系统的配置,但假设操作系统看起来有 16GB 内存。操作系统通过称为交换(或分页,尽管这两个术语之间有技术上的区别,但对本讨论并不重要)的过程来管理这一点。您可以加载使用高达 16GB 内存的程序,操作系统将不活动部分的程序复制到磁盘。当需要这些内存区域时,操作系统将其从磁盘复制到 RAM(通常,它首先需要将某些内容从 RAM 复制到磁盘以腾出空间)。

这个过程对于运行大量应用程序的系统效果很好,因为大多数应用程序不会同时活动。但对于 Java 应用程序来说,效果不佳。如果在此系统上运行具有 12GB 堆的 Java 程序,则操作系统可以通过将 8GB 的堆保持在内存中,将 4GB 保存在磁盘上来处理(这简化了情况,因为其他程序将使用部分内存)。JVM 不会知道这一点;交换由操作系统透明处理。因此,JVM 将愉快地填充其被告知使用的全部 12GB 堆。这会导致严重的性能损失,因为操作系统将数据从磁盘交换到 RAM(这本身是一个昂贵的操作)。

更糟糕的是,保证发生交换的唯一时机是在进行全局垃圾收集(GC)时,此时 JVM 必须访问整个堆。如果系统在进行全局 GC 期间发生交换,暂停时间将比通常情况下长上一个数量级。同样地,当使用 G1 GC 时,后台线程在堆中扫描时,由于长时间等待数据从磁盘复制到主存储器,很可能会滞后,导致昂贵的并发模式失败。

因此,调整堆大小的第一个原则是永远不要指定比机器上的物理内存更大的堆大小——如果有多个 JVM 运行,这也适用于所有堆的总和。您还需要为 JVM 的本机内存留出一些空间,并为其他应用程序留出一些内存空间:通常至少需要为常见操作系统配置留出 1GB 的空间。

堆的大小由两个值控制:初始值(使用 -XmsN 指定)和最大值(-XmxN)。默认值因操作系统、系统 RAM 量和使用的 JVM 而异。默认值也可能受命令行上其他标志的影响;堆大小是 JVM 的核心人机工程调整之一。

JVM 的目标是根据可用于其的系统资源找到“合理”的默认堆初始值,并在应用程序需要更多内存时(根据其执行 GC 的时间)将堆增长到“合理”的最大值。在本章和下一章稍后讨论的一些高级调整标志和细节缺失时,初始和最大大小的默认值在 表 5-9 中给出。JVM 将略微向下舍入这些值以对齐目的;打印大小的 GC 日志将显示这些值与本表中的数字不完全相等。

表 5-9. 默认堆大小

操作系统和 JVM初始堆(Xms最大堆(Xmx
Linux最小值(512 MB,物理内存的 1/64)最小值(32 GB,物理内存的 1/4)
macOS64 MB最小值(1 GB,物理内存的 1/4)
Windows 32 位客户端 JVMs16 MB256 MB
Windows 64 位服务器 JVMs64 MB最小值(1 GB,物理内存的 1/4)

在物理内存少于 192 MB 的计算机上,最大堆大小将是物理内存的一半(96 MB 或更少)。

注意,表 5-9 中的数值是那些在 Docker 容器中 JDK 8 版本更新到 192 之前指定内存限制的情况下将会不正确的调整:JVM 将使用机器上的总内存来计算默认大小。在之后的 JDK 8 版本和 JDK 11 中,JVM 将使用容器的内存限制。

为堆设置初始值和最大值允许 JVM 根据工作负载调整其行为。如果 JVM 发现初始堆大小的 GC 过多,它将不断增加堆大小,直到 JVM 执行“正确”的 GC 量,或者直到堆达到其最大大小。

对于不需要大型堆的应用程序,这意味着根本不需要设置堆大小。相反,您指定 GC 算法的性能目标:您愿意容忍的暂停时间、您想要在 GC 中花费的时间百分比等。细节取决于所使用的 GC 算法,并将在下一章讨论(尽管即使在那种情况下,也选择了默认值,以便对于广泛的应用程序范围,这些值也无需调整)。

在 JVM 运行在隔离容器中的世界中,通常需要指定最大堆。在主要运行单个 JVM 的虚拟机上,默认的初始堆大小只有分配给虚拟机的内存的四分之一。同样,在带有内存限制的 JDK 11 Docker 容器中,通常希望堆消耗大部分内存(留出前面提到的余地)。这里的默认值更适合运行多个应用程序的系统,而不是专门为特定 JVM 的容器。

没有明确的规则决定最大堆值的大小(除非不指定大于机器支持的大小)。一个好的经验法则是调整堆大小,使其在完整 GC 后占用 30%。要计算这一点,请运行应用程序直到达到稳定状态配置:即已加载任何缓存,已创建最大数量的客户端连接等。然后使用jconsole连接到应用程序,强制进行完整 GC,并观察完整 GC 完成时使用的内存量。(或者,对于吞吐量 GC,如果可用,可以查阅 GC 日志。)如果采用这种方法,请确保调整容器(如果适用)以获得额外的 0.5–1 GB 内存,用于 JVM 的非堆需求。

请注意,即使显式设置了最大大小,堆的自动调整大小也会发生:堆将从其默认初始大小开始,并且 JVM 将增加堆以满足 GC 算法的性能目标。指定比所需更大的堆不一定会带来内存惩罚:它只会增长到足以满足 GC 性能目标的程度。

另一方面,如果您确切地知道应用程序需要多大的堆大小,您可以将堆的初始值和最大值都设置为该值(例如,-Xms4096m -Xmx4096m)。这样可以使 GC 稍微更高效,因为它永远不需要弄清楚是否应调整堆的大小。

快速总结

  • JVM 将尝试根据其运行的机器找到合理的最小和最大堆大小。

  • 除非应用程序需要比默认更大的堆,否则请考虑调整 GC 算法的性能目标(在下一章节中给出)而不是微调堆大小。

分配代大小

一旦确定了堆大小,JVM 必须决定将堆的多少分配给年轻代和多少分配给老年代。JVM 通常会自动执行此操作,并且通常在确定年轻代和老年代之间的最佳比率方面表现良好。在某些情况下,您可能需要手动调整这些值,尽管大多数情况下,本节的目的是提供垃圾收集工作原理的理解。

不同代大小的性能影响应该很明显:如果年轻代相对较大,则年轻代的 GC 暂停时间将增加,但年轻代的收集频率将减少,并且升级到老年代的对象将减少。但另一方面,因为老年代相对较小,它会更频繁地填满并执行更多的全 GC。在这里找到平衡是关键。

不同的 GC 算法尝试以不同方式找到这种平衡。但是,所有 GC 算法都使用相同的一组标志来设置代的大小;本节介绍了这些通用标志。

调整代大小的命令行标志都会调整年轻代的大小;老年代会得到剩余的一切。可以使用多种标志来设置年轻代的大小:

-XX:NewRatio=N

设置年轻代和老年代的比例。

-XX:NewSize=N

设置年轻代的初始大小。

-XX:MaxNewSize=N

设置年轻代的最大大小。

-XmnN

设置NewSizeMaxNewSize为相同值的快捷方式。

年轻代首先由NewRatio大小设定,其默认值为 2。影响堆空间大小设定的参数通常指定为比率;该值在一个方程中用于确定受影响空间的百分比。NewRatio值在以下公式中使用:

Initial Young Gen Size = Initial Heap Size / (1 + NewRatio)

将堆的初始大小和NewRatio代入计算得到的值将成为年轻代的设置。因此,默认情况下,年轻代从初始堆大小的 33%开始。

或者,可以通过指定NewSize标志来显式设置年轻代的大小。如果设置了此选项,则它将优先于从NewRatio计算出的值。该标志没有默认值,因为默认情况下是从NewRatio计算出的。

随着堆的扩展,年轻代的大小也会扩展,直到由MaxNewSize标志指定的最大大小。默认情况下,该最大值也是使用NewRatio值设置的,尽管它基于最大(而非初始)堆大小。

通过指定年轻代的最小和最大大小来调整年轻代的性能最终会变得相当困难。当堆大小固定时(通过将-Xms设置为-Xmx),通常最好使用-Xmn来指定年轻代的固定大小。如果应用程序需要动态大小的堆并需要更大(或更小)的年轻代,则专注于设置NewRatio值。

自适应大小调整

堆大小、代的大小和幸存者空间的大小可以在执行过程中变化,因为 JVM 尝试根据其策略和调整找到最佳性能。这是一种尽力而为的解决方案,并且依赖于过去的性能:假设未来的 GC 周期将与最近过去的 GC 周期类似。对于许多工作负载来说,这是一个合理的假设,即使分配速率突然发生变化,JVM 也将根据新信息重新调整其大小。

自适应大小以两种重要方式提供优势。首先,这意味着小型应用程序无需担心过度指定其堆大小。考虑用于调整 Java NoSQL 服务器等事务操作的管理命令行程序——这些程序通常存在时间较短,并且使用最小的内存资源。即使默认堆大小可以增长到 1 GB,这些应用程序也将使用 64(或 16)MB 的堆。由于自适应大小,像这样的应用程序无需进行特定调整;平台默认值确保它们不会使用大量内存。

第二,这意味着许多应用程序实际上根本不需要担心调整其堆大小——或者如果它们需要比平台默认值更大的堆,则可以指定更大的堆并忘记其他细节。 JVM 可以自动调整堆和代的大小,以使用最佳内存量,考虑到 GC 算法的性能目标。自适应大小是使自动调整工作的原因。

然而,调整大小需要一小部分时间——这在大部分情况下发生在 GC 暂停期间。如果您花时间精细调整 GC 参数和应用程序堆大小的大小约束,则可以禁用自适应大小。禁用自适应大小对于经历明显不同阶段的应用程序也很有用,如果您想要为这些阶段中的一个最佳调整 GC。

在全局级别,可以通过关闭-XX:-UseAdaptiveSizePolicy标志(默认为true)来禁用自适应大小。除了年轻代的幸存者空间(在下一章中详细讨论),如果将最小堆大小和最大堆大小设置为相同值,并且新生代的初始大小和最大大小设置为相同值,则自适应大小也将有效地关闭。

要查看 JVM 如何调整应用程序中的空间大小,请设置-XX:+PrintAdaptiveSizePolicy标志。当执行 GC 时,GC 日志将包含详细信息,详细说明在集合期间如何调整各代的大小。

快速总结

  • 在整体堆大小内部,各代的大小受到分配给年轻代的空间量的控制。

  • 年轻代将与整体堆大小同步增长,但也可以作为总堆大小的百分比而波动(基于年轻代的初始大小和最大大小)。

  • 自适应调整控制 JVM 在堆内调整年轻代与老年代比例的方式。

  • 通常应保持启用自适应调整,因为调整这些代大小是 GC 算法尝试实现其暂停时间目标的方式。

  • 对于精细调整的堆,可以禁用自适应调整以获得小幅性能提升。

元空间大小

当 JVM 加载类时,必须跟踪这些类的某些元数据。这占用了称为 元空间 的独立堆空间。在旧版 JVM 中,这是由称为 永久代 的不同实现处理的。

对于最终用户而言,元空间是不透明的:我们知道它保存了大量与类相关的数据,并且在某些情况下需要调整该区域的大小。

请注意,元空间不保存类的实际实例(Class 对象)或反射对象(例如,Method 对象);这些对象保存在常规堆中。元空间中的信息仅供编译器和 JVM 运行时使用,并且它所保存的数据被称为 类元数据

预先计算特定程序所需的元空间大小并没有一个很好的方法。其大小与使用的类数量成比例,因此更大的应用程序将需要更大的空间。这是 JDK 技术变化使生活更轻松的另一个领域:调整永久代曾经相当常见,但现在调整元空间则相对罕见。主要原因是元空间大小的默认值非常慷慨。Table 5-10 列出了默认的初始和最大大小。

表 5-10. 元空间的默认大小

JVM默认初始大小默认最大大小
32 位客户端 JVM12 MB无限制
32 位服务器 JVM16 MB无限制
64 位 JVM20.75 MB无限制

元空间类似于常规堆的单独实例。它的大小根据初始大小(-XX:MetaspaceSize=N)动态设置,并会根据需要增加到最大大小(-XX:MaxMetaspaceSize=N)。

调整元空间大小需要进行完整的 GC,因此这是一个昂贵的操作。如果在程序启动时(加载类时)出现大量的完整 GC,往往是因为正在调整永久代或元空间的大小,因此增加初始大小是改善这种情况下启动性能的好方法。例如,服务器通常指定初始元空间大小为 128 MB、192 MB 或更大。

Java 类与其他任何东西一样都可以符合 GC 的条件。在应用服务器中,这种情况经常发生,每次部署(或重新部署)应用程序时会创建新的类加载器。然后,旧的类加载器将不再被引用,并且符合 GC 的条件,任何它们定义的类也是如此。与此同时,应用程序的新类将具有新的元数据,因此元空间必须有足够的空间。这通常会导致完全 GC,因为元空间需要增长(或丢弃旧的元数据)。

限制元空间大小的一个原因是防止类加载器泄漏:当应用服务器(或类似 IDE 的其他程序)不断定义新的类加载器和类,并保持对旧类加载器的引用时,会填满元空间并在机器上消耗大量内存的潜力。另一方面,在这种情况下,实际的类加载器和类对象也仍然在主堆中,并且在元空间的内存成为问题之前,主堆很可能会填满并导致 OutOfMemoryError

堆转储(参见 第七章)可用于诊断存在哪些类加载器,从而有助于确定是否有类加载器泄漏填满了元空间。否则,可以使用 jmap 并带有参数 -clstats 打印有关类加载器的信息。

快速摘要

  • 元空间保存类元数据(而不是类对象),并表现得像一个单独的堆。

  • 此区域的初始大小可以基于加载所有类后的使用情况。这将稍微加快启动速度。

  • 定义和丢弃大量类的应用程序将在元空间填满并且旧类被移除时偶尔会看到完全 GC。这在开发环境中尤其常见。

控制并行性

除串行收集器外,所有 GC 算法均使用多线程。这些线程的数量由 -XX:ParallelGCThreads=N 标志控制。此标志的值影响以下操作所使用的线程数量:

  • 当年轻一代使用 -XX:+UseParallelGC 时的收集

  • 当使用 -XX:+UseParallelGC 时老年代的收集

  • 当使用 -XX:+UseG1GC 时年轻一代的收集

  • G1 GC 的停止-世界阶段(尽管不是完全 GC)

因为这些 GC 操作会停止所有应用程序线程的执行,所以 JVM 会尽可能使用尽可能多的 CPU 资源以最小化暂停时间。默认情况下,这意味着 JVM 将在每台机器上的 CPU 上运行一个线程,最多八个。一旦达到这个阈值,JVM 将为每 1.6 个 CPU 添加一个新线程。因此,在具有超过八个 CPU 的机器上,显示的总线程数(其中 N 是 CPU 数)如下:

ParallelGCThreads = 8 + ((N - 8) * 5 / 8)

有时候这个数目过大。在一个拥有八个 CPU 的机器上,使用小堆(比如 1 GB)的应用程序,如果将堆分配给四到六个线程,会稍微提高效率。在拥有 128 个 CPU 的机器上,83 个 GC 线程对于除了最大堆之外的其他情况来说都太多了。

如果在具有 CPU 限制的 Docker 容器中运行 JVM,则该 CPU 限制将用于此计算。

此外,如果一台机器上运行多个 JVM,限制所有 JVM 的总 GC 线程数是个好主意。当它们运行时,GC 线程非常高效,每个线程将占用单 CPU 的 100%(这就是为什么吞吐量收集器的平均 CPU 使用率比预期高的原因)。在拥有八个或更少 CPU 的机器上,GC 将占用机器 CPU 的 100%。在拥有更多 CPU 和多个 JVM 的机器上,太多的 GC 线程仍将并行运行。

假设一台拥有 16 个 CPU 的机器上运行四个 JVM 实例;每个 JVM 默认会有 13 个 GC 线程。如果这四个 JVM 同时执行 GC 操作,那么机器将有 52 个耗 CPU 的线程竞争 CPU 时间。这会导致相当多的竞争;将每个 JVM 的 GC 线程限制为四个将更有效率。即使四个 JVM 不太可能同时执行 GC 操作,但其中一个 JVM 使用 13 个线程执行 GC 意味着其余 JVM 中的应用线程现在必须在 16 个 CPU 中有 13 个正在忙于执行 GC 任务的机器上竞争 CPU 资源。在这种情况下,为每个 JVM 提供四个 GC 线程提供了更好的平衡。

请注意,此标志不会设置 G1 GC 使用的后台线程数(尽管它确实会影响)。详细信息请参见下一章节。

快速总结

  • 所有 GC 算法使用的基本线程数都基于机器上的 CPU 数。

  • 当单台机器上运行多个 JVM 时,线程数会过高,必须进行减少。

GC 工具

由于 GC 对 Java 性能至关重要,许多工具都监控其性能。了解 GC 日志的效果对于了解应用程序性能的影响是最佳方式,GC 日志记录了程序执行期间每次 GC 操作。

GC 日志中的详细信息根据 GC 算法而异,但日志的基本管理对所有算法都是相同的。然而,GC 日志的管理在 JDK 8 和后续版本中并不相同:JDK 11 使用一组不同的命令行参数来启用和管理 GC 日志。我们将在此讨论 GC 日志的管理,并在下一章节的特定于算法的调优部分详细介绍日志内容。

在 JDK 8 中启用 GC 日志记录。

JDK 8 提供多种启用 GC 日志的方法。指定-verbose:gc-XX:+PrintGC中的任何一个标志都将创建一个简单的 GC 日志(这些标志是彼此的别名,默认情况下日志是禁用的)。-XX:+PrintGCDetails标志将创建一个包含更多信息的日志。推荐使用该标志(默认情况下也是false);仅使用简单日志很难诊断 GC 发生的情况。

配合详细的日志,建议包含-XX:+PrintGCTimeStamps-XX:+PrintGCDateStamps,以确定 GC 操作之间的时间。这两个参数的区别在于,时间戳是相对于 0 的(基于 JVM 启动时),而日期时间戳则是实际的日期字符串。由于日期时间戳需要格式化日期,稍微效率略低一些,尽管这是一个不太频繁的操作,但其影响不太可能被注意到。

GC 日志被写入标准输出,尽管可以(并且通常应该)使用-Xloggc:*filename*标志来更改其位置。使用-Xloggc会自动启用简单的 GC 日志,除非也启用了PrintGCDetails

可以通过日志轮换来限制 GC 日志中保留的数据量;这对于长时间运行的服务器非常有用,否则可能会在几个月内用日志填满磁盘。日志文件轮换由以下标志控制:-XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=N -XX:GCLogFileSize=*N*。默认情况下,UseGCLogFileRotation是禁用的。当启用该标志时,默认文件数为 0(意味着无限制),默认日志文件大小为 0(意味着无限制)。因此,为了使日志轮换按预期工作,必须为所有这些选项指定值。注意,文件大小将会向上舍入至 8 KB,以避免小于此值的问题。

将所有这些内容整合在一起,用于日志记录的一组有用标志如下:

-Xloggc:gc.log -XX:+PrintGCTimeStamps -XX:+UseGCLogFileRotation
-XX:NumberOfGCLogFile=8 -XX:GCLogFileSize=8m

这将记录带有时间戳的 GC 事件,以便与其他日志进行关联,并将保留的日志限制在 8 个文件中的 64 MB。这种日志记录足够精简,甚至可以在生产系统上启用。

在 JDK 11 中启用 GC 日志记录

JDK 11 及更高版本使用 Java 的新统一日志记录功能。这意味着所有的日志记录(包括与 GC 相关的和其他的)都是通过-Xlog标志启用的。然后你可以附加各种选项来控制日志记录的行为。为了指定类似 JDK 8 中长示例的日志记录,你需要使用以下标志:

-Xlog:gc*:file=gc.log:time:filecount=7,filesize=8M

冒号将命令分成四个部分。你可以运行java -Xlog:help:来获取更多有关可用选项的信息,但以下是它们在这个字符串中的映射。

第一部分(gc*)指定了应该启用日志记录的模块;我们启用了所有 GC 模块的日志记录。有一些选项可以仅记录特定的部分(例如 gc+age 将记录关于对象老化的信息,这是下一章节中讨论的一个主题)。这些特定的模块通常在默认日志级别下的输出有限,因此您可能会使用类似 gc*,gc+age=debug 的方式来记录所有 gc 模块的基本(info 级别)消息以及老化代码的调试级别消息。通常,以 info 级别记录所有模块是可以接受的。

第二部分设置了日志文件的目标位置。

第三部分(time)是一个装饰器:这个装饰器指示将消息记录为带有时间戳的形式,与我们为 JDK 8 指定的方式相同。可以指定多个装饰器。

最后,第四部分指定了输出选项;在这种情况下,我们说当日志文件达到 8 MB 时进行日志轮转,总共保留八个日志文件。

值得注意的是:在 JDK 8 和 JDK 11 之间,日志轮转处理稍有不同。假设我们指定了一个名为 gc.log 的日志文件,并且应该保留三个文件。在 JDK 8 中,日志会被记录如下:

  1. 开始记录到 gc.log.0.current

  2. 当日志文件满时,将其重命名为 gc.log.0 并开始记录到 gc.log.1.current

  3. 当日志文件满时,将其重命名为 gc.log.1 并开始记录到 gc.log.2.current

  4. 当日志文件满时,将其重命名为 gc.log.2,移除 gc.log.0,并开始记录到一个新的 gc.log.0.current

  5. 重复这个周期。

在 JDK 11 中,日志会按照以下方式记录:

  1. 开始记录到 gc.log

  2. 当日志文件满时,将其重命名为 gc.log.0 并开始一个新的 gc.log

  3. 当日志文件满时,将其重命名为 gc.log.1 并开始一个新的 gc.log

  4. 当日志文件满时,将其重命名为 gc.log.2 并开始一个新的 gc.log

  5. 当日志文件满时,将其重命名为 gc.log.0,移除旧的 gc.log.0,并开始一个新的 gc.log

如果你想知道为什么在之前的 JDK 11 命令中我们指定了保留七个日志文件,这就是原因:在这种情况下将有八个活动文件。无论如何,请注意,文件名称中附加的数字并不代表文件创建的顺序。这些数字在一个循环中重复使用,因此有一定的顺序,但是最旧的日志文件可以是任何一个。

gc 日志包含了与每个收集器相关的大量信息,因此我们将在下一章节详细介绍这些细节。解析日志以获取关于应用程序的汇总信息也很有用:例如它有多少次暂停,平均暂停时间以及总暂停时间等。

不幸的是,并没有很多优秀的开源工具可以解析日志文件。与分析器一样,商业厂商提供了支持,例如来自 jClarity(Censum)和 GCeasy 的服务。后者提供了基本日志解析的免费服务。

对于堆的实时监控,请使用jvisualvmjconsolejconsole的内存面板显示堆的实时图表,如图 5-4 所示。

一张显示堆占用量随 GC 周期变化的图表

图 5-4. 实时堆显示

此视图显示整个堆,它定期在使用约 100 MB 和 160 MB 之间循环。jconsole也可以只显示 Eden 区、幸存者空间、老年代或永久代。如果我选择 Eden 作为要绘制的区域,它将显示类似的模式,因为 Eden 在 0 MB 和 60 MB 之间波动(而且,您可以猜到,这意味着如果我选择了老年代绘制,它将基本上是 100 MB 的平直线)。

对于可脚本化的解决方案,jstat是首选工具。jstat提供九个选项来打印关于堆的不同信息;jstat -options将提供完整列表。一个有用的选项是-gcutil,它显示在 GC 中花费的时间以及当前填充的每个 GC 区域的百分比。jstat的其他选项将以 KB 为单位显示 GC 大小。

记住,jstat可以带一个可选参数——重复执行命令的毫秒数——以便在应用程序中随时间监视 GC 的效果。以下是每秒重复的一些示例输出:

% jstat -gcutil 23461 1000
  S0     S1     E      O      P     YGC     YGCT    FGC    FGCT     GCT
 51.71   0.00  99.12  60.00  99.93     98    1.985     8    2.397    4.382
  0.00  42.08   5.55  60.98  99.93     99    2.016     8    2.397    4.413
  0.00  42.08   6.32  60.98  99.93     99    2.016     8    2.397    4.413
  0.00  42.08  68.06  60.98  99.93     99    2.016     8    2.397    4.413
  0.00  42.08  82.27  60.98  99.93     99    2.016     8    2.397    4.413
  0.00  42.08  96.67  60.98  99.93     99    2.016     8    2.397    4.413
  0.00  42.08  99.30  60.98  99.93     99    2.016     8    2.397    4.413
 44.54   0.00   1.38  60.98  99.93    100    2.042     8    2.397    4.439
 44.54   0.00   1.91  60.98  99.93    100    2.042     8    2.397    4.439

当监视进程 ID 23461 启动时,程序已经执行了 98 次年轻代收集(YGC),总计耗时 1.985 秒(YGCT)。此外,它还执行了 8 次完全 GC(FGC),总共需要 2.397 秒(FGCT);因此 GC 总时间(GCT)为 4.382 秒。

此处显示了年轻代的所有三个部分:两个幸存者空间(S0S1)和 Eden 区(E)。监控开始时,Eden 正在填满(99.12%),因此在下一秒会进行年轻代收集:Eden 减少到 5.55%的使用率,幸存者空间交换位置,并且少量内存被提升到老年代(O),其使用率增至 60.98%。像往常一样,永久代(P)几乎没有变化,因为应用程序已加载了所有必要的类。

如果您忘记启用 GC 日志记录,这是一个良好的替代方法,以观察 GC 如何随时间运行。

快速概述

  • GC 日志是诊断 GC 问题所需的关键数据;它们应定期收集(即使在生产服务器上)。

  • 使用PrintGCDetails标志可以获得更好的 GC 日志文件。

  • 提供用于解析和理解 GC 日志的程序是现成的;它们有助于总结 GC 日志中的数据。

  • jstat可以为实时程序提供良好的 GC 可见性。

概要

垃圾收集器的性能是任何 Java 应用程序整体性能的关键特性之一。对于许多应用程序而言,唯一需要调整的是选择合适的 GC 算法,并且如果需要的话,增加应用程序的堆大小。自适应调整将允许 JVM 自动调整其行为,以利用给定的堆提供良好的性能。

更复杂的应用程序将需要额外的调整,特别是针对特定的 GC 算法。如果本章中的简单 GC 设置无法提供应用程序所需的性能,请查阅调整建议。

第六章:垃圾收集算法

第五章研究了所有垃圾收集器的一般行为,包括适用于所有 GC 算法的 JVM 标志:如何选择堆大小、代大小、日志记录等。基本的垃圾收集调优适用于许多情况。当它们不适用时,就需要检查正在使用的 GC 算法的具体操作,以确定如何更改其参数以最小化对应用程序的影响。

调整单个收集器所需的关键信息是在启用该收集器时从 GC 日志中获取的数据。因此,本章从查看每个算法的日志输出的角度开始,这使我们能够理解 GC 算法的工作原理及如何调整以获得更好的性能。然后,每个部分都包括调优信息以实现更佳的性能。

本章还涵盖了一些新的实验性收集器的详细信息。目前写作时,这些收集器可能不是百分之百稳定的,但很可能会在下一个 Java LTS 版本发布时成为成熟的、适合生产的收集器(就像 G1 GC 最初是实验性收集器,现在成为 JDK 11 的默认选择)。

几种不寻常的情况影响所有 GC 算法的性能:分配非常大的对象、既不短命也不长寿的对象等。本章末尾将涵盖这些情况。

理解吞吐量收集器

我们将从查看各个垃圾收集器开始,首先是吞吐量收集器。尽管我们已经看到 G1 GC 收集器通常更受欢迎,但吞吐量收集器的细节更为简单,更好地奠定了理解工作原理的基础。

回顾第五章中的内容,垃圾收集器必须执行三个基本操作:找到未使用的对象、释放它们的内存并压缩堆。吞吐量收集器在同一 GC 周期内执行所有这些操作;这些操作合称为收集。这些收集器可以在单个操作期间收集青年代或老年代。

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

一个堆在进行年轻代收集前后的图表。

图 6-1 吞吐量 GC 年轻代收集

当 Eden 区填满时,会发生年轻代收集。年轻代收集将所有对象移出 Eden 区:一些对象移动到一个幸存者空间(本图中的 S0),一些对象移动到老年代,从而导致老年代包含更多对象。当然,许多对象因不再被引用而被丢弃。

因为 Eden 区在此操作后通常为空,考虑到它已经被压缩,这可能看起来有些不寻常,但这正是其效果。

在使用PrintGCDetails的 JDK 8 GC 日志中,吞吐量收集器的小 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 秒发生。年轻代中的对象现在占据了 14,463 KB(14 MB,在幸存者空间中);在 GC 之前,它们占据了 227,983 KB(227 MB)。¹ 此时年轻代的总大小为 264 MB。

与此同时,堆的整体占用量(包括年轻代和老年代)从 280 MB 减少到 66 MB,此时整个堆的大小为 613 MB。此操作花费的时间不到 0.02 秒(输出末尾的 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

这里的信息是相同的;只是格式不同。这个日志条目有多行;前一个日志条目实际上是一行(但在这种格式中不会重现)。此日志还打印出元空间的大小,但这些在年轻代收集期间永远不会改变。元空间也不包括在此示例的第五行报告的总堆大小中。

图 6-2 显示了在进行完整的 GC 之前和之后堆的情况。

完整 GC 前后堆的示意图。

图 6-2. 吞吐量完整的 GC

老年代收集器会释放年轻代中的所有内容。只有那些具有活动引用的对象才会留在老年代中,并且所有这些对象都已经被压缩,以便老年代的开始被占用,其余部分为空闲。

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。元空间的大小未改变;在大多数完整的 GC 中不会对其进行收集。(如果元空间空间不足,JVM 将运行完整的 GC 来收集它,并且您将看到元空间的大小发生变化;稍后我会展示这一点。)由于在完整的 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

快速总结

  • 吞吐量收集器有两个操作:次要收集和完整的 GC,每个操作都标记、释放和压缩目标代。

  • 从 GC 日志中获取的时间是确定 GC 对使用这些收集器的应用程序的整体影响的快速方法。

自适应和静态堆大小调整

调整吞吐量收集器关键在于暂停时间,以及在整体堆大小和老年代与年轻代大小之间取得平衡。

在这里需要考虑两个权衡。首先,我们有时间与空间之间的经典编程权衡。更大的堆在机器上消耗更多内存,消耗该内存的好处(至少在一定程度上)是应用程序将具有更高的吞吐量。

第二个权衡涉及执行 GC 所需的时间长度。通过增加堆大小可以减少完全 GC 暂停的次数,但这可能会因 GC 时间较长而导致平均响应时间增加。同样,通过将更多的堆分配给年轻代而不是老年代可以缩短完全 GC 暂停时间,但这反过来会增加老年代 GC 集合的频率。

这些权衡的影响显示在图 6-3 中。该图显示了以不同堆大小运行的股票 REST 服务器的最大吞吐量。对于较小的 256 MB 堆,服务器在 GC 中花费了大量时间(实际上是总时间的 36%);因此吞吐量受限。随着堆大小的增加,吞吐量迅速增加,直到堆大小设置为 1,500 MB。之后,吞吐量增加速度较慢:此时应用程序并非真正受 GC 限制(GC 时间约占总时间的 6%)。边际收益递减法已悄然而至:应用程序可以使用额外内存以提高吞吐量,但增益变得更有限。

在堆大小达到 4,500 MB 后,吞吐量开始略微下降。此时,应用程序已经达到了第二个权衡:额外的内存导致更长的 GC 周期,即使这些周期较少,也会降低总体吞吐量。

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

jp2e 0603

图 6-3。各种堆大小的吞吐量

在吞吐量收集器中,自适应大小调整堆(和代)以满足其暂停时间目标。这些目标是使用以下标志设置的:-XX:MaxGCPauseMillis=N-XX:GCTimeRatio=N

MaxGCPauseMillis 标志指定应用程序愿意容忍的最大暂停时间。可能会有诱惑将其设置为 0,或者像 50 毫秒这样的小值。请注意,此目标适用于次要 GC 和完全 GC。如果使用非常小的值,应用程序将会得到非常小的老年代:例如可以在 50 毫秒内清理的老年代。这将导致 JVM 执行非常频繁的完全 GC,性能将非常糟糕。因此,请保持现实:将该值设置为可以实现的值。默认情况下,此标志未设置。

GCTimeRatio标志指定您愿意应用程序在 GC 中花费的时间量(与其应用级线程运行时间相比)。这是一个比率,因此*N*的值需要一些思考。该值在以下方程中使用,以确定应用程序线程理想情况下应该运行的时间百分比:

T h r o u g h p u t G o a l = 1 - 1 (1+GCTimeRatio)

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

更容易的做法是决定您希望应用程序执行工作的最低百分比(例如,95%),然后根据以下方程计算GCTimeRatio的值:

G C T i m e R a t i o = Throughput (1-Throughput)

对于通过量目标为 95%(0.95),该方程得出GCTimeRatio为 19。

JVM 使用这两个标志在初始(-Xms)和最大(-Xmx)堆大小建立的边界内设置堆的大小。MaxGCPauseMillis标志优先级较高:如果设置了该标志,则调整年轻代和老年代的大小,直到达到暂停时间目标。一旦达到目标,堆的总体大小将增加,直到达到时间比率目标。一旦两个目标都达到,JVM 将尝试减少堆的大小,以便最终达到满足这两个目标的最小可能堆大小。

因为默认情况下未设置暂停时间目标,自动堆大小调整的常见效果是堆(和代数)大小将增加,直到满足GCTimeRatio目标。尽管如此,该标志的默认设置实际上是乐观的。当然,您的经验会有所不同,但我更习惯于看到应用程序在 GC 中花费 3%到 6%的时间,并表现良好。有时甚至我会在内存严重受限的环境中处理应用程序,这些应用程序最终会在 GC 中花费 10%到 15%的时间。GC 对这些应用程序的性能有重大影响,但总体性能目标仍然能够达到。

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

表 6-1展示了这种动态调优对于需要小堆且几乎不进行 GC 的应用程序的影响(这是具有少量长寿命周期对象的标准 REST 服务器)。

表 6-1. 动态 GC 调优效果

GC 设置结束堆大小GC 中的时间百分比OPS
默认649 MB0.9%9.2
MaxGCPauseMillis=50ms560 MB1.0%9.2
Xms=Xmx=2048m2 GB0.04%9.2

默认情况下,堆的最小大小为 64 MB,最大大小为 2 GB(由于机器具有 8 GB 物理内存)。在这种情况下,GCTimeRatio 的工作就如预期的那样:堆动态调整为 649 MB,此时应用程序在 GC 中花费的总时间约为总时间的 1%。

在这种情况下设置 MaxGCPauseMillis 标志开始减小堆的大小以满足暂停时间目标。因为在此示例中垃圾收集器的工作量很小,所以它成功地仅花费总时间的 1% 在 GC 中,同时保持了 9.2 OPS 的吞吐量。

最后,请注意,并非总是越多越好。完整的 2 GB 堆确实意味着应用程序在 GC 中花费的时间较少,但在这里 GC 并非主要的性能因素,因此吞吐量并未增加。通常情况下,花费时间优化应用程序的错误区域并没有帮助。

如果将相同的应用程序更改为每个用户在全局缓存中保存先前的 50 个请求(例如,像 JPA 缓存那样),垃圾收集器将需要更加努力。表 6-2 显示了这种情况下的权衡。

表 6-2. 堆占用对动态 GC 调优的影响

GC 设置最终堆大小GC 时间百分比OPS
默认1.7 GB9.3%8.4
MaxGCPauseMillis=50ms588 MB15.1%7.9
Xms=Xmx=2048m2 GB5.1%9.0
Xmx=3560M; MaxGCRatio=192.1 GB8.8%9.0

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

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

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

快速总结

  • 动态堆调整是堆大小调整的良好首步。对于大部分应用程序而言,这将是唯一需要的,动态设置将最小化 JVM 的内存使用。

  • 可以静态地调整堆大小以获得最大可能的性能。JVM 为一组合理的性能目标确定的大小是调整的良好起点。

理解 G1 垃圾收集器

G1 GC 在堆内操作离散的区域。每个区域(默认约为 2,048 个)可以属于老年代或新生代,并且代的区域不一定是连续的。在老年代有区域的想法是,当并发后台线程寻找无引用对象时,某些区域将包含比其他区域更多的垃圾。一个区域的实际收集仍然需要停止应用线程,但是 G1 GC 可以专注于主要是垃圾的区域,并且只花一点时间清空这些区域。这种方法——仅清理主要是垃圾的区域——是 G1 GC 名称的由来:垃圾优先。

这不适用于年轻代的区域:在年轻代 GC 期间,整个年轻代要么被释放,要么被晋升(到幸存者空间或老年代)。尽管如此,年轻代是以区域来定义的,部分原因是如果区域预定义,调整大小的代就更容易。

G1 GC 被称为并发收集器,因为在老年代内自由对象的标记与应用线程同时进行(即它们保持运行)。但它并不完全是并发的,因为年轻代的标记和压缩需要停止所有应用线程,并且老年代的压缩也发生在应用线程停止时。

G1 GC 有四个逻辑操作:

  • 年轻代收集

  • 背景,并发标记周期

  • 混合收集

  • 如果需要,进行完整的 GC

我们将依次查看每个操作,从 G1 GC 年轻代收集开始,如图 6-4 所示。

一个 G1 GC young 收集之前和之后堆的图示。

图 6-4. G1 GC 年轻代收集

此图中每个小方块代表一个 G1 GC 区域。每个区域的数据由黑色区域表示,区域内的字母标识其属于的代([E]den,[O]ld generation,[S]urvivor space)。空白区域不属于任何代;G1 GC 根据需要任意使用它们。

当 Eden 填满时(在本例中,填满了四个区域),触发 G1 GC 年轻代收集。收集后,Eden 为空(尽管区域被分配给它,随着应用程序的进行,这些区域将开始填充数据)。至少有一个区域被分配给了幸存者空间(在此示例中部分填充),并且一些数据已经移动到了老年代。

在 G1 中,GC 日志对这个收集过程的描述与其他收集器有些不同。JDK 8 的示例日志使用了PrintGCDetails,但是 G1 GC 的日志细节更加详细。这些示例只展示了一些重要的行。

这是年轻代的标准收集过程:

23.430: [GC pause (young), 0.23094400 secs]
...
   [Eden: 1286M(1286M)->0B(1212M)
   	Survivors: 78M->152M Heap: 1454M(4096M)->242M(4096M)]
   [Times: user=0.85 sys=0.05, real=0.23 secs]

年轻代的收集在真实时间中花费了 0.23 秒,其中 GC 线程消耗了 0.85 秒的 CPU 时间。共移出了 1,286 MB 的对象从伊甸园(自适应调整大小为 1,212 MB),其中 74 MB 移至存活区(其大小从 78 M 增至 152 MB),其余对象被释放。我们通过观察堆总占用减少了 1,212 MB 来确认它们已被释放。在一般情况下,一些存活区的对象可能会被移至老年代,如果存活区满了,一些来自伊甸园的对象则会直接晋升到老年代——在这些情况下,老年代的大小会增加。

JDK 11 中类似的日志如下:

[23.200s][info   ][gc,start     ] GC(10) Pause Young (Normal)
                                           (G1 Evacuation Pause)
[23.200s][info   ][gc,task      ] GC(10) Using 4 workers of 4 for evacuation
[23.430s][info   ][gc,phases    ] GC(10)   Pre Evacuate Collection Set: 0.0ms
[23.430s][info   ][gc,phases    ] GC(10)   Evacuate Collection Set: 230.3ms
[23.430s][info   ][gc,phases    ] GC(10)   Post Evacuate Collection Set: 0.5ms
[23.430s][info   ][gc,phases    ] GC(10)   Other: 0.1ms
[23.430s][info   ][gc,heap      ] GC(10) Eden regions: 643->606(606)
[23.430s][info   ][gc,heap      ] GC(10) Survivor regions: 39->76(76)
[23.430s][info   ][gc,heap      ] GC(10) Old regions: 67->75
[23.430s][info   ][gc,heap      ] GC(10) Humongous regions: 0->0
[23.430s][info   ][gc,metaspace ] GC(10) Metaspace: 18407K->18407K(1067008K)
[23.430s][info   ][gc           ] GC(10) Pause Young (Normal)
                                           (G1 Evacuation Pause)
                                           1454M(4096M)->242M(4096M) 230.104ms
[23.430s][info   ][gc,cpu       ] GC(10) User=0.85s Sys=0.05s Real=0.23s

并发的 G1 GC 周期开始和结束如图 6-5 所示。

一个展示 G1 并发周期前后堆的图表。

Figure 6-5. G1 GC 执行的并发收集

该图表显示了三个要观察的重要点。首先,年轻代已经改变了其占用:在并发周期内可能会有至少一个(甚至更多)年轻代收集。因此,在标记周期之前的伊甸园区域已经完全释放,并且开始分配新的伊甸园区域。

其次,现在一些区域被标记为 X。这些区域属于老年代(请注意它们仍然包含数据)——这些是标记周期确定包含大部分垃圾的区域。

最后,请注意,老年代(由带有 O 或 X 标记的区域组成)在周期完成后实际上更加占用。这是因为标记周期期间发生的年轻代收集将数据晋升到了老年代。此外,标记周期实际上并不释放老年代中的任何数据:它只是识别大部分是垃圾的区域。这些区域的数据将在稍后的周期中释放。

G1 GC 的并发周期有几个阶段,有些会停止所有应用线程,有些则不会。第一个阶段称为initial-mark(在 JDK 8 中)或concurrent start(在 JDK 11 中)。该阶段停止所有应用线程——部分因为它也执行了年轻代收集,并设置了周期的后续阶段。

在 JDK 8 中,看起来是这样的:

50.541: [GC pause (G1 Evacuation pause) (young) (initial-mark), 0.27767100 secs]
    ... lots of other data ...
    [Eden: 1220M(1220M)->0B(1220M)
    	Survivors: 144M->144M Heap: 3242M(4096M)->2093M(4096M)]
    [Times: user=1.02 sys=0.04, real=0.28 secs]

并在 JDK 11 中:

[50.261s][info   ][gc,start      ] GC(11) Pause Young (Concurrent Start)
                                              (G1 Evacuation Pause)
[50.261s][info   ][gc,task       ] GC(11) Using 4 workers of 4 for evacuation
[50.541s][info   ][gc,phases     ] GC(11)   Pre Evacuate Collection Set: 0.1ms
[50.541s][info   ][gc,phases     ] GC(11)   Evacuate Collection Set: 25.9ms
[50.541s][info   ][gc,phases     ] GC(11)   Post Evacuate Collection Set: 1.7ms
[50.541s][info   ][gc,phases     ] GC(11)   Other: 0.2ms
[50.541s][info   ][gc,heap       ] GC(11) Eden regions: 1220->0(1220)
[50.541s][info   ][gc,heap       ] GC(11) Survivor regions: 144->144(144)
[50.541s][info   ][gc,heap       ] GC(11) Old regions: 1875->1946
[50.541s][info   ][gc,heap       ] GC(11) Humongous regions: 3->3
[50.541s][info   ][gc,metaspace  ] GC(11) Metaspace: 52261K->52261K(1099776K)
[50.541s][info   ][gc            ] GC(11) Pause Young (Concurrent Start)
                                              (G1 Evacuation Pause)
                                              1220M->0B(1220M) 280.055ms
[50.541s][info   ][gc,cpu        ] GC(11) User=1.02s Sys=0.04s Real=0.28s

就像普通的年轻代收集一样,应用线程被停止(持续 0.28 秒),并且年轻代被清空(因此伊甸园最终大小为 0)。从年轻代移动了 71 MB 的数据到老年代。在 JDK 8 中有些难以理解(为 2,093 - 3,242 + 1,220);而 JDK 11 的输出更清晰地显示了这一点。

另一方面,JDK 11 的输出包含了一些我们还没有讨论过的内容的引用。首先是大小以区域而不是 MB 为单位。我们将在本章后面讨论区域大小,但在本示例中,区域大小为 1 MB。此外,JDK 11 还提到了一个新领域:巨大区域。那是老年代的一部分,也将在本章后面讨论。

初始标记或并发开始日志消息宣布后台并发周期已经开始。由于标记周期的初始标记阶段也需要停止所有应用程序线程,所以 G1 GC 利用了年轻代 GC 周期来完成这项工作。将初始标记阶段添加到年轻代 GC 的影响并不大:它使用的 CPU 周期比之前的收集(仅仅是一个普通的年轻代收集)多了 20%,尽管暂停时间略长。(幸运的是,机器上有多余的 CPU 周期供并行 G1 线程使用,否则暂停时间将会更长。)

接下来,G1 GC 扫描根区域:

50.819: [GC concurrent-root-region-scan-start]
51.408: [GC concurrent-root-region-scan-end, 0.5890230]

[50.819s][info ][gc             ] GC(20) Concurrent Cycle
[50.819s][info ][gc,marking     ] GC(20) Concurrent Clear Claimed Marks
[50.828s][info ][gc,marking     ] GC(20) Concurrent Clear Claimed Marks 0.008ms
[50.828s][info ][gc,marking     ] GC(20) Concurrent Scan Root Regions
[51.408s][info ][gc,marking     ] GC(20) Concurrent Scan Root Regions 589.023ms

这个过程需要 0.58 秒,但不会停止应用程序线程;它只使用后台线程。然而,这个阶段不能被年轻代收集打断,因此为这些后台线程提供可用的 CPU 周期至关重要。如果在根区域扫描期间年轻代填满了,那么年轻代收集(已经停止所有应用程序线程)必须等待根扫描完成。实际上,这意味着收集年轻代需要比平常更长的暂停时间。这种情况在 GC 日志中显示如下:

350.994: [GC pause (young)
	351.093: [GC concurrent-root-region-scan-end, 0.6100090]
	351.093: [GC concurrent-mark-start],
	0.37559600 secs]

[350.384s][info][gc,marking   ] GC(50) Concurrent Scan Root Regions
[350.384s][info][gc,marking   ] GC(50) Concurrent Scan Root Regions 610.364ms
[350.994s][info][gc,marking   ] GC(50) Concurrent Mark (350.994s)
[350.994s][info][gc,marking   ] GC(50) Concurrent Mark From Roots
[350.994s][info][gc,task      ] GC(50) Using 1 workers of 1 for marking
[350.994s][info][gc,start     ] GC(51) Pause Young (Normal) (G1 Evacuation Pause)

这里的 GC 暂停在根区域扫描结束之前开始。在 JDK 8 中,GC 日志中的交错输出表明年轻代收集必须暂停等待根区域扫描完成才能继续进行。在 JDK 11 中,这有点难以检测:你必须注意到根区域扫描结束的时间戳恰好与下一个年轻代收集开始的时间戳相同。

无论哪种情况,都无法准确知道年轻代收集延迟了多长时间。在这个例子中,它并不一定会延迟整整 610 毫秒;在那段时间内(直到年轻代实际填满),事情仍在继续。但在这种情况下,时间戳显示应用程序线程等待了额外的约 100 毫秒—这就是为什么年轻代 GC 暂停的持续时间比日志中其他暂停的平均持续时间长约 100 毫秒的原因(如果这种情况经常发生,这表明 G1 GC 需要更好地调整,如下一节所讨论的)。

在根区域扫描之后,G1 GC 进入并发标记阶段。这完全在后台进行;开始和结束时会打印一条消息:

111.382: [GC concurrent-mark-start]
....
120.905: [GC concurrent-mark-end, 9.5225160 sec]

[111.382s][info][gc,marking   ] GC(20) Concurrent Mark (111.382s)
[111.382s][info][gc,marking   ] GC(20) Concurrent Mark From Roots
...
[120.905s][info][gc,marking   ] GC(20) Concurrent Mark From Roots 9521.994ms
[120.910s][info][gc,marking   ] GC(20) Concurrent Preclean
[120.910s][info][gc,marking   ] GC(20) Concurrent Preclean 0.522ms
[120.910s][info][gc,marking   ] GC(20) Concurrent Mark (111.382s, 120.910s)
                                         9522.516ms

并发标记可以被中断,因此在此阶段可能发生年轻代收集(因此在省略号处会有大量 GC 输出)。

还请注意,在 JDK 11 示例中,输出具有与根区域扫描发生时相同的 GC 记录—20—。我们正在更细化地分解操作,而不像 JDK 日志将整个后台扫描视为一个操作。例如,当并发标记不能时,根扫描可能会引入暂停。

标记阶段后是重新标记阶段和正常的清理阶段:

120.910: [GC remark 120.959:
	[GC ref-PRC, 0.0000890 secs], 0.0718990 secs]
 	[Times: user=0.23 sys=0.01, real=0.08 secs]
120.985: [GC cleanup 3510M->3434M(4096M), 0.0111040 secs]
 	[Times: user=0.04 sys=0.00, real=0.01 secs]

[120.909s][info][gc,start     ] GC(20) Pause Remark
[120.909s][info][gc,stringtable] GC(20) Cleaned string and symbol table,
                                           strings: 1369 processed, 0 removed,
                                           symbols: 17173 processed, 0 removed
[120.985s][info][gc            ] GC(20) Pause Remark 2283M->862M(3666M) 80.412ms
[120.985s][info][gc,cpu        ] GC(20) User=0.23s Sys=0.01s Real=0.08s

这些阶段会停止应用程序线程,尽管通常只是短暂的时间。接下来会同时进行额外的清理阶段:

120.996: [GC concurrent-cleanup-start]
120.996: [GC concurrent-cleanup-end, 0.0004520]

[120.878s][info][gc,start      ] GC(20) Pause Cleanup
[120.879s][info][gc            ] GC(20) Pause Cleanup 1313M->1313M(3666M) 1.192ms
[120.879s][info][gc,cpu        ] GC(20) User=0.00s Sys=0.00s Real=0.00s
[120.879s][info][gc,marking    ] GC(20) Concurrent Cleanup for Next Mark
[120.996s][info][gc,marking    ] GC(20) Concurrent Cleanup for Next Mark
                                          117.168ms
[120.996s][info][gc            ] GC(20) Concurrent Cycle 70,177.506ms

而常规的 G1 GC 后台标记周期完成了——至少在找到垃圾方面如此。但实际上,很少有内存被释放。在清理阶段中回收了一点内存,但到目前为止,G1 GC 真正做的只是识别出大部分是垃圾并可以回收的旧区域(在 图 6-5 中用 X 标记的区域)。

现在 G1 GC 执行一系列混合 GC。它们被称为“混合”是因为它们执行了正常的年轻代收集,同时也收集了后台扫描中的一些标记区域。混合 GC 的效果显示在 图 6-6 中。

就像对于年轻代的收集一样,G1 GC 已经完全清空了 Eden 区并调整了幸存者空间。此外,两个标记的区域已被收集。这些区域已知主要包含垃圾,因此它们的大部分被释放了。这些区域中的任何存活数据都被移到另一个区域(就像从年轻代中的区域移到老年代的区域中的存活数据一样)。这就是 G1 GC 如何压缩老年代的方式——在执行时移动对象实质上是压缩堆。

G1 GC 混合收集前后堆的示意图。

图 6-6. G1 GC 执行的混合 GC

混合 GC 操作通常在日志中看起来是这样的:

79.826: [GC pause (mixed), 0.26161600 secs]
....
   [Eden: 1222M(1222M)->0B(1220M)
   	Survivors: 142M->144M Heap: 3200M(4096M)->1964M(4096M)]
   [Times: user=1.01 sys=0.00, real=0.26 secs]

[3.800s][info][gc,start      ] GC(24) Pause Young (Mixed) (G1 Evacuation Pause)
[3.800s][info][gc,task       ] GC(24) Using 4 workers of 4 for evacuation
[3.800s][info][gc,phases     ] GC(24)   Pre Evacuate Collection Set: 0.2ms
[3.825s][info][gc,phases     ] GC(24)   Evacuate Collection Set: 250.3ms
[3.826s][info][gc,phases     ] GC(24)   Post Evacuate Collection Set: 0.3ms
[3.826s][info][gc,phases     ] GC(24)   Other: 0.4ms
[3.826s][info][gc,heap       ] GC(24) Eden regions: 1222->0(1220)
[3.826s][info][gc,heap       ] GC(24) Survivor regions: 142->144(144)
[3.826s][info][gc,heap       ] GC(24) Old regions: 1834->1820
[3.826s][info][gc,heap       ] GC(24) Humongous regions: 4->4
[3.826s][info][gc,metaspace  ] GC(24) Metaspace: 3750K->3750K(1056768K)
[3.826s][info][gc            ] GC(24) Pause Young (Mixed) (G1 Evacuation Pause)
                                          3791M->3791M(3983M) 124.390ms
[3.826s][info][gc,cpu        ] GC(24) User=1.01s Sys=0.00s Real=0.26s
[3.826s][info][gc,start      ] GC(25) Pause Young (Mixed) (G1 Evacuation Pause)

注意,整个堆的使用情况已经减少了不止从 Eden 中移除的 1,222 MB。这个差异(16 MB)看起来很小,但要记住,同时一些幸存者空间被提升到老年代;此外,每个混合 GC 仅清理了目标老年代区域的一部分。随着我们的继续,你会发现确保混合 GC 清理足够的内存以防止未来并发故障是很重要的。

在 JDK 11 中,第一个混合 GC 被标记为 Prepared Mixed 并紧随并发清理之后。

混合 GC 循环将继续,直到几乎所有标记的区域都被收集,此时 G1 GC 将恢复常规的年轻代 GC 周期。最终,G1 GC 将开始另一个并发周期,确定应该释放老年代的哪些区域。

虽然混合 GC 循环通常在 GC 原因中标记为(Mixed),但有时在并发循环后(即G1 Evacuation Pause)会正常标记年轻代收集。如果并发循环发现老年代中可以完全释放的区域,则这些区域会在常规的年轻代撤离暂停期间被回收。技术上来说,这不是收集器实现中的混合循环。但从逻辑上讲,是的:对象从年轻代被释放或晋升到老年代,同时垃圾对象(实际上是区域)从老年代被释放。

如果一切顺利,这就是您在 GC 日志中看到的所有 GC 活动集。但还有一些失败案例需要考虑。

有时您会在日志中观察到完整 GC,这表明需要更多调整(包括可能增加堆空间)以提高应用程序性能。主要触发这种情况的是四次:

并发模式失败

G1 GC 启动标记周期,但在完成周期之前,老年代已满。在这种情况下,G1 GC 中止标记周期:

51.408: [GC concurrent-mark-start]
65.473: [Full GC 4095M->1395M(4096M), 6.1963770 secs]
 [Times: user=7.87 sys=0.00, real=6.20 secs]
71.669: [GC concurrent-mark-abort]

[51.408][info][gc,marking     ] GC(30) Concurrent Mark From Roots
...
[65.473][info][gc             ] GC(32) Pause Full (G1 Evacuation Pause)
                                          4095M->1305M(4096M) 60,196.377
...
[71.669s][info][gc,marking     ] GC(30) Concurrent Mark From Roots 191ms
[71.669s][info][gc,marking     ] GC(30) Concurrent Mark Abort

这意味着应该增加堆大小,必须尽快开始 G1 GC 后台处理,或者必须调整周期以更快运行(例如使用额外的后台线程)。如何执行这些操作的详细信息如下。

晋升失败

G1 GC 已完成标记周期,并开始执行混合 GC 来清理老区域。在清理足够空间之前,从年轻代晋升的对象太多,因此老年代仍然空间不足。在日志中,混合 GC 立即跟随完整 GC:

2226.224: [GC pause (mixed)
	2226.440: [SoftReference, 0 refs, 0.0000060 secs]
	2226.441: [WeakReference, 0 refs, 0.0000020 secs]
	2226.441: [FinalReference, 0 refs, 0.0000010 secs]
	2226.441: [PhantomReference, 0 refs, 0.0000010 secs]
	2226.441: [JNI Weak Reference, 0.0000030 secs]
		(to-space exhausted), 0.2390040 secs]
....
    [Eden: 0.0B(400.0M)->0.0B(400.0M)
    	Survivors: 0.0B->0.0B Heap: 2006.4M(2048.0M)->2006.4M(2048.0M)]
    [Times: user=1.70 sys=0.04, real=0.26 secs]
2226.510: [Full GC (Allocation Failure)
	2227.519: [SoftReference, 4329 refs, 0.0005520 secs]
	2227.520: [WeakReference, 12646 refs, 0.0010510 secs]
	2227.521: [FinalReference, 7538 refs, 0.0005660 secs]
	2227.521: [PhantomReference, 168 refs, 0.0000120 secs]
	2227.521: [JNI Weak Reference, 0.0000020 secs]
		2006M->907M(2048M), 4.1615450 secs]
    [Times: user=6.76 sys=0.01, real=4.16 secs]

[2226.224s][info][gc            ] GC(26) Pause Young (Mixed)
                                            (G1 Evacuation Pause)
                                            2048M->2006M(2048M) 26.129ms
...
[2226.510s][info][gc,start      ] GC(27) Pause Full (G1 Evacuation Pause)

这种失败意味着混合收集需要更快地进行;每个年轻代收集需要处理更多老年代的区域。

撤离失败

在进行年轻代收集时,幸存空间和老年代中没有足够的空间来容纳所有幸存对象。这会在 GC 日志中出现作为特定类型的年轻代 GC:

60.238: [GC pause (young) (to-space overflow), 0.41546900 secs]

[60.238s][info][gc,start       ] GC(28) Pause Young (Concurrent Start)
                                          (G1 Evacuation Pause)
[60.238s][info][gc,task        ] GC(28) Using 4 workers of 4
                                          for evacuation
[60.238s][info][gc             ] GC(28) To-space exhausted

这表明堆大部分已满或碎片化。G1 GC 会尝试补偿,但可能会最终执行完整的 GC。简单的解决方法是增加堆大小,虽然“高级调整”中提供了其他可能的解决方案。

巨大分配失败

分配非常大对象的应用程序可能会触发 G1 GC 中的另一种完整 GC;详细信息请参见“G1 GC 分配巨大对象”(包括如何避免)。在 JDK 8 中,除非使用特殊的日志参数,否则无法诊断这种情况,但在 JDK 11 中,可以通过此日志显示:

[3023.091s][info][gc,start     ] GC(54) Pause Full (G1 Humongous Allocation)

元数据 GC 阈值

正如我提到的,元空间本质上是一个独立的堆,与主堆独立收集。它不通过 G1 GC 进行收集,但是当 JDK 8 需要收集时,G1 GC 将在主堆上执行完整的 GC(立即在年轻代收集之前):

0.0535: [GC (Metadata GC Threshold) [PSYoungGen: 34113K->20388K(291328K)]
    73838K->60121K(794112K), 0.0282912 secs]
    [Times: user=0.05 sys=0.01, real=0.03 secs]
0.0566: [Full GC (Metadata GC Threshold) [PSYoungGen: 20388K->0K(291328K)]
    [ParOldGen: 39732K->46178K(584192K)] 60121K->46178K(875520K),
    [Metaspace: 59040K->59036K(1101824K)], 0.1121237 secs]
    [Times: user=0.28 sys=0.01, real=0.11 secs]

在 JDK 11 中,元空间可以在不需要完整 GC 的情况下进行收集/调整大小。

快速总结

  • G1 有多个循环(并发循环内的阶段)。运行 G1 的良好调整的 JVM 应该只会经历年轻代、混合和并发 GC 循环。

  • 在一些 G1 并发阶段会发生小的暂停。

  • 如果需要避免完整的 GC 循环,则应对 G1 进行调整。

G1 GC 的调整

调整 G1 GC 的主要目标是确保没有并发模式或疏散故障会导致需要进行完整的 GC。用于防止完整 GC 的技术也可以在频繁的年轻 GC 必须等待根区域扫描完成时使用。

在 JDK 8 中,调整以避免完整的收集是至关重要的,因为当 G1 GC 在 JDK 8 中执行完整的 GC 时,它会使用一个线程。这会导致比通常更长的暂停时间。在 JDK 11 中,完整的 GC 由多个线程执行,导致较短的暂停时间(基本上与使用吞吐量收集器执行完整 GC 的暂停时间相同)。这种差异是在使用 G1 GC 时更喜欢升级到 JDK 11 的一个原因(尽管一个避免完整 GC 的 JDK 8 应用程序也会表现良好)。

其次,调整可以尽量减少沿途发生的暂停。

这些是防止完整 GC 的选项:

  • 增加老年代的大小,可以通过增加总堆空间或调整两代之间的比率来实现。

  • 增加后台线程的数量(假设有足够的 CPU)。

  • 更频繁地执行 G1 GC 后台活动。

  • 增加在混合 GC 循环中完成的工作量。

这里可以应用很多调整,但是 G1 GC 的目标之一是不需要进行太多的调整。为此,G1 GC 主要通过一个标志进行调整:与用于调整吞吐量收集器的相同 -XX:MaxGCPauseMillis=N 标志。

当与 G1 GC 结合使用时(与吞吐量收集器不同),该标志确实具有默认值:200 毫秒。如果 G1 GC 的停止世界阶段的暂停开始超过该值,G1 GC 将尝试进行补偿 — 调整年轻代和老年代的比例、调整堆大小、更早地启动后台处理、更改保留阈值以及(最重要的)在混合 GC 循环期间处理更多或更少的老年代区域。

这里存在一些权衡:如果该值减小,年轻代大小将会收缩以达到暂停时间目标,但会执行更频繁的年轻代 GC。此外,在混合 GC 期间可以收集的老年代区域数量将减少以达到暂停时间目标,这会增加并发模式失败的可能性。

如果设置暂停时间目标不能防止发生全局 GC,可以分别调整这些不同的方面。为 G1 GC 调整堆大小的方法与其他 GC 算法相同。

调整 G1 背景线程

你可以将 G1 GC 的并发标记视为与应用程序线程的竞争:G1 GC 必须更快地清除旧一代,以防止应用程序将新数据提升到其中。要实现这一点,可以尝试增加后台标记线程的数量(假设机器上有足够的 CPU 可用)。

G1 GC 使用两组线程。第一组线程由 -XX:ParallelGCThreads=*N* 标志控制,你在 第五章 中首次看到了这个标志。该值影响停止应用程序线程时使用的线程数:年轻和混合收集以及必须停止线程的并发备注周期的阶段。第二个标志是 -XX:ConcGCThreads=*N*,它影响用于并发备注的线程数。

ConcGCThreads 标志的默认值定义如下:

ConcGCThreads = (ParallelGCThreads + 2) / 4

这个划分是基于整数的,因此将有一个后台扫描线程对应五个并行线程,两个后台扫描线程对应六到九个并行线程,依此类推。

增加后台扫描线程的数量将使并发周期变短,这应该会使 G1 GC 在混合 GC 周期结束前更容易释放旧一代,而不会被其他线程再次填满。一如既往,这假设 CPU 周期是可用的;否则,扫描线程将从应用程序中取走 CPU,并有效地引入暂停,就像我们在 第五章 中将串行收集器与 G1 GC 进行比较时看到的那样。

调整 G1 GC 的运行频率(更频繁或更少)

G1 GC 也可以在更早地开始后台标记周期时赢得竞争。该周期从堆达到由 -XX:InitiatingHeapOccupancyPercent=N 指定的占用率开始,其默认值为 45。此百分比指的是整个堆,而不仅仅是旧一代。

InitiatingHeapOccupancyPercent 值是恒定的;G1 GC 在尝试满足其暂停时间目标时不会更改该数字。如果该值设置得太高,应用程序将执行全局 GC,因为并发阶段没有足够的时间来完成,而其余堆已经填满。如果该值太小,应用程序将执行比通常更多的后台 GC 处理。

当然,那些后台线程在某个时候需要运行,因此硬件应该有足够的 CPU 来容纳它们。但是,如果运行太频繁,可能会导致严重的惩罚,因为那些停止应用程序线程的并发阶段将会有更多的小暂停。这些暂停会迅速积累,因此应该避免对 G1 GC 进行过于频繁的后台扫描。在并发循环后检查堆的大小,并确保 InitiatingHeapOccupancyPercent 的值高于该值。

调整 G1 GC 混合 GC 循环

在并发循环之后,G1 GC 不能开始新的并发循环,直到旧代中所有先前标记的区域都已被收集。因此,使 G1 GC 更早开始标记循环的另一种方法是在混合 GC 循环中处理更多区域(这样最终混合 GC 循环将减少)。

混合 GC 所做的工作量取决于三个因素。第一个因素是在第一次检测中发现的大部分垃圾的区域数量。没有直接影响这一点的方法:在混合 GC 中,如果一个区域的垃圾量达到 85%,则宣布其可收集。

第二个因素是 G1 GC 处理这些区域的最大混合 GC 循环数,由标志 -XX:G1Mixed``GCCountTarget=N 指定。默认值为 8;减少该值有助于克服晋升失败(但会延长混合 GC 循环的暂停时间)。

另一方面,如果混合 GC 暂停时间过长,可以增加该值,以减少混合 GC 过程中的工作量。只需确保增加该数字不会过长延迟下一个 G1 GC 并发循环,否则可能会导致并发模式失败。

最后,第三个因素是 GC 暂停的最大期望长度(即由 MaxGCPauseMillis 指定的值)。由 G1MixedGCCountTarget 标志指定的混合循环数是一个上限;如果在暂停目标时间内有时间,则 G1 GC 将收集超过已标记的旧代区域的八分之一(或者指定的任何值)。增加 MaxGCPauseMillis 标志的值允许在每个混合 GC 中收集更多旧代区域,从而允许 G1 GC 更早开始下一个并发循环。

快速总结

  • G1 GC 调优应始于设定合理的暂停时间目标。

  • 如果这样做后仍然存在全 GC 问题,并且无法增加堆大小,则可以针对特定失败应用特定调整:

    • 要使后台线程更频繁运行,请调整 InitiatingHeapOccupancyPercent

    • 如果有额外的 CPU 可用,通过 ConcGCThreads 标志调整线程数。

    • 为防止晋升失败,减少 G1MixedGCCountTarget 的大小。

了解 CMS 收集器

尽管 CMS 收集器已被弃用,但它仍然在当前 JDK 构建中可用。因此,本节介绍了如何调优它以及它为何被弃用的原因。

CMS 有三个基本操作:

  • 收集年轻代(停止所有应用程序线程)

  • 运行并发循环以清理老年代中的数据

  • 执行全局 GC 以压缩老年代(如有必要)

在 图 6-7 中展示了年轻代的 CMS 收集。

CMS 年轻代收集前后堆的图示。

图 6-7. CMS 执行的年轻代收集

CMS 年轻代收集类似于吞吐量年轻代收集:数据从伊甸园移动到一个幸存者空间(如果幸存者空间填满则移入老年代)。

CMS 的 GC 日志条目也类似(我仅展示 JDK 8 的日志格式):

89.853: [GC 89.853: [ParNew: 629120K->69888K(629120K), 0.1218970 secs]
		1303940K->772142K(2027264K), 0.1220090 secs]
		[Times: user=0.42 sys=0.02, real=0.12 secs]

当前年轻代的大小为 629 MB;收集后,其中有 69 MB 保留在幸存者空间。同样,整个堆的大小为 2,027 MB,在收集后占用了 772 MB。整个过程耗时 0.12 秒,尽管并行 GC 线程累计 CPU 使用时间为 0.42 秒。

在 图 6-8 中展示了一个并发循环。

CMS 根据堆的占用情况启动并发循环。当堆充分填满时,会启动背景线程遍历堆并移除对象。循环结束时,堆看起来像图中的底部行所示。请注意,老年代没有压缩:有些区域分配了对象,有些是空闲区域。当年轻代收集将对象从伊甸园移入老年代时,JVM 将尝试使用这些空闲区域来容纳对象。通常这些对象无法完全放入一个空闲区域,这就是为什么在 CMS 循环之后,堆的高水位标记更大的原因。

CMS 并发循环前后堆的图示。

图 6-8. CMS 执行的并发收集

在 GC 日志中,此周期显示为多个阶段。尽管大多数并发循环使用后台线程,某些阶段会引入短暂的暂停,停止所有应用程序线程。

并发循环从初始标记阶段开始,停止所有应用程序线程:

89.976: [GC [1 CMS-initial-mark: 702254K(1398144K)]
		772530K(2027264K), 0.0830120 secs]
		[Times: user=0.08 sys=0.00, real=0.08 secs]

这个阶段负责在堆中找到所有的 GC 根对象。第一组数字显示,目前占用老年代的 702 MB,总共 1,398 MB,而第二组数字显示整个 2,027 MB 堆的占用为 772 MB。在 CMS 周期的这个阶段,应用程序线程停止了 0.08 秒。

下一个阶段是标记阶段,不会停止应用程序线程。GC 日志中的这些行代表这个阶段:

90.059: [CMS-concurrent-mark-start]
90.887: [CMS-concurrent-mark: 0.823/0.828 secs]
		[Times: user=1.11 sys=0.00, real=0.83 secs]

标记阶段花费了 0.83 秒(和 1.11 秒的 CPU 时间)。由于这只是一个标记阶段,它对堆占用并没有做任何操作,因此关于此方面的数据未显示。如果有数据的话,可能会显示在这 0.83 秒内,由于应用线程继续执行,年轻代中分配对象导致堆的增长。

再来是预清理阶段,该阶段也与应用线程并发运行:

90.887: [CMS-concurrent-preclean-start]
90.892: [CMS-concurrent-preclean: 0.005/0.005 secs]
		[Times: user=0.01 sys=0.00, real=0.01 secs]

下一个阶段是备注阶段,但它涉及几个操作:

90.892: [CMS-concurrent-abortable-preclean-start]
92.392: [GC 92.393: [ParNew: 629120K->69888K(629120K), 0.1289040 secs]
		1331374K->803967K(2027264K), 0.1290200 secs]
		[Times: user=0.44 sys=0.01, real=0.12 secs]
94.473: [CMS-concurrent-abortable-preclean: 3.451/3.581 secs]
		[Times: user=5.03 sys=0.03, real=3.58 secs]

94.474: [GC[YG occupancy: 466937 K (629120 K)]
	94.474: [Rescan (parallel) , 0.1850000 secs]
	94.659: [weak refs processing, 0.0000370 secs]
	94.659: [scrub string table, 0.0011530 secs]
		[1 CMS-remark: 734079K(1398144K)]
		1201017K(2027264K), 0.1863430 secs]
	[Times: user=0.60 sys=0.01, real=0.18 secs]

等等,CMS 刚执行了一个预清理阶段?那么这个可中止的预清理阶段又是什么?

使用可中止的预清理阶段是因为备注阶段(严格来说,在输出中是最后一个条目)不是并发的——它将停止所有应用线程。CMS 希望避免年轻代收集紧随备注阶段之后发生的情况,这种情况下,应用线程将因连续的暂停操作而停止。这里的目标是通过防止连续暂停来最小化暂停长度。

因此,可中止的预清理阶段会等待年轻代填满约 50%。理论上,这是在年轻代收集之间的一半,为 CMS 避免连续出现暂停提供了最佳机会。在此示例中,可中止的预清理阶段从 90.8 秒开始,并等待大约 1.5 秒以进行常规年轻代收集(在日志的 92.392 秒处)。CMS 使用过去的行为来计算下一次可能发生的年轻代收集时间——在这种情况下,CMS 计算大约在 4.2 秒后会发生年轻代收集。因此在 2.1 秒后(在 94.4 秒时),CMS 结束了预清理阶段(虽然这是唯一停止该阶段的方法,但 CMS 称其为“中止”该阶段)。然后,最后,CMS 执行了备注阶段,导致应用线程暂停了 0.18 秒(在可中止的预清理阶段期间应用线程没有暂停)。

接下来是另一个并发阶段——扫描阶段:

94.661: [CMS-concurrent-sweep-start]
95.223: [GC 95.223: [ParNew: 629120K->69888K(629120K), 0.1322530 secs]
		999428K->472094K(2027264K), 0.1323690 secs]
		[Times: user=0.43 sys=0.00, real=0.13 secs]
95.474: [CMS-concurrent-sweep: 0.680/0.813 secs]
		[Times: user=1.45 sys=0.00, real=0.82 secs]

此阶段花费了 0.82 秒,并与应用线程并发运行。它还碰巧被一个年轻代收集中断了。这个年轻代收集与扫描阶段无关,但作为一个例子留在这里,显示年轻代收集可以与老年代收集阶段同时发生。在图 6-8 中,请注意在并发收集期间年轻代的状态发生了变化——在扫描阶段期间可能发生了任意数量的年轻代收集(由于可中止的预清理阶段至少会有一次年轻代收集)。

接下来是并发重置阶段:

95.474: [CMS-concurrent-reset-start]
95.479: [CMS-concurrent-reset: 0.005/0.005 secs]
	[Times: user=0.00 sys=0.00, real=0.00 secs]

这是并发阶段的最后一步;CMS 周期完成了,老年代中发现的未引用对象现在是自由的(导致堆中显示的情况见图 6-8)。不幸的是,日志没有提供任何有关释放了多少对象的信息;重置行也没有提供堆占用的信息。要了解这一点,请看下一个年轻收集:

98.049: [GC 98.049: [ParNew: 629120K->69888K(629120K), 0.1487040 secs]
		1031326K->504955K(2027264K), 0.1488730 secs]

现在比较老年代在 89.853 秒时的占用情况(CMS 周期开始之前),大约是 703 MB(此时整个堆占用了 772 MB,其中包括 69 MB 在幸存者空间,因此老年代消耗了剩余的 703 MB)。在 98.049 秒的收集中,老年代占用约 504 MB;因此 CMS 周期清理了大约 199 MB 的内存。

如果一切顺利,这些将是 CMS 运行的唯一周期,也是 CMS GC 日志中出现的唯一日志消息。但是还有三条更多的消息需要注意,这些消息表明 CMS 遇到了问题。第一条是并发模式失败:

267.006: [GC 267.006: [ParNew: 629120K->629120K(629120K), 0.0000200 secs]
	267.006: [CMS267.350: [CMS-concurrent-mark: 2.683/2.804 secs]
	[Times: user=4.81 sys=0.02, real=2.80 secs]
 	(concurrent mode failure):
	1378132K->1366755K(1398144K), 5.6213320 secs]
	2007252K->1366755K(2027264K),
	[CMS Perm : 57231K->57222K(95548K)], 5.6215150 secs]
	[Times: user=5.63 sys=0.00, real=5.62 secs]

当发生年轻收集并且老年代没有足够空间来容纳预期晋升的所有对象时,CMS 执行的基本上是完整 GC。所有应用程序线程都会停止,并且老年代中的任何死对象都会被清理,将其占用减少到 1,366 MB —— 这个操作使应用程序线程暂停了整整 5.6 秒。这个操作是单线程的,这也是它执行时间如此之长的一个原因(并且也是堆增长时并发模式失败变得更糟糕的一个原因)。

这种并发模式失败是 CMS 被弃用的一个主要原因。G1 GC 可能会发生并发模式失败,但是当它转回到完整 GC 时,在 JDK 11 中会并行执行该完整 GC(尽管在 JDK 8 中不会)。CMS 完整 GC 执行时间会长很多倍,因为它必须在单线程中执行。²

第二个问题发生在老年代有足够空间来容纳晋升的对象,但是空闲空间碎片化,所以晋升失败:

6043.903: [GC 6043.903:
	[ParNew (promotion failed): 614254K->629120K(629120K), 0.1619839 secs]
	6044.217: [CMS: 1342523K->1336533K(2027264K), 30.7884210 secs]
	2004251K->1336533K(1398144K),
	[CMS Perm : 57231K->57231K(95548K)], 28.1361340 secs]
	[Times: user=28.13 sys=0.38, real=28.13 secs]

在这里,CMS 启动了一个年轻的收集,并假设存在空间来容纳所有晋升的对象(否则,它会声明并发模式失败)。这一假设被证明是不正确的:CMS 无法晋升对象,因为老年代是碎片化的(或者,少见的情况是,要晋升的内存量大于 CMS 预期的量)。

结果,在年轻收集过程中(当所有线程已经停止时),CMS 收集并压缩了整个老年代。好消息是,通过堆的压缩,碎片问题已经解决(至少暂时解决了)。但是这导致了长达 28 秒的暂停时间。这个时间比 CMS 发生并发模式失败时要长得多,因为整个堆被压缩;而并发模式失败只是简单地释放了堆中的对象。此时的堆看起来就像吞吐收集器的完全 GC 结束时一样(图 6-2):年轻代完全为空,老年代已经被压缩。

最后,CMS 日志可能显示完全 GC,但没有任何常规的并发 GC 消息:

279.803: [Full GC 279.803:
		[CMS: 88569K->68870K(1398144K), 0.6714090 secs]
		558070K->68870K(2027264K),
		[CMS Perm : 81919K->77654K(81920K)],
		0.6716570 secs]

当元空间填满并且需要收集时,会发生这种情况。CMS 不会收集元空间,因此如果填满了,需要进行完全 GC 来丢弃任何未引用的类。“高级调整” 显示了如何解决这个问题。

快速总结

  • CMS 有几个 GC 操作,但预期的操作是小 GC 和并发周期。

  • CMS 中的并发模式失败和推广失败都很昂贵;应尽量调整 CMS 以避免这些问题。

  • 默认情况下,CMS 不会收集元空间。

调整以解决并发模式失败

在调整 CMS 时的主要关注点是确保不会发生并发模式或推广失败。正如 CMS GC 日志所示,发生并发模式失败是因为 CMS 没有及时清理老年代:当需要在年轻代执行收集时,CMS 计算到它没有足够的空间来晋升这些对象到老年代,于是首先收集老年代。

老年代最初通过将对象放置在彼此相邻的位置来填充。当老年代填充了一定量(默认为 70%)时,并发周期开始,并且后台 CMS 线程开始扫描老年代的垃圾。此时比赛开始:CMS 必须在老年代扫描和释放对象完成之前(剩余的 30% 填充),完成扫描老年代。如果并发周期失败,CMS 将经历并发模式失败。

我们可以尝试多种方法来避免这种失败:

  • 增加老年代的大小,可以通过将新生代与老年代的比例调整或完全添加更多堆空间来实现。

  • 更频繁地运行后台线程。

  • 使用更多后台线程。

如果有更多内存可用,更好的解决方案是增加堆的大小。否则,更改后台线程的操作方式。

更频繁地运行后台线程

让 CMS 赢得竞争的一种方法是更早地启动并发周期。如果并发周期在老年代填充了 60% 时开始,CMS 完成的机会就比在老年代填充了 70% 时开始的机会更大。实现这一点的最简单方法是设置这两个标志:

  • -XX:CMSInitiatingOccupancyFraction=N

  • -XX:+UseCMSInitiatingOccupancyOnly

同时使用这两个标志也使 CMS 更容易理解:如果两者都设置了,CMS 仅根据填充的老年代的百分比确定何时启动后台线程。(请注意,与 G1 GC 不同,在这里,占用比率仅为老年代,而不是整个堆。)

默认情况下,UseCMSInitiatingOccupancyOnly 标志为 false,CMS 使用更复杂的算法来确定何时启动后台线程。如果需要更早地启动后台线程,则最好以最简单的方式启动它,并将 UseCMSInitiatingOccupancyOnly 标志设置为 true

调整 CMSInitiatingOccupancyFraction 的值可能需要几次迭代。如果启用了 UseCMSInitiatingOccupancyOnly,则 CMSInitiatingOccupancyFraction 的默认值为 70:当老年代占用率为 70% 时,CMS 循环启动。

对于给定应用程序来说,该标志的更好值可以通过在 GC 日志中找到 CMS 循环失败开始的时间来确定。在日志中查找并发模式失败,然后回溯到最近的 CMS 循环开始的时间。CMS-initial-mark 行将显示 CMS 循环开始时老年代的填充程度:

89.976: [GC [1 CMS-initial-mark: 702254K(1398144K)]
		772530K(2027264K), 0.0830120 secs]
		[Times: user=0.08 sys=0.00, real=0.08 secs]

在此示例中,这大约为 50%(1,398 MB 中的 702 MB)。这还不够早,因此 CMSInitiatingOccupancyFraction 需要设置为低于 50 的值。(尽管该标志的默认值为 70,但此示例在老年代填充了 50% 时启动了 CMS 线程,因为未设置 UseCMSInitiatingOccupancyOnly 标志。)

这里的诱惑是将值设置为 0 或另一个较小的数字,以便后台 CMS 循环始终运行。通常不鼓励这样做,但只要您意识到正在做出的权衡,它可能会很好地解决问题。

第一个权衡出现在 CPU 时间上:CMS 后台线程将持续运行,并且它们会消耗相当多的 CPU —— 每个后台 CMS 线程将消耗一个 CPU 的 100%。当多个 CMS 线程运行并且作为结果总 CPU 占用率急剧上升时,也会有非常短暂的爆发。如果这些线程是不必要地运行,则会浪费 CPU 资源。

另一方面,使用这些 CPU 循环并不一定是个问题。即使在最佳情况下,后台 CMS 线程有时也必须运行。因此,机器必须始终有足够的 CPU 循环可用于运行这些 CMS 线程。因此,在确定机器大小时,您必须计划 CPU 的使用情况。

第二个折中方案更为重要,与暂停有关。正如 GC 日志所示,CMS 周期的某些阶段会停止所有应用线程。CMS 被使用的主要原因是为了限制 GC 暂停的影响,因此比需要的更频繁地运行 CMS 是得不偿失的。CMS 暂停通常比年轻代暂停短得多,特定应用程序可能不会对这些额外的暂停敏感——这是额外暂停与减少并发模式失败机会之间的折中。但是持续运行后台 GC 暂停可能会导致过度的总体暂停,最终会降低应用程序的性能。

除非那些折中方案是可以接受的,否则要注意不要将CMSInitiatingOccupancyFraction设置得比堆中的活动数据量高,至少要高出 10%到 20%。

调整 CMS 后台线程

每个 CMS 后台线程将在机器上占用 100% 的 CPU。如果应用程序遇到并发模式失败并且有额外的 CPU 周期可用,则可以通过设置 -XX:ConcGCThreads=N 标志来增加这些后台线程的数量。CMS 与 G1 GC 设置此标志的方式不同;它使用以下计算:

ConcGCThreads = (3 + ParallelGCThreads) / 4

因此,CMS 在比 G1 GC 更早的阶段增加了ConcGCThreads的值。

快速摘要

  • 避免并发模式失败是实现 CMS 最佳性能的关键。

  • 避免这些失败的最简单方法(如果可能的话)是增加堆的大小。

  • 否则,下一步是通过调整CMSInitiatingOccupancy​Frac⁠tion来更早地启动并发后台线程。

  • 调整后台线程的数量也可能有所帮助。

高级调整

关于调整的这一部分涵盖了一些相当不寻常的情况。尽管不经常遇到这些情况,但是本节解释了 GC 算法的许多底层细节。

续寿和幸存者空间

当年轻代被收集时,一些对象仍然存活。这不仅包括了那些注定会存在很长时间的新创建对象,还包括了其他短暂存在的对象。考虑一下在第五章中的BigDecimal计算的循环。如果 JVM 在该循环中间执行 GC,那么其中一些短命的 BigDecimal 对象就会不幸:它们刚刚被创建并且正在使用中,因此无法释放,但它们也不会存活足够长的时间以证明将它们移到老年代是合理的。

这就是为什么年轻代被分为两个幸存者空间和伊甸园的原因。这种设置允许对象在仍然在年轻代时有额外的机会被收集,而不是被提升到(并填满)老年代。

当年轻一代对象被收集时,JVM 发现仍然存活的对象,将其移动到幸存者空间而不是老年代。在第一次年轻一代收集期间,对象从伊甸园移动到幸存者空间 0。在下一次收集期间,活动对象从幸存者空间 0 和伊甸园移动到幸存者空间 1。此时,伊甸园和幸存者空间 0 完全为空。下一次收集将活动对象从幸存者空间 1 和伊甸园移动到幸存者空间 0,依此类推。(幸存者空间也被称为tofrom空间;在每次收集期间,对象从from空间移动到to空间。fromto只是在每次收集期间在两个幸存者空间之间切换的指针。)

显然,这种情况不能永远持续,否则就不会将任何对象移入老年代。对象在两种情况下被移入老年代。首先,幸存者空间相对较小。当在年轻代收集期间目标幸存者空间填满时,伊甸园中剩余的活动对象直接移入老年代。其次,对象在幸存者空间中可以保留的 GC 周期数量存在限制。该限制称为tenuring threshold

调优可能会影响到这些情况之一。幸存者空间占用年轻代分配的一部分,并且像堆的其他区域一样,JVM 动态调整它们的大小。幸存者空间的初始大小由-XX:InitialSurvivorRatio=N 标志决定,该标志在以下方程式中使用:

survivor_space_size = new_size / (initial_survivor_ratio + 2)

对于默认的初始幸存者比率为 8,每个幸存者空间将占年轻代的 10%。

JVM 可能会将幸存者空间的大小增加到由-XX:MinSurvivorRatio=N 标志设置的最大值。该标志在以下方程式中使用:

maximum_survivor_space_size = new_size / (min_survivor_ratio + 2)

默认情况下,此值为 3,这意味着幸存者空间的最大大小将为年轻代的 20%。请再次注意,该值是一个比率,因此比率的最小值给出了幸存者空间的最大大小。因此,名称有些反直觉。

要保持幸存者空间的固定大小,将SurvivorRatio设置为所需值,并禁用UseAdaptiveSizePolicy标志(尽管请记住,禁用自适应大小将同时应用于老年代和新生代)。

JVM 根据 GC 后幸存者空间的填充情况(遵循定义的比率)决定是否增加或减少幸存者空间的大小。幸存者空间将被调整大小,以便在 GC 后,默认情况下填充至 50%。可以使用-XX:TargetSurvivorRatio=N 标志来更改该值。

最后,还有一个问题是对象在在幸存者空间之间来回移动多少次后被移至老年代。这个答案由晋升阈值确定。JVM 不断计算它认为最佳的晋升阈值。阈值从由-XX:InitialTenuringThreshold=*N标志指定的值开始(对于吞吐量和 G1 GC 收集器,默认值为 7,对于 CMS 为 6)。最终 JVM 将确定一个介于 1 和由-XX:MaxTenuringThreshold=N*标志指定的值之间的阈值;对于吞吐量和 G1 GC 收集器,默认的最大阈值为 15,对于 CMS 为 6。

综合考虑所有因素,在什么情况下可能调整哪些值?查看晋升统计信息很有帮助;这些信息在我们迄今为止使用的 GC 日志命令中没有打印出来。

在 JDK 8 中,可以通过包含标志-XX:+PrintTenuringDistribution(默认为false)将晋升分布添加到 GC 日志中。在 JDK 11 中,可通过在Xlog参数中包含age*=debugage*=trace来添加。

最重要的是要查看幸存者空间是否太小,以至于在小型 GC 过程中,对象直接从 Eden 区晋升到老年代。要避免这种情况的原因是短寿命对象将填满老年代,导致频繁发生 Full GC。

在使用吞吐量收集器记录的 GC 日志中,该条件的唯一提示是这一行:

Desired survivor size 39059456 bytes, new threshold 1 (max 15)
	 [PSYoungGen: 657856K->35712K(660864K)]
	 1659879K->1073807K(2059008K), 0.0950040 secs]
	 [Times: user=0.32 sys=0.00, real=0.09 secs]

使用age*=debug的 JDK 11 日志类似;在收集过程中,它会打印出所需的幸存者大小。

这里单个幸存者空间的期望大小为 39 MB,而年轻代大小为 660 MB:JVM 计算出两个幸存者空间应占年轻代的约 11%。但一个悬而未决的问题是是否这个大小足以防止溢出。该日志并没有提供明确的答案,但 JVM 调整了晋升阈值至 1 表明它已经确定大多数对象直接晋升到老年代,因此最小化了晋升阈值。这个应用可能直接将对象晋升到老年代而不充分利用幸存者空间。

当使用 G1 GC 时,在 JDK 8 日志中可获得更详细的输出:

 Desired survivor size 35782656 bytes, new threshold 2 (max 6)
 - age   1:   33291392 bytes,   33291392 total
 - age   2:    4098176 bytes,   37389568 total

在 JDK 11 中,包含age*=trace在日志配置中即可获得该信息。

期望的幸存者空间与之前的示例相似——35 MB——但输出还显示了幸存者空间中所有对象的大小。有 37 MB 的数据需要晋升,幸存者空间确实溢出了。

是否可以改善这种情况取决于应用程序。如果对象的生存时间长于几个 GC 周期,它们最终会进入老年代,因此调整幸存者空间和保留阈值不会真正有所帮助。但是,如果对象在几个 GC 周期后就会消失,通过使幸存者空间更有效地安排可以获得一些性能。

如果增加了幸存者空间的大小(通过减少幸存比率),则会从年轻代的伊甸园部分中腾出内存。这正是对象实际分配的地方,意味着在进行小型 GC 之前可以分配的对象更少。因此,通常不建议选择这个选项。

另一个可能性是增加年轻代的大小。在这种情况下可能适得其反:对象可能较少地晋升到老年代,但由于老年代较小,应用程序可能更频繁进行完全 GC。

如果可以增加堆的大小,无论是年轻代还是幸存者空间都可以获得更多内存,这将是最佳解决方案。一个好的过程是增加堆大小(或者至少是年轻代大小),并减少幸存比率。这将增加幸存者空间的大小,而不是增加伊甸园的大小。应用程序最终应该有与之前大致相同数量的年轻代收集。但是,应该有更少的完全 GC,因为假设对象在更多 GC 周期后将不再存活。

如果调整了幸存者空间的大小,使其永远不会溢出,那么只有在达到MaxTenuringThreshold后对象才会被晋升到老年代。可以增加该值以使对象在更多年轻代 GC 周期后仍在幸存者空间中。但要注意,如果增加了保留阈值并且对象在幸存者空间中停留时间更长,那么在未来的年轻代收集期间,幸存者空间可能会溢出并重新开始直接晋升到老年代。

快速总结

  • 幸存者空间设计用于允许对象(特别是刚分配的对象)在年轻代中存留几个 GC 周期。这增加了对象在被晋升到老年代之前被释放的概率。

  • 如果幸存者空间太小,对象将直接晋升到老年代,从而导致更多的老年代 GC 周期。

  • 处理这种情况的最佳方法是增加堆的大小(或者至少增加年轻代),并允许 JVM 处理幸存者空间。

  • 在罕见情况下,调整保留阈值或幸存者空间大小可以防止对象晋升到老年代。

分配大对象

本节详细描述了 JVM 如何分配对象。这是有趣的背景信息,对于频繁创建大量大对象的应用程序是重要的。在这个上下文中,是一个相对的术语;它取决于 JVM 内某种类型缓冲区的大小。

这个缓冲区被称为线程本地分配缓冲区(TLAB)。对于所有的 GC 算法,TLAB 的大小都是一个考虑因素,而 G1 GC 还要考虑非常大的对象(再次强调,这是一个相对的术语——对于一个 2 GB 的堆来说,大于 512 MB 的对象就算是非常大的)。非常大的对象对 G1 GC 的影响可能很重要——在使用任何收集器时,TLAB 的大小调整是相当不寻常的,但是对于使用 G1 时的 GC 区域大小调整则更为常见。

线程本地分配缓冲区

第五章 讨论了对象在 eden 区内的分配方式;这样可以实现更快的分配(特别是对于那些很快被丢弃的对象)。

在 eden 区分配如此迅速的一个原因是每个线程都有一个专用的区域来分配对象——即线程本地分配缓冲区,或者称为 TLAB(Thread-Local Allocation Buffer)。当对象直接分配在共享空间,比如 eden 区时,需要一些同步来管理该空间内的空闲指针。通过为每个线程设置其专用的分配区域,线程在分配对象时无需执行任何同步操作。³

通常情况下,开发人员和最终用户对 TLAB 的使用是透明的:TLAB 默认是启用的,JVM 管理它们的大小和使用方式。关于 TLAB 的重要一点是它们的大小很小,因此无法在 TLAB 中分配大对象。大对象必须直接从堆中分配,这会因为同步而额外消耗时间。

当一个 TLAB 变满时,某个大小的对象就无法再在其中分配了。在这种情况下,JVM 有两种选择。一种选择是“退休”该 TLAB 并为该线程分配一个新的 TLAB。由于 TLAB 只是 eden 区内的一个部分,在下一次年轻代收集时,退休的 TLAB 将被清理,并且可以随后重新使用。或者 JVM 可以直接在堆上分配对象,并保留现有的 TLAB(至少直到线程再次向 TLAB 分配其他对象)。假设一个 TLAB 是 100 KB,已经分配了 75 KB。如果需要新分配 30 KB 的对象,那么可以退休该 TLAB,浪费 25 KB 的 eden 空间。或者可以直接从堆上分配 30 KB 的对象,并且希望下一个分配的对象能够适应 TLAB 中仍然空余的 25 KB 空间。

参数可以控制这一点(如本节后面讨论的),但关键在于 TLAB 的大小。默认情况下,TLAB 的大小基于三个因素:应用程序中的线程数、Eden 的大小和线程的分配速率。

因此,两种类型的应用程序可能会从调整 TLAB 参数中受益:分配大量大对象的应用程序以及与 Eden 大小相比具有相对较大数量线程的应用程序。默认情况下,TLAB 是启用的;可以通过指定-XX:-UseTLAB来禁用它们,尽管它们提供了显著的性能提升,但禁用它们始终是一个不好的主意。

由于 TLAB 大小的计算部分基于线程的分配速率,因此无法明确预测应用程序的最佳 TLAB 大小。相反,我们可以监视 TLAB 分配,以查看是否有任何分配发生在 TLAB 之外。如果有大量的分配发生在 TLAB 之外,我们有两个选择:减少分配的对象大小或调整 TLAB 大小参数。

监视 TLAB 分配是另一个案例,Java Flight Recorder 比其他工具更强大。图 6-9 显示了来自 JFR 记录的 TLAB 分配屏幕的样本。

jp2e 0609

图 6-9. Java Flight Recorder 中的 TLAB 视图

在此记录中选择的 5 秒钟内,有 49 个对象在 TLAB 之外分配;这些对象的最大大小为 48 字节。由于最小的 TLAB 大小为 1.35 MB,我们知道这些对象之所以分配到堆上,是因为在分配时 TLAB 已经满了:它们并非因为大小而直接在堆上分配。这在年轻代 GC 发生之前是典型的情况(因为 Eden 和因此从 Eden 中划分出的 TLAB 变满了)。

在此期间的总分配为 1.59 KB;在这个例子中,分配的数量和大小都不是令人担忧的原因。一些对象总会分配在 TLAB 之外,特别是当 Eden 接近年轻代集合时。与图 6-10 相比,该示例显示大量分配发生在 TLAB 之外。

jp2e 0610

图 6-10. TLAB 之外的过度分配发生

在本次记录中,在 TLAB 内分配的总内存为 952.96 MB,在 TLAB 外分配的对象总内存为 568.32 MB。这是一个情况,可以通过更改应用程序以使用较小的对象或调整 JVM 以在更大的 TLAB 中分配这些对象来产生有益影响。请注意,其他选项卡可以显示分配到 TLAB 之外的实际对象;我们甚至可以安排获取分配这些对象时的堆栈信息。如果 TLAB 分配存在问题,JFR 将快速指出。

在 JFR 之外,查看 TLAB 分配的最佳方法是在 JDK 8 的命令行中添加-XX:+PrintTLAB标志或在 JDK 11 的日志配置中包含tlab*=trace(此配置提供以下信息及更多)。然后,在每次 young collection 时,GC 日志将包含两种类型的行:描述每个线程的 TLAB 使用情况的行,以及描述 JVM 整体 TLAB 使用情况的摘要行。

每个线程的行看起来像这样:

TLAB: gc thread: 0x00007f3c10b8f800 [id: 18519] desired_size: 221KB
    slow allocs: 8  refill waste: 3536B alloc: 0.01613    11058KB
    refills: 73 waste  0.1% gc: 10368B slow: 2112B fast: 0B

此输出中的gc表示该行在 GC 期间打印;线程本身是一个常规应用程序线程。该线程的 TLAB 大小为 221 KB。自上次 young collection 以来,它从堆中分配了八个对象(slow allocs);这占此线程分配总量的 1.6%(0.01613),总量为 11,058 KB。TLAB 中“浪费”的 0.1%来自三个方面:当前 GC 周期开始时,TLAB 中有 10,336 字节空闲;其他(已退休)TLAB 中有 2,112 字节空闲;通过特殊的“快速”分配器分配的字节为 0。

在每个线程的 TLAB 数据打印完成后,JVM 会提供一行摘要数据(在 JDK 11 中通过配置tlab*=debug日志提供此数据):

TLAB totals: thrds: 66  refills: 3234 max: 105
        slow allocs: 406 max 14 waste:  1.1% gc: 7519856B
        max: 211464B slow: 120016B max: 4808B fast: 0B max: 0B

在此案例中,自上次 young collection 以来,有 66 个线程执行了某种形式的分配。在这些线程中,它们重新填充了其 TLAB 3,234 次;任何特定线程重新填充其 TLAB 的最大次数为 105 次。总体而言,堆中进行了 406 次分配(由一个线程最多进行了 14 次),TLAB 中的 1.1%由已退休 TLAB 中的空闲空间浪费掉。

在每个线程的数据中,如果线程显示出 TLAB 外的许多分配,请考虑调整它们的大小。

TLAB 大小调整

那些在 TLAB 外分配大量对象的应用程序将受益于可以将分配移至 TLAB 的更改。如果只有少数特定对象类型始终在 TLAB 外分配,则程序更改是最佳解决方案。

否则——或者无法进行程序更改——您可以尝试调整 TLAB 大小以适应应用程序使用情况。由于 TLAB 大小基于 eden 的大小,调整新的大小参数将自动增加 TLAB 的大小。

可以使用标志-XX:TLABSize=*N*来显式设置 TLAB 的大小(默认值 0 表示使用先前描述的动态计算)。该标志仅设置 TLAB 的初始大小;为了防止在每次 GC 时调整大小,请添加-XX:-ResizeTLAB(该标志的默认值为true)。这是探索调整 TLAB 性能的最简单(实际上是唯一有用的)选项。

当一个新对象不适合当前的 TLAB(但适合一个新的空 TLAB)时,JVM 需要做出决定:是在堆中分配对象,还是淘汰当前的 TLAB 并分配一个新的 TLAB。该决定基于几个参数。在 TLAB 的日志输出中,refill waste值给出了该决策的当前阈值:如果 TLAB 无法容纳比该值大的新对象,则该新对象将在堆中分配。如果所讨论的对象小于该值,则 TLAB 将被淘汰。

这个值是动态的,但默认从 TLAB 大小的 1%开始——具体来说,从-XX:TLABWasteTargetPercent=*N指定的值开始。每次在堆外进行分配时,这个值会增加-XX:TLABWasteIncrement=N*的值(默认为 4)。这样可以防止线程在 TLAB 中达到阈值并持续在堆中分配对象:随着目标百分比的增加,TLAB 被淘汰的机会也增加。调整TLABWasteTargetPercent值还会调整 TLAB 的大小,因此虽然可以调整此值,但其影响并不总是可预测。

最后,当 TLAB 调整大小生效时,可以使用-XX:MinTLABSize=*N*指定 TLAB 的最小大小(默认为 2 KB)。TLAB 的最大大小略小于 1 GB(可以由整数数组占用的最大空间,按照对象对齐目的四舍五入),并且不能更改。

快速总结

  • 分配大量大对象的应用程序可能需要调整 TLAB(尽管通常在应用程序中使用较小的对象是更好的方法)。

巨大对象

在可能的情况下,分配在 TLAB 外的对象仍然在伊甸园内分配。如果对象无法适应伊甸园,则必须直接在老年代中分配。这样会阻止该对象的正常 GC 生命周期,因此如果对象的生命周期很短,则 GC 会受到负面影响。在这种情况下,除了改变应用程序以避免需要这些短寿命的大对象外,几乎无能为力。

然而,在 G1 GC 中,巨大对象的处理方式不同:如果它们大于一个 G1 区域,则 G1 会将它们分配到老年代。因此,在 G1 GC 中使用大量巨大对象的应用程序可能需要特殊调整来补偿这一点。

G1 GC 区域大小

G1 GC 将堆分成具有固定大小的区域。区域大小不是动态的;它在启动时根据堆的最小大小(即Xms的值)确定。最小区域大小为 1 MB。如果最小堆大小大于 2 GB,则根据以下公式设置区域的大小(使用对数 2 为底):

region_size = 1 << log(Initial Heap Size / 2048);

简而言之,区域大小是最小的 2 的幂,使得初始堆大小被划分时接近 2048 个区域。这里也使用了一些最小和最大约束条件;区域大小始终至少为 1 MB,从不超过 32 MB。表 6-3 总结了所有可能性。

表 6-3。默认 G1 区域大小

堆大小默认 G1 区域大小
少于 4 GB1 MB
4 GB 和 8 GB 之间2 MB
8 GB 和 16 GB 之间4 MB
16 GB 和 32 GB 之间8 MB
32 GB 和 64 GB 之间16 MB
大于 64 GB32 MB

可以使用-XX:G1HeapRegionSize=*N*标志设置 G1 区域的大小(默认值名义上为 0,表示使用刚刚描述的动态值)。此处给定的值应为 2 的幂(例如,1 MB 或 2 MB);否则,它将被舍入为最接近的 2 的幂。

G1 GC 分配巨大对象

如果 G1 GC 的区域大小为 1 MB,并且程序分配了一个 200 万字节的数组,那么该数组将无法放入单个 G1 GC 区域中。但是这些巨大的对象必须在连续的 G1 GC 区域中分配。如果 G1 GC 的区域大小为 1 MB,那么要分配一个 3.1 MB 的数组,G1 GC 必须在老年代中找到四个区域来分配该数组。(最后一个区域的其余部分将保持空闲,浪费 0.9 MB 的空间。)这会破坏 G1 GC 通常执行压缩的方式,即根据它们的填充程度释放任意区域。

实际上,G1 GC 将巨大对象定义为区域大小的一半,因此在这种情况下,分配 512 KB(加上 1 字节)的数组将触发我们正在讨论的巨大分配。

因为巨大对象直接分配在老年代中,所以在年轻代收集期间无法释放它。因此,如果对象的生存期较短,这也会破坏收集器的分代设计。巨大对象将在并发 G1 GC 周期中被收集。好的一面是,巨大对象可以快速释放,因为它是它所占用的区域中唯一的对象。巨大对象在并发周期的清理阶段被释放(而不是在混合 GC 期间)。

增加 G1 GC 区域的大小,以便程序将分配的所有对象都适合单个 G1 GC 区域,可以使 G1 GC 更高效。这意味着将 G1 区域大小设置为最大对象大小的两倍加 1 字节。

在 JDK 8u60 中对 G1 GC 的改进(以及所有 JDK 11 版本中)最小化了这个问题,因此它不再是它曾经经常是的关键问题。

快速总结

  • G1 区域的大小是以 2 的幂为单位的,从 1 MB 开始。

  • 堆大小与初始大小非常不同的堆将具有太多的 G1 区域;在这种情况下,应增加 G1 区域大小。

  • 应用程序分配大于 G1 区域一半大小的对象时,应增加 G1 区域的大小,以便对象可以适应 G1 区域。为了适应这一点,应用程序必须分配至少 512 KB 的对象(因为最小的 G1 区域是 1 MB)。

AggressiveHeap

AggressiveHeap标志(默认为false)最早在 Java 的早期版本中引入,旨在简化设置各种命令行参数——这些参数适用于运行单个 JVM 的内存很大的机器。尽管该标志自那些版本以来一直存在,但现在不再推荐使用(尽管官方尚未弃用)。

该标志的问题在于它隐藏了采用的实际调优设置,使得很难确定 JVM 正在设置的内容。现在,它设置的一些值是根据更好的关于运行 JVM 的机器的信息以及人体工程学的原则设置的,因此在某些情况下启用此标志会损害性能。我经常看到的命令行中包含此标志,然后稍后会覆盖它设置的值。(顺便说一句,这样做是有效的:命令行中后面的值目前会覆盖前面的值。这种行为没有得到保证。)

表 6-4 列出了启用AggressiveHeap标志时自动设置的所有调优项。

表 6-4. 使用AggressiveHeap启用的调优设置

标志
Xmx半数内存或整体内存的最小值:160 MB
XmsXmx相同
NewSize设定为Xmx的 3/8
UseLargePagestrue
ResizeTLABfalse
TLABSize256 KB
UseParallelGCtrue
ParallelGCThreads与当前默认相同
YoungPLABSize256 KB(默认为 4 KB)
OldPLABSize8 KB (默认为 1 KB)
CompilationPolicyChoice0(当前默认)
ThresholdTolerance100 (default is 10)
ScavengeBeforeFullGCfalse(默认为true
BindGCTaskThreadsToCPUstrue(默认为false

这些最后六个标志足够晦涩,以至于我在本书的其他地方没有讨论过它们。简要来说,它们涵盖了以下几个领域:

PLAB 大小调整

PLABspromotion-local allocation buffers—这些是在 GC 中清理世代时每个线程使用的区域。每个线程可以晋升到特定的 PLAB,从而避免了同步的需要(类似于 TLAB 的工作方式)。

编译策略

JVM 随附了备用的 JIT 编译算法。当前默认算法曾在某个时期略显实验性,但现在已成为推荐政策。

禁用 young GC 在 full GC 之前

ScavengeBeforeFullGC设置为false意味着当发生全 GC 时,JVM 将不会在全 GC 前执行年轻代 GC。通常这是件坏事,因为这意味着年轻代中的垃圾对象(可进行回收)可能会阻止老年代对象的回收。显然,曾经有段时间这个设置是有意义的(至少对于某些基准测试来说),但一般建议是不要更改这个标志。

将 GC 线程绑定到 CPU

设置列表中的最后一个标志意味着每个并行 GC 线程都绑定到特定的 CPU(使用特定于操作系统的调用)。在有限的情况下——当 GC 线程是机器上唯一运行的东西,并且堆非常大时——这是有意义的。在一般情况下,最好让 GC 线程可以在任何可用的 CPU 上运行。

和所有的调优一样,你的效果可能有所不同,如果你仔细测试了AggressiveHeap标志并发现它能提升性能,那么尽管使用它。只需注意它在幕后所做的事情,并意识到每次 JVM 升级时,这个标志的相对优势都需要重新评估。

快速总结

  • AggressiveHeap标志是一个旧版尝试,旨在将堆参数设置为适合单个在非常大的机器上运行的 JVM 的值。

  • 由该标志设置的值不会随着 JVM 技术的进步而调整,因此从长远来看其实用性是可疑的(尽管它仍然经常被使用)。

完全控制堆大小

“堆大小的设置”讨论了堆的初始最小和最大大小的默认值。这些值取决于机器上的内存量以及使用的 JVM,并且在那里呈现的数据有许多特例情况。如果你对默认堆大小是如何计算的完整细节感兴趣,这一节会详细解释。这些细节包括低级调优标志;在某些情况下,调整这些计算方法可能比简单设置堆大小更为方便。例如,如果你想要运行多个具有共同(但调整过的)舒适性堆大小的 JVM,那么这可能是个例外。在大多数情况下,这一节的真正目的是完整解释这些默认值是如何选择的。

默认大小基于机器上的内存量,可以使用-XX:MaxRAM=*N*标志设置。通常情况下,JVM 通过检查机器上的内存量来计算该值。然而,JVM 将MaxRAM限制为 32 位 Windows 服务器的 4 GB 和 64 位 JVM 的 128 GB。最大堆大小是MaxRAM的四分之一。这就是为什么默认堆大小会有所不同:如果机器上的物理内存少于MaxRAM,则默认堆大小是其四分之一。但即使有数百 GB 的 RAM 可用,JVM 默认也只会使用 32 GB:128 GB 的四分之一。

实际上,默认的最大堆大小计算如下:

Default Xmx = MaxRAM / MaxRAMFraction

因此,默认的最大堆大小也可以通过调整 -XX:MaxRAMFraction=N 标志的值来设置,默认为 4。最后,为了保持事情的有趣性,-XX:ErgoHeapSizeLimit=N 标志也可以设置为 JVM 应该使用的最大默认值。默认情况下该值为 0(表示忽略);否则,如果它小于 MaxRAM / MaxRAMFraction,则使用该限制。

另一方面,在物理内存非常少的机器上,JVM 希望确保留足够的内存给操作系统。这就是为什么在只有 192 MB 内存的机器上,JVM 将最大堆限制为 96 MB 或更少的原因。这个计算基于 -XX:MinRAMFraction=N 标志的值,默认为 2:

if ((96 MB * MinRAMFraction) > Physical Memory) {
    Default Xmx = Physical Memory / MinRAMFraction;
}

初始堆大小的选择类似,尽管复杂度较低。初始堆大小的值确定如下:

Default Xms =  MaxRAM / InitialRAMFraction

从默认最小堆大小可以得出结论,InitialRAMFraction 标志的默认值为 64。这里的一个注意事项是,如果该值小于 5 MB——或者严格来说小于 -XX:OldSize=N(默认为 4 MB)加上 -XX:NewSize=N(默认为 1 MB)所指定的值——那么老年代和新生代大小的总和将用作初始堆大小。

快速总结

  • 默认的初始堆大小和最大堆大小的计算在大多数计算机上都很简单。

  • 在边缘处,这些计算可能会相当复杂。

实验性 GC 算法

在具有多个 CPU 的 JDK 8 和 JDK 11 生产 VM 中,您将根据应用程序的要求使用 G1 GC 或吞吐量收集器。在小型机器上,如果适合您的硬件,则将使用串行收集器。这些是支持生产的收集器。

JDK 12 引入了新的收集器。虽然这些收集器不一定是生产就绪的,但我们会为实验目的来一窥一下它们。

并发压缩:ZGC 和 Shenandoah

现有的并发收集器并非完全并发。无论是 G1 GC 还是 CMS 都没有对年轻代进行并发收集:释放年轻代需要停止所有应用线程。而且这些收集器都不进行并发压缩。在 G1 GC 中,老年代在混合 GC 循环中被压缩:在目标区域内,未释放的对象会被压缩到空白区域中。在 CMS 中,老年代在碎片化严重时进行压缩,以便允许新的分配。年轻代的收集也会通过将存活对象移动到幸存者空间或老年代来压缩堆的这部分。

在压缩期间,对象会在内存中移动它们的位置。这是 JVM 在此操作期间停止所有应用程序线程的主要原因——如果已知应用程序线程已停止,那么更新内存引用的算法将简单得多。因此,应用程序的暂停时间主要由移动对象和确保对它们的引用是最新的时间所主导。

为了解决这个问题,设计了两个实验性的收集器。第一个是 Z 垃圾收集器,或 ZGC;第二个是 Shenandoah 垃圾收集器。ZGC 首次出现在 JDK 11 中;Shenandoah GC 首次出现在 JDK 12 中,但现已被回溯到 JDK 8 和 JDK 11。来自 AdoptOpenJDK 的 JVM 构建(或者您自己从源代码编译的构建)包含这两个收集器;来自 Oracle 的构建仅包含 ZGC。

要使用这些收集器,必须指定-XX:+UnlockExperimentalVMOptions标志(默认情况下为false)。然后,您可以使用-XX:+UseZGC-XX:+UseShenandoahGC替换其他 GC 算法。像其他 GC 算法一样,它们有几个调优选项,但由于算法仍在开发中,所以我们将使用默认参数运行。 (并且这两个收集器都旨在以最少的调整运行。)

尽管它们采取了不同的方法,但两个收集器都允许堆的并发压缩,这意味着堆中的对象可以在不停止所有应用程序线程的情况下移动。这主要有两个影响。

首先,堆不再是分代的(即不再有年轻代和老年代;只有一个单一的堆)。年轻代背后的想法是,收集小部分堆比整个堆更快,并且其中许多(理想情况下大多数)对象将是垃圾。因此,年轻代允许在大部分时间内减少暂停时间。如果应用程序线程在收集期间不需要暂停,那么对于年轻代的需求就消失了,因此这些算法不再需要将堆分段。

第二个影响是可以预期应用程序线程执行的操作的延迟会降低(至少在许多情况下是这样)。考虑一个通常在 200 毫秒内执行的 REST 调用;如果该调用由于 G1 GC 中的年轻代收集而被中断,并且该收集花费了 500 毫秒,那么用户将看到该 REST 调用花费了 700 毫秒。当然,大多数调用不会遇到这种情况,但某些调用会,而这些异常情况将影响系统的整体性能。在不需要停止应用程序线程的情况下,进行并发压缩的收集器不会遇到这些异常情况。

这在某种程度上简化了情况。回顾一下关于 G1 GC 的讨论,堆区域中标记自由对象的后台线程有时会有短暂的暂停。因此,G1 GC 有三种类型的暂停:相对较长的完全 GC 暂停(理想情况下,您已经调整得足够好,不会发生这种情况),用于年轻代 GC 收集的较短暂停(包括释放和压缩部分老年代的混合收集),以及用于标记线程的非常短暂的暂停。

ZGC 和 Shenandoah 都有类似的暂停,属于后者的类别;在短时间内,所有应用程序线程都会停止。这些收集器的目标是保持这些时间非常短,大约在 10 毫秒的量级。

这些收集器还会在单个线程操作上引入延迟。具体细节因算法而异,但简而言之,应用程序线程访问对象时受到屏障的保护。如果对象恰好正在移动过程中,应用程序线程将在屏障处等待,直到移动完成。(同样,如果应用程序线程正在访问对象,则垃圾收集线程必须在屏障处等待,直到可以重新定位对象。)实际上,这是对对象引用的一种锁定形式,但这个术语使这个过程看起来比实际情况更为重型。总体而言,这对应用程序的吞吐量影响很小。

并发压缩的延迟影响

要了解这些算法的整体影响,可以考虑一下表 6-5 中的数据。该表显示了处理固定负载 500 OPS 的 REST 服务器使用各种收集器时的响应时间。这里的操作非常快速;它只是分配并保存一个相当大的字节数组(用一个已保存的数组替换,以保持内存压力恒定)。

表 6-5. 并发压缩收集器的延迟影响

收集器平均时间90th%时间99th%时间最大时间
吞吐量 GC13 ms60 ms160 ms265 ms
G1 GC5 ms10 ms35 ms87 ms
ZGC1 ms5 ms5 ms20 ms
Shenandoah GC1 ms5 ms5 ms22 ms

这些结果正是我们从各种收集器中所期望的。吞吐量收集器的完全 GC 时间导致最大响应时间为 265 毫秒,并且有很多超过 50 毫秒响应时间的异常情况。使用 G1 GC 后,这些完全 GC 时间已经消失,但仍然存在较短的时间用于年轻代收集,导致最大时间为 87 毫秒,约 10 毫秒的异常情况。并且使用并发收集器后,这些年轻代收集暂停也消失了,因此最大时间现在约为 20 毫秒,异常情况只有 5 毫秒。

一个警告:垃圾收集暂停传统上一直是像我们这里讨论的延迟异常的最大贡献者。但是还存在其他原因:服务器和客户端之间的临时网络拥塞,操作系统调度延迟等等。因此,虽然前两种情况中很多异常情况是由并发收集器仍然存在的那几毫秒的短暂暂停造成的,但我们现在正在进入那些其他因素也对总延迟有很大影响的领域。

并发紧凑收集器的吞吐效果

这些收集器的吞吐效果更难以分类。与 G1 GC 一样,这些收集器依赖于后台线程来扫描和处理堆。因此,如果这些线程没有足够的 CPU 周期可用,收集器将经历与之前看到的并发故障相同的情况,并最终进行完整的 GC。并发紧凑收集器通常比 G1 GC 后台线程使用更多的后台处理。

另一方面,如果后台线程有足够的 CPU 可用,使用这些收集器时的吞吐量将高于 G1 GC 或吞吐量收集器的吞吐量。这再次符合你在 第 5 章 中看到的情况。该章节的示例显示,当 G1 GC 将 GC 处理转移到后台线程时,其吞吐量可能高于吞吐量收集器。并发紧凑收集器与吞吐量收集器相比具有相同的优势,而与 G1 GC 相比则有类似(但更小)的优势。

无收集:Epsilon GC

JDK 11 中还包含一个什么都不做的收集器:epsilon collector。当你使用这个收集器时,对象从堆中永远不会被释放,当堆填满时,你将会得到内存溢出错误。

传统的程序当然不能使用这个收集器。它真正设计用于内部 JDK 测试,但在两种情况下可能会有用:

  • 非常短命的程序

  • 程序要精心编写以重用内存并永远不执行新分配

第二种情况在一些内存有限的嵌入式环境中很有用。这种编程是专业的,我们在这里不考虑它。但第一种情况具有有趣的可能性。

考虑一个程序的情况,它分配了一个包含 4,096 个元素的数组列表,每个元素都是一个 0.5 MB 的字节数组。使用各种收集器运行该程序的时间在 表 6-6 中显示。本示例中使用默认的 GC 调优。

表 6-6. 基于小型分配的程序的性能指标

收集器时间所需堆
吞吐量 GC2.3 s3,072 MB
G1 GC3.24 s4,096 MB
Epsilon1.6 s2,052 MB

禁用垃圾收集在这种情况下有显著优势,可提高 30% 的性能。而其他收集器需要显著的内存开销:就像我们见过的其他实验性收集器一样,ε 收集器不是分代的(因为对象无法释放,所以不需要设置一个单独的空间来能够快速释放它们)。因此,对于这个生成大约 2 GB 对象的测试,ε 收集器所需的总堆空间略高于这个值;我们可以使用 -Xmx2052m 运行这种情况。吞吐量收集器需要额外三分之一的内存来容纳其年轻代,而 G1 GC 需要更多的内存来设置其所有区域。

要使用这个收集器,你需要再次指定 -XX:+UnlockExperimentalVMOptions 标志,以及 -XX:+UseEpsilonGC

除非你确信程序永远不会需要比你提供的内存更多的内存,否则使用这个收集器是有风险的。但在这些情况下,它可以提供良好的性能提升。

概述

过去的两章花了大量时间深入探讨 GC(及其各种算法)的工作细节。如果 GC 消耗的时间超出了你的预期,了解所有这些工作原理应该有助于你采取必要的步骤来改善情况。

现在你已经了解了所有的细节,让我们退后一步来确定选择和调整垃圾收集器的方法。以下是一组快速问题,帮助你把所有内容放在上下文中:

你的应用程序能容忍一些完整 GC 暂停吗?

如果不是的话,G1 GC 就是首选算法。即使你可以容忍一些完整暂停,G1 GC 通常比并行 GC 更好,除非你的应用程序受限于 CPU。

默认设置能满足你的性能需求吗?

首先尝试默认设置。随着 GC 技术的成熟,自动调整越来越好。如果没有得到所需的性能,请确保 GC 是你的问题。查看 GC 日志,看看你在 GC 中花费的时间以及长时间暂停发生的频率。对于繁忙的应用程序,如果在 GC 中花费的时间不到 3%,你不太可能通过调整来获得很大的提升(尽管如果这是你的目标,你可以尝试减少异常值)。

你拥有的暂停时间是否接近你的目标?

如果是这样,调整最大暂停时间可能是你所需的全部。如果不是这样,你需要做其他事情。如果暂停时间过长但吞吐量正常,你可以减少年轻代的大小(对于完整的 GC 暂停,还有老年代);你将获得更多但较短的暂停时间。

即使 GC 暂停时间很短,吞吐量是否滞后?

您需要增加堆的大小(或至少是年轻代的大小)。更多并不总是更好:更大的堆会导致更长的暂停时间。即使使用并发收集器,更大的堆默认情况下会导致更大的年轻代,因此您将看到更长的年轻代收集暂停时间。但如果可以的话,请增加堆的大小,或至少增加代的相对大小。

如果您使用并发收集器,并因并发模式失败而出现全 GC,请问您是否使用了并发收集器并因提升失败而出现全 GC?

如果你有可用的 CPU,请尝试增加并行 GC 线程的数量,或通过调整InitiatingHeapOccupancyPercent提前启动后台扫描。对于 G1 来说,如果有未处理的混合 GC,请尝试减少混合 GC 计数目标,以便并行循环不会启动。

如果您使用并发收集器,并因提升失败而出现全 GC,请问您是否使用了并发收集器并因提升失败而出现全 GC?

在 G1 GC 中,疏散失败(到空间溢出)表示堆是碎片化的,但通常情况下,如果 G1 GC 提前执行后台扫描和更快地执行混合 GC,则可以解决此问题。尝试增加并发 G1 线程的数量、调整InitiatingHeapOccupancyPercent,或减少混合 GC 计数目标。

¹ 实际上,227,893 KB 只有 222 MB。为了方便讨论,在本章中,我将以 1,000 为单位截断 KB;假装我是一个磁盘制造商。

² 类似的工作也可以使 CMS 全 GC 使用并行线程运行,但 G1 GC 的工作被优先考虑。

³ 这是线程本地变量可以防止锁竞争的一种变体方法(参见第九章)。

第七章:堆内存最佳实践

第五章和第六章讨论了如何调整垃圾收集器以尽可能少地影响程序的详细信息。调整垃圾收集器非常重要,但通常通过利用更好的编程实践可以获得更好的性能提升。本章讨论了一些在 Java 中使用堆内存的最佳实践方法。

我们在这里有两个相互冲突的目标。第一个一般规则是尽量少地创建对象,并尽快丢弃它们。使用更少的内存是提高垃圾收集器效率的最佳方法。另一方面,频繁重新创建某些类型的对象可能会导致整体性能下降(即使 GC 性能得到改善)。如果这些对象被重复使用,程序可以看到显著的性能提升。对象可以以各种方式重复使用,包括线程本地变量、特殊对象引用和对象池。重复使用对象意味着它们将长时间存在并影响垃圾收集器,但是当它们被明智地重复使用时,整体性能将会提高。

本章讨论了这两种方法及其之间的权衡。不过首先,我们将研究了解堆内部发生情况的工具。

堆分析

GC 日志和在第五章讨论的工具非常适合了解 GC 对应用程序的影响,但为了更进一步的可视化,我们必须深入研究堆本身。本节讨论的工具能够深入了解应用程序当前正在使用的对象。

大多数情况下,这些工具仅对堆中的活动对象进行操作——在下一个完整的 GC 周期中将被回收的对象不包含在工具的输出中。在某些情况下,工具通过强制执行完整的 GC 来实现这一点,因此在使用工具后可能会影响应用程序的行为。在其他情况下,这些工具遍历堆并报告活动数据,而不会在此过程中释放对象。不管哪种情况,这些工具都需要时间和机器资源;它们通常在程序执行的测量过程中不是很有用。

堆直方图

减少内存使用是一个重要的目标,但与大多数性能主题一样,有助于将努力集中在最大化可用收益上。在本章后面,你将看到一个关于懒初始化Calendar对象的示例。这将在堆中节省 640 字节的空间,但如果应用程序总是初始化这样的对象,性能上几乎没有可测量的差异。必须进行分析以确定哪些类型的对象消耗了大量内存。

最简单的方法是通过堆直方图。直方图是查看应用程序内对象数量的快速方法,而无需进行完整的堆转储(因为堆转储可能需要一段时间进行分析,而且会消耗大量磁盘空间)。如果一些特定对象类型负责在应用程序中创建内存压力,那么堆直方图是发现这一问题的快速方法。

可以使用 jcmd(此处使用进程 ID 8898)获取堆直方图:

% jcmd 8998 GC.class_histogram
8898:

 num     #instances         #bytes  class name
----------------------------------------------
   1:        789087       31563480  java.math.BigDecimal
   2:        172361       14548968  C
   3:         13224       13857704  B
   4:        184570        5906240  java.util.HashMap$Node
   5:         14848        4188296  [I
   6:        172720        4145280  java.lang.String
   7:         34217        3127184  [Ljava.util.HashMap$Node;
   8:         38555        2131640  [Ljava.lang.Object;
   9:         41753        2004144  java.util.HashMap
  10:         16213        1816472  java.lang.Class

在直方图中,我们通常可以看到字符数组([C)和 String 对象位于最顶部附近,因为这些是最常见的 Java 对象。字节数组([B)和对象数组([Ljava.lang.Object;)也很常见,因为类加载器将其数据存储在这些结构中。如果您对此语法不熟悉,请参阅 Java Native Interface(JNI)文档。

在此示例中,包含 BigDecimal 类是值得追究的:我们知道示例代码产生了大量瞬态 BigDecimal 对象,但是让这么多对象在堆中存在并不是我们通常所期望的。GC.class_histogram 的输出仅包括活动对象,因为该命令通常会强制进行完整的 GC。您可以在命令中包含 -all 标志以跳过完整的 GC,但那么直方图将包含未引用的(垃圾)对象。

运行此命令也可以获得类似的输出:

% jmap -histo process_id

jmap 的输出包括可被收集(死)对象。在查看直方图之前强制进行完整的 GC,请改用此命令:

% jmap -histo:live process_id

直方图很小,因此对于自动化系统中的每个测试收集一个直方图可能很有帮助。但是,由于获取直方图需要几秒钟并触发完整的 GC,因此不应在性能测量稳定状态下获取。

堆转储

直方图非常适合识别由于分配过多某一两个特定类的实例而引起的问题,但是要进行更深入的分析,则需要堆转储。许多工具可以查看堆转储,并且其中大多数可以连接到实时程序以生成转储。通常更容易通过命令行生成转储,可以使用以下任一命令执行:

% jcmd process_id GC.heap_dump /path/to/heap_dump.hprof

或者

% jmap -dump:live,file=/path/to/heap_dump.hprof process_id

jmap 中包含 live 选项将在转储堆之前强制进行完整的 GC。这是 jcmd 的默认设置,但如果出于某种原因希望包含那些其他(死)对象,则可以在 jcmd 命令行的末尾指定 -all。如果以强制进行完整的 GC 的方式使用该命令,显然会在应用程序中引入长时间的暂停,但即使不强制进行完整的 GC,应用程序也将因为写入堆转储而暂停一段时间。

任一命令都会在给定目录中创建名为heap_dump.hprof的文件;然后可以使用各种工具打开该文件。其中最常见的工具如下:

jvisualvm

jvisualvm 的 Monitor 选项卡可以从运行中的程序中获取堆转储或打开以前生成的堆转储。从那里,您可以浏览堆,检查最大的保留对象,并针对堆执行任意查询。

mat

开源的 EclipseLink Memory Analyzer 工具 (mat) 可以加载一个或多个堆转储,并对它们进行分析。它可以生成报告,指出问题可能出现的位置,也可以用于浏览堆并在堆中执行类似 SQL 的查询。

堆的第一次分析通常涉及保留内存。对象的保留内存是如果该对象本身有资格被收集,将释放的内存量。在 [图 7-1 中,String Trio 对象的保留内存包括该对象占用的内存以及 Sally 和 David 对象占用的内存。它不包括 Michael 对象使用的内存,因为该对象有另一个引用,如果释放 String Trio,则该对象将不会有资格进行 GC。

![对象图显示一些对象有多个引用。###### 图 7-1. 保留内存的对象图保留大量堆空间的对象通常称为堆的 支配者 。如果堆分析工具显示少数对象主导了大部分堆,事情就简单了:您只需减少它们的创建数量,缩短它们的保留时间,简化它们的对象图或使它们变小。这可能说起来容易,但至少分析是简单的。更常见的情况是,需要进行侦查工作,因为程序可能在共享对象。就像前一个图中的 Michael 对象一样,这些共享对象不会计入任何其他对象的保留集,因为释放一个单独对象不会释放共享对象。此外,最大的保留大小通常是你无法控制的类加载器。作为一个极端的例子,图 7-2 显示了一个堆的顶部保留对象,来自一个根据客户端连接缓存项目并在全局哈希映射中弱引用的股票服务器版本(因此缓存项目具有多个引用)。内存分析器图表显示保留内存最多的顶级对象。

图 7-2. 内存分析器中的保留内存视图

堆包含 1.4 GB 的对象(该值不会出现在此选项卡上)。即使如此,单独引用的最大对象集合仅为 6 MB(并且,毫不奇怪,是类加载框架的一部分)。查看直接保留最大内存量的对象并不能解决内存问题。

这个例子展示了列表中多个StockPriceHistoryImpl对象的实例,每个对象都占用了相当数量的内存。从这些对象占用的内存量可以推断出它们是问题所在。但通常情况下,对象可能以一种共享的方式存在,这样查看保留堆也看不出明显的问题。

对象的直方图是一个有用的第二步(见图 7-3)。

股票服务器应用程序中的对象直方图。

图 7-3. 内存分析器中的直方图视图

直方图聚合了相同类型的对象,在这个例子中,可以明显地看出,七百万个TreeMap$Entry对象所保留的 1.4 GB 内存是关键。即使不知道程序内部发生了什么,也很容易利用内存分析工具追踪这些对象,看看是什么在持有它们。

堆分析工具提供了一种查找特定对象(或在这种情况下一组对象)的 GC 根源的方法,尽管直接跳转到 GC 根源并不一定有帮助。GC 根源是持有静态全局引用的系统对象,通过一长串其他对象引用到所讨论的对象。通常这些来自系统加载的类的静态变量或引导类路径。这包括Thread类和所有活动线程;线程通过它们的线程本地变量或通过它们目标Runnable对象的引用来保留对象(或者在Thread类的子类的情况下,子类的其他引用)。

在某些情况下,了解目标对象的 GC 根源是有帮助的,但如果对象具有多个引用,则会有多个 GC 根源。这里的引用是一个反向的树结构。假设有两个对象引用特定的TreeMap$Entry对象。这两个对象可能被其他两个对象引用,每个对象可能被其他三个对象引用,依此类推。根据 GC 根源的追踪,引用的爆炸意味着任何给定对象可能有多个 GC 根源。

相反,更有成效的方法是像侦探一样查找对象图中的最低点,这是通过检查对象及其传入引用,并追踪这些传入引用直到找到重复路径来完成的。在这种情况下,持有在树映射中的StockPriceHistoryImpl对象的引用有两个:ConcurrentHashMap,它保存会话的属性数据,以及WeakHashMap,它保存全局缓存。

在图 7-4 中,回溯足够展开,仅显示有关其中两个对象的少量数据。确认它是会话数据的方式是继续展开ConcurrentHashMap路径,直到清楚该路径是会话数据。对于WeakHashMap的路径,同样的逻辑也适用。

每个 TreeMap 对象被其他两个对象引用。

图 7-4. Memory Analyzer 中对象引用的回溯

此示例中使用的对象类型使得分析比通常情况下更容易一些。如果该应用程序的主要数据被建模为String对象而不是BigDecimal对象,并存储在HashMap对象而不是TreeMap对象中,事情会变得更加困难。堆转储中还有数十万其他字符串和数万个HashMap对象。找到有趣对象的路径需要一些耐心。作为一个经验法则,从集合对象(例如HashMap)开始而不是条目(例如HashMap$Entry),并寻找最大的集合。

快速总结

  • 知道哪些对象消耗了内存是了解需要优化代码中的哪些对象的第一步。

  • 直方图是识别因创建某一类型的太多对象而导致的内存问题的一种快速简便方法。

  • 堆转储分析是追踪内存使用的最强大技术,尽管需要耐心和努力才能充分利用。

内存不足错误

在这些情况下,JVM 在这些情况下会抛出内存不足错误:

  • JVM 没有可用的本机内存。

  • 元空间已耗尽。

  • Java 堆本身已经耗尽了内存:应用程序无法为给定的堆大小创建任何额外的对象。

  • JVM 在执行垃圾回收时花费了太多时间。

最后两种情况——涉及 Java 堆本身——更为常见,但不要仅仅因为内存不足错误就自动认为堆是问题所在。必须查看内存不足错误的原因(该原因是异常输出的一部分)。

本机内存耗尽

此列表中的第一个情况——JVM 没有可用的本机内存——是与堆毫不相关的原因。在 32 位 JVM 中,进程的最大大小为 4 GB(某些版本的 Windows 为 3 GB,在某些较旧的 Linux 版本中约为 3.5 GB)。指定非常大的堆大小,比如 3.8 GB,会使应用程序大小接近该限制。即使是 64 位 JVM,操作系统可能也没有足够的虚拟内存来满足 JVM 请求。

有关此主题的更全面讨论,请参阅第八章。请注意,如果内存不足错误的消息讨论的是分配本机内存,堆调整不是解决方案:您需要查看错误消息中提到的任何本机内存问题。例如,以下消息告诉您线程堆栈的本机内存已耗尽:

Exception in thread "main" java.lang.OutOfMemoryError:
unable to create new native thread

但请注意,JVM 有时会因与内存无关的事物而发出此错误。用户通常对可运行的线程数量有限制;此限制可能由操作系统或容器施加。例如,在 Linux 中,通常只允许用户创建 1,024 个进程(您可以通过运行ulimit -u来检查此值)。尝试创建第 1,025 个线程将抛出相同的OutOfMemoryError,宣称内存不足以创建本地线程,而实际上是由于进程数目的操作系统限制导致错误。

元空间内存不足

元空间内存不足错误也与堆无关,而是因为元空间本地内存已满。由于元空间默认没有最大大小,因此此错误通常是因为您选择设置了最大大小(本节的原因将在下文中变得清晰)。

此错误可能有两个根本原因:第一个是应用程序使用的类超过了您分配的元空间可以容纳的数量(参见“调整元空间大小”)。第二种情况更加棘手:涉及类加载器内存泄漏。这种情况在动态加载类的服务器中最为频繁。例如,Java EE 应用服务器就是一个典型例子。部署到应用服务器的每个应用程序都在自己的类加载器中运行(这提供了隔离,使得一个应用程序的类不与其他应用程序的类共享或干扰)。在开发过程中,每次修改应用程序后,必须重新部署:创建一个新的类加载器来加载新的类,旧的类加载器则被允许超出作用域。一旦类加载器超出作用域,类的元数据就可以被回收。

如果旧的类加载器未超出作用域,则类的元数据无法被释放,最终元空间将被填满并抛出内存不足错误。在这种情况下,增加元空间的大小会有所帮助,但最终只是推迟了错误的发生。

如果此情况发生在应用服务器环境中,除了联系应用服务器供应商并要求其修复泄漏外,别无他法。如果您正在编写自己的应用程序,并创建和丢弃大量类加载器,请确保正确地丢弃类加载器本身(特别是确保没有线程将其上下文类加载器设置为临时类加载器之一)。要调试此情况,刚才描述的堆转储分析非常有用:在直方图中,查找所有ClassLoader类的实例,并追踪它们的 GC 根以查看它们所持有的内容。

识别此情况的关键是再次查看内存溢出错误的全文输出。如果元空间已满,则错误文本将如下所示:

Exception in thread "main" java.lang.OutOfMemoryError: Metaspace

顺便说一下,类加载器泄漏是您应考虑设置元空间最大大小的原因。如果不加限制,具有类加载器泄漏的系统将消耗机器上的所有内存。

堆外内存

当堆本身内存不足时,错误消息如下:

Exception in thread "main" java.lang.OutOfMemoryError: Java heap space

引起内存不足条件的常见情况类似于我们刚讨论的元空间示例。应用程序可能仅需更多的堆空间:它持有的活动对象数量无法适应为其配置的堆空间。或者,应用程序可能存在内存泄漏:它继续分配额外的对象,而不允许其他对象超出作用域。在第一种情况下,增加堆大小将解决问题;在第二种情况下,增加堆大小只会推迟错误的发生。

在任一情况下,都需要进行堆转储分析以找出消耗最多内存的是什么;然后可以专注于减少这些对象的数量(或大小)。如果应用程序存在内存泄漏,则可以在几分钟内连续进行堆转储,并进行比较。mat已经内建了这种功能:如果打开了两个堆转储,mat有一个选项可以计算两个堆之间直方图的差异。

图 7-5 展示了由集合类(在本例中为HashMap)引起的 Java 内存泄漏的经典案例。(集合类是内存泄漏的最常见原因之一:应用程序将项目插入集合中,并且从不释放它们。)这是一个比较直方图视图:显示了两个堆转储中对象数量的差异。例如,与基线相比,目标堆转储中多了 19,744 个Integer对象。

克服这种情况的最佳方法是改变应用程序逻辑,以便在不再需要时主动从集合中丢弃项目。或者,使用弱引用或软引用的集合可以在应用程序中没有其他引用它们时自动丢弃这些项目,但这些集合也有成本(如本章后面所讨论的)。

直方图比较,显示哈希映射条目数量大幅增加。

图 7-5. 直方图比较

在抛出此类异常时,通常 JVM 不会退出,因为异常只影响 JVM 中的单个线程。让我们来看一个具有两个线程执行计算的 JVM。其中一个可能会收到OutOfMemoryError。默认情况下,该线程的线程处理程序将打印出堆栈跟踪,并且该线程将退出。

但是 JVM 仍然有另一个活动线程,所以 JVM 不会退出。并且因为遇到错误的线程已经终止,因此未来可能在一个 GC 周期中大量的内存可能被声明:所有终止线程引用的对象,而这些对象没有被其他线程引用。因此,生存线程将能够继续执行,并且通常具有足够的堆内存来完成其任务。

处理请求的线程池的服务器框架工作方式基本相同。它们通常会捕获错误并阻止线程终止,但这不会影响此讨论;线程正在执行的请求相关联的内存仍然会变得可收集。

因此,当此错误被抛出时,只有当它导致 JVM 中的最后一个非守护线程终止时,才会对 JVM 产生致命影响。这在服务器框架中永远不会发生,并且在具有多个线程的独立程序中通常不会发生。而且通常这种情况都会很好地解决,因为与活动请求相关联的内存通常会变得可收集。

如果你希望 JVM 在堆内存耗尽时退出,可以设置-XX:+ExitOnOutOfMemoryError标志,默认值为false

GC 超过限制

上一个案例中描述的恢复假设当一个线程遇到内存不足错误时,与该线程相关联的内存将变得可收集,JVM 可以恢复。这并不总是真实的,这导致了 JVM 抛出内存不足错误的最终情况:当它确定自己花费太多时间执行 GC 时:

Exception in thread "main" java.lang.OutOfMemoryError: GC overhead limit exceeded

当满足以下所有条件时,会抛出此错误:

  • 完全 GC 所花费的时间超过了由-XX:GCTimeLimit=*N*标志指定的值。默认值为 98(即,如果 98%的时间花在 GC 上)。

  • 完全 GC 回收的内存量小于由-XX:GCHeapFreeLimit=*N*标志指定的值。默认值为 2,这意味着在完全 GC 期间释放的堆小于 2%时,满足此条件。

  • 上述两个条件已经连续满足了五个完全 GC 周期(该值不可调)。

  • -XX:+UseGCOverheadLimit标志的值为true(默认情况下为true)。

请注意,所有这四个条件必须满足。在不抛出内存不足错误的应用程序中,常见的是连续发生五次以上的完全 GC。这是因为即使应用程序花费了 98%的时间执行完全 GC,但每次 GC 可能会释放堆的超过 2%。在这种情况下,考虑增加GCHeapFreeLimit的值。

注意,作为释放内存的最后一招,如果前两个条件连续四次完整的 GC 循环均满足,则在第五次完整的 GC 循环之前将释放 JVM 中的所有软引用。通常这可以避免错误,因为第五次循环可能会释放超过堆的 2%(假设应用程序使用软引用)。

快速总结

  • 内存不足错误出现的原因多种多样;不要假设堆空间是问题所在。

  • 对于元空间和常规堆来说,内存泄漏是最常见的导致内存不足错误的原因;堆分析工具可以帮助找出泄漏的根本原因。

使用更少的内存

在 Java 中更高效地使用内存的第一种方法是减少堆内存的使用。这个说法应该不足为奇:使用更少的内存意味着堆填充的频率降低,需要的 GC 循环也会减少。效果可能会倍增:更少的年轻代收集意味着对象的老化年龄不经常增加——这意味着对象不太可能晋升到老年代。因此,全量 GC 循环(或并发 GC 循环)的数量会减少。而如果这些全量 GC 循环可以释放更多内存,它们也会更不频繁地发生。

本节探讨了三种减少内存使用的方法:减少对象大小、使用对象的延迟初始化以及使用规范化对象。

减少对象大小

对象占用一定量的堆内存,因此减少内存使用的最简单方法是减少对象的大小。鉴于运行程序的机器的内存限制,可能无法将堆大小增加 10%,但将堆中一半对象的大小减少 20% 可以达到同样的目标。正如在第十二章中讨论的,Java 11 对于 String 对象有这样的优化,这意味着 Java 11 的用户可以将最大堆大小设置为比 Java 8 要求的小 25%,而不会影响 GC 或性能。

对象的大小可以通过(显而易见地)减少它所包含的实例变量的数量以及(不那么明显地)减少这些变量的大小来减少。

表 7-1. Java 类型实例变量的字节大小

类型大小
byte1
char2
short2
int4
float4
long8
double8
reference8(在 32 位 Windows JVM 中为 4)^(a)
^(a) 详见“压缩指针”获取更多详情。

此处的 reference 类型是对任何 Java 对象(类的实例或数组)的引用。这个空间仅用于引用本身。包含对其他对象引用的对象的大小因是否考虑对象的浅层、深层或保留大小而异,但该大小还包括一些不可见的对象头字段。对于普通对象,在 32 位 JVM 上,头字段的大小为 8 字节,在 64 位 JVM 上为 16 字节(无论堆大小如何)。对于数组,在 32 位 JVM 或堆大小小于 32 GB 的 64 位 JVM 上,头字段的大小为 16 字节,否则为 24 字节。

例如,考虑以下类定义:

public class A {
    private int i;
}

public class B {
    private int i;
    private Locale l = Locale.US;
}

public class C {
    private int i;
    private ConcurrentHashMap chm = new ConcurrentHashMap();
}

在 64 位 JVM(堆大小小于 32 GB)上,这些对象的单个实例的实际大小如 表 7-2 所示。

表 7-2. 简单对象的字节大小

浅层大小深层大小保留大小
A161616
B2421624
C24200200

在类 B 中,定义 Locale 引用会增加 8 字节到对象大小,但在该示例中,Locale 对象是被其他类共享的。如果类不需要 Locale 对象,包含该实例变量只会浪费额外的引用字节。然而,如果应用程序创建大量 B 类的实例,这些字节会累积。

另一方面,定义和创建 ConcurrentHashMap 对象会消耗额外的字节用于对象引用,再加上哈希映射对象的额外字节。如果哈希映射从未被使用,那么 C 类的实例就会显得浪费。

仅定义必需的实例变量是节省对象空间的一种方法。不那么明显的情况涉及使用较小的数据类型。如果一个类需要跟踪八种可能的状态之一,可以使用 byte 而不是 int,可能会节省 3 字节。在频繁实例化的类中,使用 float 替代 doubleint 替代 long 等,可以帮助节省内存,尤其是在使用适当大小的集合(或者使用简单的实例变量而不是集合)时。如 第十二章 中讨论的那样,这可以实现类似的节省。

减少对象中的实例字段可以帮助减小对象的大小,但也存在一个灰色地带:那些用于存储基于数据片段计算结果的对象字段怎么处理?这是时间与空间之间的经典计算机科学折衷:是花费内存(空间)来存储值,还是花费时间(CPU 周期)根据需要计算值?在 Java 中,这种折衷也适用于 CPU 时间,因为额外的内存可能导致 GC 消耗更多的 CPU 周期。

例如,String 的哈希码是通过对字符串的每个字符进行加权求和得出的,这在计算上是相对耗时的。因此,String 类将该值存储在实例变量中,这样哈希码只需要计算一次:最终,重用该值几乎总是比不存储它而节省的内存效果更好。另一方面,大多数类的 toString() 方法不会缓存对象的字符串表示形式在实例变量中,这既会消耗实例变量的内存,也会消耗其引用的字符串的内存。通常,计算新字符串所需的时间比保留字符串引用所需的内存通常提供更好的性能。 (另外,String 的哈希值经常使用,对象的 toString() 表示通常很少使用。)

这绝对是一个因人而异的情况,以及在时间/空间连续体中切换使用内存以缓存值和重新计算值之间的最佳点,将取决于许多因素。如果减少 GC 是目标,平衡将更倾向于重新计算。

快速总结

  • 减少对象大小通常可以提高 GC 的效率。

  • 一个对象的大小并不总是立即显而易见:对象被填充以适应 8 字节边界,而对象引用的大小在 32 位和 64 位 JVM 之间是不同的。

  • 即使 null 实例变量也会在对象类中占用空间。

使用延迟初始化

大多数情况下,关于是否需要特定实例变量的决定并不像前一节所述的那样黑白分明。一个特定的类可能只有 10% 的时间需要一个 Calendar 对象,但是创建 Calendar 对象是昂贵的,因此保留该对象而不是按需重新创建它是有意义的。这是一种 延迟初始化 可以帮助的情况。

到目前为止,这个讨论假设实例变量是急切地初始化的。一个需要使用 Calendar 对象(并且不需要线程安全性)的类可能如下所示:

public class CalDateInitialization {
    private Calendar calendar = Calendar.getInstance();
    private DateFormat df = DateFormat.getDateInstance();

    private void report(Writer w) {
        w.write("On " + df.format(calendar.getTime()) + ": " + this);
    }
}

而延迟初始化字段则在计算性能上有少许的折衷——代码必须每次执行时测试变量的状态:

public class CalDateInitialization {
    private Calendar calendar;
    private DateFormat df;

    private void report(Writer w) {
        if (calendar == null) {
            calendar = Calendar.getInstance();
            df = DateFormat.getDateInstance();
        }
        w.write("On " + df.format(calendar.getTime()) + ": " + this);
    }
}

当涉及的操作很少使用时,最好使用延迟初始化。如果操作经常使用,就不会节省内存(因为它总是被分配),并且在常见操作上会有轻微的性能损失。

当涉及的代码必须是线程安全时,延迟初始化变得更加复杂。作为第一步,最简单的方法是简单地添加传统的同步:

public class CalDateInitialization {
    private Calendar calendar;
    private DateFormat df;

    private synchronized void report(Writer w) {
        if (calendar == null) {
            calendar = Calendar.getInstance();
            df = DateFormat.getDateInstance();
        }
        w.write("On " + df.format(calendar.getTime()) + ": " + this);
    }
}

引入同步机制到解决方案中,可能会导致同步成为性能瓶颈的可能性增加。这种情况应该很少见。懒初始化带来的性能好处只有在很少初始化这些字段的对象时才会发生,因为如果通常初始化这些字段,实际上并没有节省内存。因此,当一个不经常使用的代码路径突然被大量线程同时使用时,同步对于懒初始化字段会成为一个瓶颈。这种情况并非不可想象,但也不是最常见的情况。

只有当惰性初始化的变量本身是线程安全的时,才能解决那个同步瓶颈。DateFormat对象不是线程安全的,所以在当前示例中,无论锁是否包括Calendar对象,都不会有什么影响:如果懒初始化对象突然被频繁使用,围绕DateFormat对象的必需同步将是一个问题。线程安全的代码应该如下所示:

public class CalDateInitialization {
    private Calendar calendar;
    private DateFormat df;

    private void report(Writer w) {
        unsychronizedCalendarInit();
        synchronized(df) {
            w.write("On " + df.format(calendar.getTime()) + ": " + this);
        }
    }
}

涉及不是线程安全的实例变量的惰性初始化可以始终在该变量周围进行同步(例如,使用前面显示的方法的synchronized版本)。

考虑一个稍微不同的例子,在这个例子中,一个大的ConcurrentHashMap是惰性初始化的:

public class CHMInitialization {
    private ConcurrentHashMap chm;

    public void doOperation() {
        synchronized(this) {
            if (chm == null) {
                chm = new ConcurrentHashMap();
                ... code to populate the map ...
            }
        }
        ...use the chm...
    }
}

因为ConcurrentHashMap可以被多个线程安全访问,在这个例子中额外的同步是懒初始化可能引入同步瓶颈的少见情况之一。(尽管这种瓶颈仍然很少见;如果对哈希映射的访问频率很高,考虑懒初始化是否真的能节省任何东西。)双重检查锁定惯用法解决了这个瓶颈:

public class CHMInitialization {
    private volatile ConcurrentHashMap instanceChm;

    public void doOperation() {
        ConcurrentHashMap chm = instanceChm;
        if (chm == null) {
            synchronized(this) {
                chm = instanceChm;
                if (chm == null) {
                    chm = new ConcurrentHashMap();
                    ... code to populate the map
                    instanceChm = chm;
                }
            }
            ...use the chm...
        }
    }
}

存在重要的线程问题:实例变量必须声明为volatile,并且通过将实例变量分配给一个局部变量可以获得轻微的性能好处。更多细节请参见第九章;在偶尔有需要的情况下,这是要遵循的设计模式。

急切地去初始化

惰性初始化变量的推论是通过将它们的值设置为null急切地去初始化它们。这允许相关对象更快地被垃圾收集器回收。尽管理论上听起来像一件好事,但实际上只在有限的情况下才有用。

一个适合惰性初始化的变量可能看起来像是急切去初始化的候选变量:在前面的例子中,CalendarDateFormat 对象可以在 report() 方法完成后设置为 null。然而,如果该变量不会在方法的后续调用中使用(或者在类的其他地方使用),那么没有理由在首次创建实例变量时将其作为实例变量。只需在方法中创建局部变量,在方法完成时,局部变量将会超出作用域,垃圾收集器可以释放它。

与关于不需要急切去初始化变量的规则的常见例外情况相反,类似于 Java 集合框架中的类:这些类长时间持有数据的引用,然后被告知不再需要该数据。考虑 JDK 中 ArrayList 类的 remove() 方法的实现(某些代码已简化):

public E remove(int index) {
    E oldValue = elementData(index);
    int numMoved = size - index - 1;
    if (numMoved > 0)
        System.arraycopy(elementData, index+1,
                         elementData, index, numMoved);
    elementData[--size] = null; // clear to let GC do its work
    return oldValue;
}

JDK 源码本身几乎没有注释,但是有关 GC 的代码注释如下:像这样将变量设置为 null 的操作是不寻常的,因此需要一些解释。在这种情况下,跟踪数组的最后一个元素被移除时发生的情况。数组中剩余的项目数量——size 实例变量被减少。假设 size 从 5 减少到 4。现在 elementData[4] 中存储的任何内容都无法访问:它超出了数组的有效大小。

elementData[4] 在这种情况下是一个过时的引用。elementData 数组可能会长时间保持活动状态,因此不再需要引用的任何内容都需要明确设置为 null

这种过时引用的概念是关键:如果长寿类缓存然后丢弃对象引用,则必须小心避免过时引用。否则,显式地将对象引用设置为 null 将不会带来太多性能好处。

快速总结

  • 仅当常见的代码路径将变量保持未初始化状态时才使用惰性初始化。

  • 线程安全代码的惰性初始化是不寻常的,但通常可以依赖于现有的同步机制。

  • 对于使用线程安全对象进行代码的惰性初始化,使用双重检查锁定。

使用不可变和规范化对象

在 Java 中,许多对象类型是不可变的。这包括具有对应原始类型的对象——IntegerDoubleBoolean 等,以及其他基于数字的类型,如 BigDecimal。当然,最常见的 Java 对象是不可变的 String。从程序设计的角度来看,为自定义类表示不可变对象通常是一个好主意。

当这些对象快速创建并且被丢弃时,它们对年轻代的影响很小;正如你在 第五章 中看到的,该影响是有限的。但与任何对象一样,如果许多不可变对象晋升到老年代,性能可能会受到影响。

因此,没有理由避免设计和使用不可变对象,即使这些对象似乎不能被改变和必须重新创建可能有点逆向思维。但在处理这些对象时通常可以进行的优化之一是避免创建相同对象的重复副本。

这个最好的例子是Boolean类。任何 Java 应用程序只需要两个Boolean类的实例:一个为 true,一个为 false。不幸的是,Boolean类设计不佳。因为它有一个公共构造函数,应用程序可以随意创建任意数量的这些对象,尽管它们都与两个标准Boolean对象之一完全相同。更好的设计应该是Boolean类只有一个私有构造函数,并且静态方法根据参数返回Boolean.TRUEBoolean.FALSE。如果你的不可变类可以遵循这样的模式,你可以防止它们贡献到应用程序的堆使用中。(理想情况下,显然你永远不应该创建Boolean对象;必要时应该只使用Boolean.TRUEBoolean.FALSE。)

这些不可变对象的唯一表示被称为对象的标准版本

创建标准对象

即使某个特定类的对象宇宙几乎是无限的,使用标准值通常可以节省内存。JDK 提供了一种机制来为最常见的不可变对象做到这一点:字符串可以调用intern()方法来找到字符串的标准版本。更多关于字符串驻留的细节在第十二章中进行了详细考察;现在我们来看如何为自定义类实现同样的功能。

要使对象标准化,创建一个映射来存储对象的标准版本。为了防止内存泄漏,确保映射中的对象是弱引用的。这样的类的框架如下所示:

public class ImmutableObject {
    private static WeakHashMap<ImmutableObject, ImmutableObject>
        map = new WeakHashMap();

    public ImmutableObject canonicalVersion(ImmutableObject io) {
        synchronized(map) {
            ImmutableObject canonicalVersion = map.get(io);
            if (canonicalVersion == null) {
                map.put(io, new WeakReference(io));
                canonicalVersion = io;
            }
            return canonicalVersion;
        }
    }
}

在多线程环境中,同步可能会成为瓶颈。如果坚持使用 JDK 类,没有简单的解决方案,因为它们不提供用于弱引用的并发哈希映射。然而,已经有提议在 JDK 中添加CustomConcurrentHashMap,最初作为 Java 规范请求(JSR)166 的一部分,并且你可以找到各种第三方实现这样的类。

快速总结

  • 不可变对象提供了特殊生命周期管理的可能性:标准化。

  • 通过标准化消除不可变对象的重复副本可以大大减少应用程序使用的堆量。

对象生命周期管理

本章讨论的第二个广泛的内存管理主题是对象生命周期管理。在大多数情况下,Java 试图最小化开发人员管理对象生命周期所需的工作:开发人员在需要时创建对象,当它们不再需要时,对象超出范围并由垃圾收集器释放。

有时,这种正常的生命周期并不是最优的。一些对象的创建成本很高,管理这些对象的生命周期将提高应用程序的效率,即使需要垃圾收集器做更多的工作。本节探讨了何时以及如何改变对象的正常生命周期,无论是通过重用对象还是保持对它们的特殊引用。

对象重用

对象重用通常通过两种方式实现:对象池和线程局部变量。全球的 GC 工程师现在正因为这些技术而叹息,因为它们会影响 GC 的效率。特别是出于这个原因,对象池技术在 GC 圈子里广受厌恶,虽然在开发圈子里,出于许多其他原因,对象池也广受厌恶。

在某种程度上,这个立场的原因似乎很明显:被重用的对象在堆中存活时间长。如果堆中有很多对象,那么创建新对象的空间就越少,因此 GC 操作会更频繁。但这只是故事的一部分。

正如您在第六章中看到的,当对象创建时,它会分配到 Eden 区。它会在幸存者空间中来回移动几个年轻代 GC 周期,最终最终被提升到老年代。每次处理新创建的(或最近创建的)池化对象时,GC 算法必须执行一些工作来复制它并调整对它的引用,直到它最终进入老年代。

虽然看起来像是结束了,但一旦对象被提升到老年代,就可能导致更多的性能问题。执行完整的 GC 所需的时间与老年代中仍然存活的对象数量成正比。存活数据的量比堆的大小更重要;处理具有少量存活对象的 3 GB 老年代比处理其中 75% 对象存活的 1 GB 老年代更快。

使用并发收集器并避免完整的 GC 并不能使情况好转太多,因为并发收集器标记阶段所需的时间也与仍存活数据的量有关。特别是对于 CMS,池中的对象可能在不同时间被提升,增加了由于碎片化而导致并发失败的机会。总体而言,对象在堆中存留的时间越长,GC 的效率就越低。

因此:对象重用是不好的。现在我们可以讨论如何以及何时重用对象了。

JDK 提供了一些常见的对象池:讨论了线程池,详见第九章,以及软引用。软引用稍后在本节中讨论,本质上是一大批可重用对象的池。与此同时,Java 服务器依赖于对象池来管理与数据库和其他资源的连接。线程本地值也是类似情况;JDK 充满了使用线程本地变量避免重新分配某些类型对象的类。显然,即使是 Java 专家也理解某些情况下需要对象重用的必要性。

对象重用的原因是许多对象的初始化成本很高,重用它们比增加 GC 时间的权衡更有效。对于像 JDBC 连接池这样的事物来说,创建网络连接,可能登录并建立数据库会话是昂贵的。在这种情况下,对象池是一个很大的性能优势。线程池是为了节省创建线程的时间而池化线程;随机数生成器作为线程本地变量提供,以节省初始化所需的时间;等等。

这些示例共享的一个特点是对象的初始化时间很长。在 Java 中,对象的分配是快速且廉价的(对于不重用对象的论点通常集中在此部分)。对象的初始化性能取决于对象本身。你应该仅考虑重新使用初始化成本非常高的对象,只有在初始化这些对象的成本是程序中主要操作之一时才这样做。

这些示例共享的另一个特点是共享对象的数量往往很少,这减少了它们对 GC 操作的影响:它们不足以减慢 GC 循环。在池中有少量对象不会对 GC 效率产生太大影响;将堆填满池化对象会显著减慢 GC 的速度。

这里只是 JDK 和 Java 程序重用对象的一些示例(以及原因):

线程池

初始化线程的成本很高。

JDBC 连接池

初始化数据库连接的成本很高。

大数组

Java 要求在分配数组时,数组中的所有单个元素必须初始化为默认的零值(适当时为 null0false)。对于大数组来说,这可能是耗时的。

本地 NIO 缓冲区

分配直接的 java.nio.Buffer(从调用 allocateDirect() 方法返回的缓冲区)是一种昂贵的操作,无论缓冲区的大小如何。最好创建一个大缓冲区,并从中切片以根据需要管理缓冲区,并返回以供未来操作重用。

安全类

MessageDigestSignature 和其他安全算法的实例化成本很高。

字符串编码器和解码器对象

JDK 中的各种类创建和重用这些对象。大多数情况下,这些也是软引用,正如你将在下一节中看到的那样。

StringBuilder辅助程序

在计算中间结果时,BigDecimal类会重复使用一个StringBuilder对象。

随机数生成器

无论是Random还是(尤其是)SecureRandom类的实例,种子成本都很高。

来自 DNS 查找的名称

网络查找很昂贵。

ZIP 编码器和解码器

有趣的是,这些对象初始化起来并不特别昂贵。然而,它们释放起来却很昂贵,因为它们依赖于对象终结来确保它们使用的本地内存也被释放。详见“终结器和最终引用”以获取更多详细信息。

两种选择(对象池和线程本地变量)在性能上有所不同。让我们更详细地看看这些差异。

对象池

对象池因多种原因而不受喜爱,其中只有一些与其性能有关。它们可能很难正确地调整大小。它们还将对象管理的负担重新放回到程序员身上:程序员不能简单地让对象超出作用域,而必须记得将对象返回到池中。

但重点在于对象池的性能,它受以下因素的影响:

GC 影响

正如你所见,持有大量对象会显著降低 GC 的效率(有时甚至极大地)。

同步

对象池不可避免地会进行同步,如果对象经常被移除和替换,那么池可能会有很多争用。结果是,访问池可能会变得比初始化新对象还要慢。

节流

对象池的这种性能影响可能是有益的:池允许对稀缺资源的访问进行节流。正如在第二章中讨论的那样,如果试图增加系统的负载超过其处理能力,性能将会下降。这就是线程池至关重要的原因之一。如果太多线程同时运行,CPU 将不堪重负,性能将下降(例如在第九章中有示例)。

这个原则也适用于远程系统访问,并且在 JDBC 连接中经常见到。如果向数据库创建的 JDBC 连接超过其处理能力,数据库的性能将会下降。在这些情况下,最好通过限制池的大小来节流资源(例如 JDBC 连接),即使这意味着应用程序中的线程必须等待空闲资源。

线程本地变量

通过将它们存储为线程本地变量来重复使用对象会导致各种性能权衡:

生命周期管理

线程本地变量比池中的对象更易于管理且成本更低。这两种技术都要求您获取初始对象:您可以从池中检出对象,或者在线程本地对象上调用get()方法。但是,对象池要求在使用完毕后将对象返回(否则其他人无法使用它)。线程本地对象始终在线程内可用,不需要显式返回。

基数

线程本地变量通常会在线程数和保存(重用)对象数之间建立一对一的对应关系。这并不是严格的情况。线程的变量副本在线程第一次使用时才创建,因此可能会比线程少的保存对象。但不能有比线程更多的保存对象,大部分时间它们的数量是相同的。

另一方面,对象池的大小可以任意设定。如果一个请求有时需要一个 JDBC 连接,有时需要两个,那么 JDBC 池可以相应地设置大小(例如,为 8 个线程设置 12 个连接)。线程本地变量无法有效地做到这一点;它们也不能调节对资源的访问(除非线程数本身作为节流器)。

同步

线程本地变量不需要同步,因为它们只能在单个线程内使用;线程本地的get()方法相对较快。(这并不总是如此;在 Java 的早期版本中,获取线程本地变量是昂贵的。如果您因为过去性能不佳而回避线程本地变量,请重新考虑在当前版本的 Java 中使用它们。)

同步提出了一个有趣的观点,因为线程本地对象的性能优势通常是通过节省同步成本来表达的(而不是通过重用对象来节省)。例如,Java 提供了ThreadLocalRandom类;在示例股票应用程序中使用了该类(而不是单个Random实例)。否则,本书中的许多示例都会在单个Random对象的next()方法上遇到同步瓶颈。使用线程本地对象是避免同步瓶颈的好方法,因为只有一个线程可以使用该对象。

然而,如果这些示例每次需要时都简单地创建一个新的Random类实例,同步问题就很容易解决了。然而,用这种方式解决同步问题并没有帮助整体性能:初始化一个Random对象是昂贵的,而频繁创建该类的实例会比多个线程共享一个实例的同步瓶颈性能更差。

使用ThreadLocalRandom类可以获得更好的性能,如表 7-3 所示。本例计算了在三种情况下每个四个线程创建 10,000 个随机数所需的时间:

  • 每个线程都会构造一个新的Random对象来计算 10,000 个数。

  • 所有线程共享一个常见的静态Random对象。

  • 所有线程共享一个常见的静态ThreadLocalRandom对象。

表 7-3. 使用ThreadLocalRandom生成 10,000 个随机数的效果

操作耗时
创建新的Random134.9 ± 0.01 微秒
ThreadLocalRandom52.0 ± 0.01 微秒
共享Random3,763 ± 200 微秒

在竞争锁的情况下进行微基准测试总是不可靠的。在本表的最后一行中,线程几乎总是在争夺Random对象上的锁;在实际应用中,争用量将会少得多。尽管如此,使用共享对象可能会看到一些争用,而每次创建新对象的代价超过使用ThreadLocalRandom对象要昂贵两倍以上。

这里的教训——以及对象重用的一般经验——是,当对象的初始化时间很长时,不要害怕探索对象池或线程本地变量来重用那些昂贵的创建对象。然而,总是要保持平衡:大型通用类的对象池几乎肯定会导致更多性能问题而不是解决问题。将这些技术留给那些初始化昂贵且重用对象数量较少的类。

快速总结

  • 一般情况下不鼓励对象重用作为通用操作,但对于初始化代价高的少量对象可能是适当的。

  • 在通过对象池或线程本地变量重用对象之间存在权衡。一般来说,线程本地变量更容易处理,假设想要实现线程与可重用对象的一对一对应关系。

软引用、弱引用及其他引用

Java 中的软引用和弱引用也允许对象被重用,尽管作为开发者,我们并不总是用这些术语来思考。这些引用类型——通常我们将其称为不定引用——更常用于缓存长时间计算或数据库查询的结果,而不是重用简单对象。例如,在股票服务器中,可以使用间接引用来缓存getHistory()方法的结果(这需要进行长时间计算或长时间数据库调用)。这个结果只是一个对象,当通过不定引用进行缓存时,我们只是因为初始化代价昂贵才会重用这个对象。

对许多程序员来说,这种情况“感觉”不同。事实上,甚至术语也反映了这一点:没有人会说“缓存”一个线程以便重用,但我们将在数据库操作结果缓存方面探讨不定引用的重用。

不定引用相较于对象池或线程本地变量的优势在于,不定引用最终会被垃圾收集器回收。如果对象池包含最近执行的最后 10,000 个股票查找,并且堆空间开始不足,那么应用程序就无法继续运行:在存储这些 10,000 个元素后,剩余堆空间就是应用程序可以使用的所有剩余堆空间。如果这些查找通过不定引用存储,JVM 可以释放一些空间(取决于引用类型),从而提高 GC 吞吐量。

缺点是不定引用对垃圾收集器效率的影响略大。图 7-6 显示了没有和有不定引用(在本例中是软引用)时内存使用的对比。

被缓存的对象占用 512 字节。左侧消耗的内存就是这些(除了指向对象的实例变量的内存)。右侧,对象被缓存在 SoftReference 对象中,增加了 40 字节的内存消耗。不定引用与任何其他对象一样:它们会消耗内存,并且其他东西(图右侧的 cachedValue 变量)会强引用它们。

不定引用对垃圾收集器的第一个影响是应用程序使用更多内存。对垃圾收集器的第二个更大影响是,不定引用对象要经过至少两个 GC 周期才能被垃圾收集器回收。

不定引用内存使用示意图。

图 7-6. 不定引用分配的内存

图 7-7 显示了引用不再被强引用(即 lastViewed 变量被设置为 null)时的情况。如果没有引用指向 StockHistory 对象,则在处理该对象所在代的下一个 GC 期间,它将被释放。因此,图的左侧现在消耗 0 字节。

图的右侧仍然消耗内存。引用被释放的确切时间取决于不定引用的类型,但现在我们先来看软引用的情况。引用将一直保留,直到 JVM 决定对象最近未被使用。在此之后,第一个 GC 周期释放引用对象,但不释放不定引用对象本身。应用程序最终的内存状态如 图 7-8 所示。

不定引用内存使用示意图。

图 7-7. 不定引用通过 GC 周期保留内存

无限引用内存使用的示意图

图 7-8. 无限引用不会立即被清除

无限引用对象本身现在至少有两个强引用:应用程序创建的原始强引用和在引用队列中由 JVM 创建的新强引用。在无限引用对象本身被垃圾收集器回收之前,所有这些强引用都必须被清除。

通 通常,这种清理工作由处理引用队列的代码完成。该代码会被通知有一个新对象进入队列,并立即移除所有对该对象的强引用。然后,在下一个 GC 周期中,无限引用对象(被引用对象)将被释放。最坏的情况是,引用队列不会立即处理,可能会经过多个 GC 周期,才会清理干净。即便是在最好的情况下,无限引用也必须经过两个 GC 周期才能被释放。

根据无限引用的类型,这个通用算法存在一些重要的变化,但所有无限引用在某种程度上都存在这种惩罚。

软引用

软引用 用于当所讨论的对象有很大可能在将来被重用,但你希望让垃圾收集器在对象未被最近使用时回收它(计算时还考虑堆的可用内存量)。软引用基本上是一个大型的最近最少使用(LRU)对象池。确保及时清除软引用是获得良好性能的关键。

这是一个例子。股票服务器可以设置一个全局缓存,按股票代码(或股票代码和日期)存储股票历史。当有请求查询从2019 年 9 月 1 日2019 年 12 月 31 日TPKS股票历史时,可以查询缓存,看看是否已经有类似请求的结果。

缓存这些数据的原因是请求往往对某些项目的需求比其他项目更频繁。如果TPKS是最常请求的股票,它可以预计会保留在软引用缓存中。另一方面,单独的KENG请求会在缓存中存活一段时间,但最终会被回收。这也考虑了随时间变化的请求:对DNLD的请求集群可以重用第一个请求的结果。随着用户意识到DNLD是一个糟糕的投资,这些缓存项最终会从堆中消失。

软引用何时被释放?首先,被引用对象不能在其他地方被强引用。如果软引用是对其被引用对象的唯一剩余引用,并且软引用最近没有被访问,那么在下一个 GC 周期中才会释放被引用对象。具体而言,公式的函数像这样伪代码:

long ms = SoftRefLRUPolicyMSPerMB * AmountOfFreeMemoryInMB;
if (now - last_access_to_reference > ms)
   free the reference

This code has two key values. The first value is set by the -XX:SoftRefLRUPolicyMSPerMB=N flag, which has a default value of 1,000.

The second value is the amount of free memory in the heap (once the GC cycle has completed). The free memory in the heap is calculated based on the maximum possible size of the heap minus whatever is in use.

So how does that all work? Take the example of a JVM using a 4 GB heap. After a full GC (or a concurrent cycle), the heap might be 50% occupied; the free heap is therefore 2 GB. The default value of SoftRefLRUPolicyMSPerMB (1,000) means that any soft reference that has not been used for the past 2,048 seconds (2,048,000 ms) will be cleared: the free heap is 2,048 (in megabytes), which is multiplied by 1,000:

   long ms = 2048000; // 1000 * 2048
   if (System.currentTimeMillis() - last_access_to_reference_in_ms > ms)
       free the reference

If the 4 GB heap is 75% occupied, objects not accessed in the last 1,024 seconds are reclaimed, and so on.

To reclaim soft references more frequently, decrease the value of the SoftRefLRUPolicyMSPerMB flag. Setting that value to 500 means that a JVM with a 4 GB heap that is 75% full will reclaim objects not accessed in the past 512 seconds.

Tuning this flag is often necessary if the heap fills up quickly with soft references. Say that the heap has 2 GB free and the application starts to create soft references. If it creates 1.7 GB of soft references in less than 2,048 seconds (roughly 34 minutes), none of those soft references will be eligible to be reclaimed. There will be only 300 MB of space left in the heap for other objects; GC will occur frequently as a result (yielding bad overall performance).

If the JVM completely runs out of memory or starts thrashing too severely, it will clear all soft references, since the alternative would be to throw an OutOfMemoryError. Not throwing the error is good, but indiscriminately throwing away all the cached results is probably not ideal. Hence, another time to lower the SoftRefLRUPolicyMSPerMB value is when the reference processing GC logs indicates that a very large number of soft references are being cleared unexpectedly. As discussed in “GC overhead limit reached”, that will occur only after four consecutive full GC cycles (and if other factors apply).

On the other side of the spectrum, a long-running application can consider raising that value if two conditions are met:

  • A lot of free heap is available.

  • The soft references are infrequently accessed.

That is an unusual situation. It is similar to a situation discussed about setting GC policies: you may think that if the soft reference policy value is increased, you are telling the JVM to discard soft references only as a last resort. That is true, but you’ve also told the JVM not to leave any headroom in the heap for normal operations, and you are likely to end up spending too much time in GC instead.

因此,警告是不要使用太多软引用,因为它们很容易填满整个堆。这个警告甚至比对创建具有太多实例的对象池的警告更强烈:当对象数量不太多时,软引用效果很好。否则,考虑一个更传统的、作为 LRU 缓存实现的、有界大小的对象池。

弱引用

当涉及的引用在多个线程同时使用时,应该使用弱引用。否则,弱引用很可能被垃圾收集器回收:仅具有弱引用的对象在每个 GC 周期中都会被回收。

这意味着弱引用永远不会进入(对软引用)在图 7-7 中所示的状态。当强引用被移除时,弱引用立即被释放。因此,程序状态直接从图 7-6 移动到图 7-8。

不过,这里有趣的效果在于弱引用最终在堆中的位置。引用对象就像其他 Java 对象一样:它们在年轻代中创建,最终晋升到老年代。如果弱引用的引用对象在弱引用本身仍在年轻代时被释放,那么弱引用将很快被释放(在下一个次要 GC)。(这假设引用队列快速处理所涉及对象。)如果引用对象保留时间足够长,使得弱引用被晋升到老年代,那么弱引用将不会在下一个并发或完整 GC 周期之前被释放。

以股票服务器的缓存为例,假设我们知道如果特定客户访问TPKS,他们几乎总是会再次访问它。基于客户端连接保持该股票的值作为强引用是有道理的:它将始终对他们可用,而一旦他们登出,连接就清除了并且回收了内存。

现在,当另一个用户需要TPKS的数据时,他们将如何找到它?由于对象在内存中的某处,我们不希望再次查找它,但连接基础的缓存对第二个用户不起作用。因此,除了基于连接保持TPKS数据的强引用外,在全局缓存中保持该数据的弱引用也是有意义的。现在第二个用户将能够找到TPKS数据——假设第一个用户没有关闭他们的连接。(这是在“堆分析”中使用的场景,数据有两个引用,不容易通过查看具有最大保留内存的对象找到。)

这就是所谓的同时访问。这就像我们对 JVM 说的:“嘿,只要有人对这个对象感兴趣,请告诉我它在哪里,但是如果他们不再需要它,请将其丢弃,我会自己重新创建它。”与软引用相比,后者基本上是说:“嘿,尽量保持这个对象在内存中,只要有足够的内存,并且似乎偶尔有人访问它。”

不理解这个区别是在使用弱引用时最常见的性能问题。不要误以为弱引用和软引用类似,只是释放得更快:一个被软引用引用的对象将可用(通常)几分钟甚至几小时,但一个被弱引用引用的对象只有在其引用对象仍然存在时才可用(取决于下一个 GC 周期清除它)。

Finalizers 和 final references

每个 Java 类都从 Object 类继承了一个 finalize() 方法;该方法可用于在对象符合 GC 条件后清理数据。这听起来像是一个不错的功能,而且在某些情况下是必需的。然而,在实践中,它被证明是一个糟糕的想法,你应该尽量避免使用这个方法。

Finalizer 是如此糟糕,以至于在 JDK 11 中 finalize() 方法已被弃用(尽管在 JDK 8 中没有)。我们将在本节的其余部分详细讨论为什么 finalizer 是糟糕的问题,但首先,让我们先有点动力。Finalizer 最初是为了解决 JVM 管理对象生命周期时可能出现的问题而引入 Java 中的。在像 C++ 这样的语言中,当你不再需要对象时,必须显式地销毁对象,对象的析构函数可以清理对象的状态。在 Java 中,当对象因超出作用域而自动回收时,finalizer 充当了析构函数的角色。

例如,JDK 中的类在操作 ZIP 文件时使用了一个 finalizer,因为打开 ZIP 文件会使用分配本地内存的本地代码。当 ZIP 文件关闭时,该内存会被释放,但如果开发者忘记调用 close() 方法会发生什么呢?即使开发者忘记了,finalizer 也可以确保已调用 close() 方法。

JDK 8 中许多类都像这样使用 finalizer,但在 JDK 11 中,它们全部使用了一个不同的机制:Cleaner 对象。这将在下一节中讨论。如果您有自己的代码,并且有意使用 finalizer(或者在不可用清理器机制的 JDK 8 上运行),请继续阅读以了解如何处理它们。

最终器(Finalizers)因为功能原因不好,并且对性能也有影响。最终器实际上是一个不定引用的特殊情况:JVM 使用一个私有引用类(java.lang.ref.Finalizer,它又是一个 java.lang.ref.FinalReference)来跟踪那些定义了 finalize() 方法的对象。当分配一个定义了 finalize() 方法的对象时,JVM 会分配两个对象:对象本身和一个 Finalizer 引用,该引用使用对象作为其引用物。

与其他不定引用一样,至少需要两个 GC 周期才能释放不定引用对象。然而,在这里的惩罚要比其他不定引用类型大得多。当软引用或弱引用的引用物变得可回收时,引用物本身立即被释放;这导致了先前在 图 7-8 中展示的内存使用情况。软引用或弱引用被放置在引用队列上,但引用对象不再引用任何东西(也就是说,其 get() 方法返回 null 而不是原始引用物)。对于软引用和弱引用,两个 GC 周期的惩罚仅适用于引用对象本身(而不是引用物)。

对于最终引用,情况并非如此。Finalizer 类的实现必须访问引用物以调用引用物的 finalize() 方法,因此当最终器引用放置在其引用队列上时,引用物无法被释放。当最终器的引用物变得可回收时,程序状态由 图 7-9 反映。

当引用队列处理最终器时,Finalizer 对象(通常)将从队列中移除,然后可以进行回收。只有这时候引用物才会被释放。这就是为什么最终器对垃圾收集的性能影响要比其他不定引用大得多——引用物所消耗的内存可能比不定引用对象本身消耗的内存更多。

不定引用内存使用的图示。

图 7-9. 最终引用保留更多内存

这导致了最终器的功能问题,即 finalize() 方法可能会无意中创建对引用物的新强引用。这再次导致 GC 性能损耗:现在引用物直到再次不再被强引用时才会被释放。功能上,这会造成一个大问题,因为下次引用物变得可回收时,其 finalize() 方法将不会被调用,引用物的预期清理也不会发生。这种错误足以成为尽量少用最终器的理由。

因此,如果不得不使用最终器,请确保对象访问的内存尽可能少。

存在一个避免至少某些这些问题的终结器的替代方法,特别是允许在正常 GC 操作期间释放参考对象。这是通过简单地使用另一种类型的无限制引用来实现的,而不是隐式使用Finalizer引用。

有时建议使用另一种无限制的引用类型:PhantomReference类。(事实上,这就是 JDK 11 所做的,如果你在 JDK 11 上,Cleaner对象会比这里呈现的示例更容易使用,这在 JDK 8 中真正有用。)这是一个很好的选择,因为在强引用不再引用参考物体之后,引用对象会相对快速被清理,而且在调试时,引用的目的是清楚的。尽管如此,使用弱引用也可以实现相同的目标(而且,弱引用可以在更多地方使用)。在某些情况下,如果软引用的缓存语义与应用程序的需求匹配,还可以使用软引用。

要创建一个替代的终结器,必须创建一个无限制引用类的子类,以保存在收集参考对象后需要清理的任何信息。然后,在引用对象的方法中执行清理(而不是在参考类中定义finalize()方法)。

这里是这样一个类的概述,它使用了弱引用。构造函数在这里分配了一个本地资源。在正常使用情况下,预计会调用setClosed()方法;这将清理本地内存。

private static class CleanupFinalizer extends WeakReference {

    private static ReferenceQueue<CleanupFinalizer> finRefQueue;
    private static HashSet<CleanupFinalizer> pendingRefs = new HashSet<>();

    private boolean closed = false;

    public CleanupFinalizer(Object o) {
        super(o, finRefQueue);
        allocateNative();
        pendingRefs.add(this);
    }

    public void setClosed() {
        closed = true;
        doNativeCleanup();
    }

    public void cleanup() {
        if (!closed) {
            doNativeCleanup();
        }
    }

    private native void allocateNative();
    private native void doNativeCleanup();
}

然而,弱引用也被放置在一个引用队列中。当从队列中提取引用时,可以检查确保本地内存已被清理(如果尚未清理,则进行清理)。

引用队列的处理发生在守护线程中:

static {
    finRefQueue = new ReferenceQueue<>();
    Runnable r = new Runnable() {
        public void run() {
            CleanupFinalizer fr;
            while (true) {
                try {
                    fr = (CleanupFinalizer) finRefQueue.remove();
                    fr.cleanup();
                    pendingRefs.remove(fr);
                } catch (Exception ex) {
                    Logger.getLogger(
                           CleanupFinalizer.class.getName()).
                           log(Level.SEVERE, null, ex);
                }
            }
        }
    };
    Thread t = new Thread(r);
    t.setDaemon(true);
    t.start();
}

所有这些都在一个private static内部类中,对使用实际类的开发人员隐藏起来,其外观如下:

public class CleanupExample {
    private CleanupFinalizer cf;
    private HashMap data = new HashMap();

    public CleanupExample() {
        cf = new CleanupFinalizer(this);
    }

    ...methods to put things into the hashmap...

    public void close() {
        data = null;
        cf.setClosed();
    }
}

开发人员构建此对象的方式与构建任何其他对象的方式相同。他们被告知调用close()方法,这将清理本地内存,但如果他们没有这样做,也没关系。弱引用仍然存在于幕后,因此在内部类处理弱引用时,CleanupFinalizer类有自己的机会清理该内存。

这个示例的一个棘手部分是需要使用pendingRefs这组弱引用。如果没有这些引用,弱引用本身将在能够将它们放入引用队列之前被收集。

这个示例克服了传统终结器的两个限制:它提供了更好的性能,因为与参考对象相关联的内存(在这种情况下是data哈希映射)在参考物体被收集后立即释放(而不是在finalizer()方法中执行),并且在清理代码中没有办法使参考对象复活,因为它已经被收集。

尽管如此,适用于使用 finalizers 的其他异议也适用于此代码:您无法确保垃圾收集器会释放引用对象,也无法确保引用队列线程会处理队列中的任何特定对象。如果有大量这些对象,处理引用队列将是昂贵的。像所有不确定引用一样,此示例仍应谨慎使用。

清理器对象

在 JDK 11 中,使用新的 java.lang.ref.Cleaner 类替代 finalize() 方法要容易得多。该类使用 PhantomReference 类在对象不再强引用时得到通知。这遵循与我刚刚建议在 JDK 8 中使用的 CleanupFinalizer 类相同的概念,但由于它是 JDK 的核心特性,开发者不必担心设置线程处理和自己的引用:他们只需注册清理器应处理的适当对象,让核心库来处理剩余的工作。

从性能的角度来看,这里的棘手部分是获取“适当”的对象来向清理器注册。清理器将保持对注册对象的强引用,因此该对象本身永远不会变为幽灵可达。相反,您创建一种影子对象并注册它。

举例来说,让我们看看 java.util.zip.Inflater 类。该类需要一些清理,因为它在处理期间必须释放分配的本地内存。当调用 end() 方法时执行此清理代码,并鼓励开发者在完成对象使用时调用该方法。但是,当对象被丢弃时,我们必须确保已调用 end() 方法;否则,我们将面临本地内存泄漏的风险。¹

伪代码中,Inflater 类看起来像这样:

public class java.util.zip.Inflater {
    private static class InflaterZStreamRef implements Runnable {
        private long addr;
        private final Cleanable cleanable;
        InflaterZStreamRef(Inflater owner, long addr) {
            this.addr = addr;
            cleanable = CleanerFactory.cleaner().register(owner, this);
        }

        void clean() {
            cleanable.clean();
        }

        private static native void freeNativeMemory(long addr);
        public synchronized void run() {
            freeNativeMemory(addr);
        }
    }

    private InflaterZStreamRef zsRef;

    public Inflater() {
        this.zsRef = new InflaterZStreamRef(this, allocateNativeMemory());
    }

    public void end() {
        synchronized(zsRef) {
            zsRef.clean();
        }
    }
}

此代码比实际实现更简单,后者(出于兼容性原因)必须跟踪可能重写 end() 方法的子类,并且当然本地内存分配更复杂。此处要理解的重点是,内部类提供了一个 Cleaner 可以强引用的对象。同时,也注册到清理器的外部类(owner)参数提供了触发器:当它只能幽灵可达时,清理器被触发,并且可以使用保存的强引用作为清理的钩子。

注意这里的内部类是 static 的。否则,它将包含对 Inflater 类本身的隐式引用,那么 Inflater 对象就永远无法变为幻像可达:从 CleanerInflaterZStreamRef 对象的引用始终是强引用,从而到 Inflater 对象的引用也是强引用。作为一个规则,将执行清理操作的对象不能包含对需要清理的对象的引用。因此,开发者不鼓励使用 lambda 表达式,而是使用类,因为 lambda 表达式再次很容易引用封闭类。

快速总结

  • 持续(软,弱,幻像和最终)引用改变了 Java 对象的普通生命周期,允许它们以可能比池或线程局部变量更符合 GC 友好的方式重复使用。

  • 当应用程序对对象感兴趣但只有在应用程序的其他地方有强引用时,应使用弱引用。

  • 软引用长时间保持对象,提供简单的 GC 友好的 LRU 缓存。

  • 持续引用会消耗自己的内存,并长时间保持其他对象的内存;应该节省使用。

  • 终结器是最初设计用于对象清理的特殊引用类型;现在鼓励使用新的 Cleaner 类,不再推荐使用终结器。

压缩 Oops

使用简单编程时,64 位 JVM 比 32 位 JVM 慢。这种性能差距是因为 64 位对象引用:64 位引用在堆中占用两倍空间(8 字节),而 32 位引用(4 字节)则不然。这导致了更多的 GC 周期,因为现在堆中的空间更少了,不能容纳其他数据。

JVM 可以通过使用压缩 oops 来补偿额外的内存。Oopsordinary object pointers 的缩写,它们是 JVM 用作对象引用的句柄。当 oops 只有 32 位长时,它们只能引用 4 GB 的内存( 2 32 ),这就是为什么 32 位 JVM 受限于 4 GB 堆大小的原因。(同样的限制也适用于操作系统级别,这就是为什么任何 32 位进程受限于 4 GB 地址空间的原因。)当 oops 是 64 位长时,它们可以引用 exabytes 的内存,远远超过您可能实际放入机器的量。

这里有一个折中方案:如果有 35 位 oops 呢?然后指针可以引用 32 GB 内存( 2 35 ),而在堆中占用的空间仍然少于 64 位引用。问题在于没有 35 位寄存器来存储这样的引用。不过,JVM 可以假设引用的最后 3 位都是 0。现在每个引用都可以在堆中以 32 位存储。当引用存储到 64 位寄存器中时,JVM 可以将其左移 3 位(在末尾添加三个零)。当从寄存器中保存引用时,JVM 可以将其右移 3 位,丢弃末尾的零。

这使 JVM 具有可以引用 32 GB 内存的指针,同时在堆中仅使用 32 位。但这也意味着 JVM 无法访问任何不可被 8 整除的地址的对象,因为从压缩 oop 中的任何地址结束都以三个零结尾。第一个可能的 oop 是 0x1,左移后变为 0x8。接下来的 oop 是 0x2,左移后变为 0x10(16)。因此,对象必须位于 8 字节边界上。

结果表明,在 JVM 中,对象已经对齐到 8 字节边界;这是大多数处理器的最佳对齐方式。因此,使用压缩 oops 并不会丢失任何东西。如果 JVM 中的第一个对象存储在位置 0 并占据 57 个字节,下一个对象将存储在位置 64——浪费了无法分配的 7 个字节。这种内存权衡是值得的(无论是否使用压缩 oops),因为给定 8 字节对齐,对象可以更快地访问。

但这就是 JVM 不试图模拟可以访问 64 GB 内存的 36 位引用的原因。在这种情况下,对象必须对齐到 16 字节边界,从堆中存储压缩指针的节省将被浪费在内存对齐的对象之间。

这有两个影响。首先,对于堆大小在 4 GB 到 32 GB 之间的情况,请使用压缩 oops。可以使用 -XX:+UseCompressedOops 标志启用压缩 oops;当最大堆大小小于 32 GB 时,默认情况下启用压缩 oops(在 “Reducing Object Size” 中指出,64 位 JVM 上使用 32 GB 堆的对象引用大小为 4 字节——这是默认情况,因为默认情况下启用了压缩 oops)。

其次,一个使用 31 GB 堆和压缩 oops 的程序通常比使用 33 GB 堆的程序更快。尽管 33 GB 堆更大,但该堆中指针使用的额外空间意味着该较大堆将执行更频繁的 GC 循环,并且性能更差。

因此,最好使用小于 32 GB 的堆,或者比 32 GB 大几 GB 的堆。一旦向堆添加额外的内存以弥补未压缩引用所占用的空间,GC 循环次数将减少。没有硬性规定指出在减轻未压缩指针的 GC 影响之前需要多少内存——但鉴于平均堆的 20% 可能用于对象引用,计划至少使用 38 GB 是一个良好的起点。

快速摘要

  • 默认情况下,只要它们最有用,压缩指针(Compressed oops)就会被启用。

  • 使用压缩指针的 31 GB 堆通常会胜过稍大的过大以至于无法使用压缩指针的堆。

摘要

快速的 Java 程序关键取决于内存管理。调整 GC 很重要,但要获得最大性能,必须有效地利用应用程序中的内存。

有一段时间,硬件趋势倾向于劝阻开发人员考虑内存:如果我的笔记本电脑有 16 GB 的内存,我对一个额外的未使用的 8 字节对象引用有多担心呢?在内存受限的容器云世界中,这种担忧再次显而易见。尽管如此,即使在大型硬件上运行具有大型堆的应用程序时,也很容易忘记编程的正常时间/空间权衡可能会转向时间/空间和时间权衡:在堆中使用过多空间可能会通过需要更多的 GC 使事情变慢。在 Java 中,管理堆始终很重要。

这种管理的主要重点在于何时以及如何使用特殊的内存技术:对象池、线程局部变量和不确定引用。明智地使用这些技术可以极大地提高应用程序的性能,但过度使用它们同样会降低性能。在数量有限——需要考虑的对象数量较少且有界——的情况下,使用这些内存技术可能非常有效。

¹ 如果不及时调用 end() 方法并依赖 GC 来清除本地内存,我们仍然会出现本地内存泄漏的情况;有关更多详情,请参阅第八章。