理解 JVM 垃圾回收器

摘自:原文:Understanding_Java_Garbage_Collection_v4
译者:黄俊


前言

垃圾回收器(GC)是 Java 平台上应用程序行为不可分割的一部分,但它经常被程序员误操 作而导致程序性能下降或者没有达到想要的效果。因此,Java 开发人员需要了解 GC 是如何 工作的,这样就可以根据应用程序的特性来选择和调优垃圾回收器,并以此来保证程序的运 行时性能、可伸缩性和可靠性。


提示:本文回顾并分类了目前 JVM 中使用的各种垃圾回收器和回收技术,并概述了常见的垃 圾回收技术和算法,定义所有回收器通用的术语和度量标准,包括:
1、Generational 分代
2、Parallel 并行
3、Stop-the-world 全局停止
4、Incremental 增量
5、Concurrent 并发
6、Mostly-concurrent 部分并发
本文对 JVM 主要使用的回收器机制和特征进行了分类,并讨论了在不同场景下如何平 衡响应性(延迟)、吞吐量、内存,也总结了一些关于垃圾回收行为的陷阱、常见的误解和 被人们口口相传却缺乏依据的“神话”操作,以及因为某些奇葩的选择如何导致令人印象深 刻的应用程序的诡异行为示例。

一、什么是垃圾回收?

Java 编程语言利用托管运行时(Java 虚拟机,也称 JVM)来提高开发人员的生产力并提供 跨平台可移植性。在不同的操作系统和硬件平台上,他们的管理内存的方式是不同的,于是 JVM 为开发人员提供了这样一个功能:在创建对象时自动分配内存,不再使用对象时自动释 放内存。这个释放未使用对象内存的过程称为“垃圾回收”(GC),由 JVM 在应用程序执行期 间在内存堆上执行。 JVM 垃圾回收对应用程序性能和吞吐量有很大的影响。随着 JVM 堆内存大小的增加, 应用线程必须暂停以允许 JVM 进行垃圾回收,这样就会导致应用程序停顿等待 GC 的时间也 不断增加,其结果就是导致程序长时间的暂停,这可能会延迟事务、降低应用程序吞吐量, 从而导致用户会话超时、迫使节点退出集群,或导致更严重的业务损失(例如收入下降或声 誉受损)等等不利因素。 本文详细地讲解了垃圾回收器的工作原理,以及商业上使用的 JVM 所使用的不同算法, 以及开发人员和架构师如何选择哪个垃圾回收器以使应用程序的性能最大化。

二、为什么你要学习垃圾回收器

整体上来说,垃圾回收器比你想象的要可靠好用得多,效率也更高。在分配内存方面,
它比 malloc()快得多,而且回收垃圾对象不需要任何成本(这是真的,因为如果能随时都知道 哪个对象是垃圾,那就相当简单对吧)。GC 将在没有任何开发人员帮助的情况下找到所有垃 圾对象,甚至循环引用的对象也是如此。但在许多方面,垃圾回收器比许多开发人员和架构 师所了解到的要诡异得多。
对于大多数回收器来说,GC 暂停时间与堆大小成正比,约为每回收 1GB 堆大小活动对 象需要增加大约 1 秒。因此,越大的堆(这对于大多数应用程序来说是常见的)意味着更长的 暂停时间。更糟糕的是,如果你运行了一个 20 分钟的测试,并调整到所有 GC 暂停都消失 了,那么很可能你只是将暂停移到了 21 分钟后(气不气)。因此不幸的是,暂停仍然会发 生,而你的应用程序将继续受到影响。此外,垃圾回收器的存在并不能消除内存泄漏——开 发人员仍然需要找到并修复。
好消息是 Java 确实提供了某种程度的 GC 的控制。开发人员和架构师可以通过调整 GC 参数来控制垃圾回收器的行为,从而调整应用程序性能。例如,在 c++中,每一个 null 值都 是有意义的,当不再需要使用对象时,在析构函数中释放内存然后赋值为 null。然而,在 Java 程序中,如果到处都使用 finalize 方式来释放内存的方式编码,这比什么都不写要糟糕得多, 如果每个类都使用 finalize 方法来释放不需要使用的引用,垃圾回收器可能必须在每个 GC 周期执行数百万个对象的 finalize 方法,最终导致垃圾回收暂停时间变得过长。这就意味着: 试图通过应用程序编程来解决垃圾回收是危险的。这需要大量的实践和理解才能做到,这些 时间本可以更好地用于创造更多有价值的功能。而且,即使你做出了所有正确的决策,应用 程序使用的其他代码也可能不会得到优化,或者应用程序的工作负载可能会随着时间的推移 而改变,你的应用程序仍然会包含与 GC 相关的性能问题。此外,由于不顾应用程序的特性, 去选择了错误的垃圾回收器或者使用了错误的配置,可能会大大增加暂停时间,甚至导致内 存不足的崩溃。正确理解垃圾回收和可用选项后,可以使你做出更明智的决策,从而提高应 用程序运行时性能和可靠性。

三、垃圾回收器的种类

垃圾回收器分为几种类型。对于每种类型,有些回收器又可以被分类为“mostly”(STW 的 GC)[作者:黄俊,B 站地址:https://space.bilibili.com/232459430],也叫“mostly concurrent” (部分 STW,部分和应用程序一起执行),也即部分并发 GC,比如 CMS 回收器。这意味着, 有时它并不是根据这样的类型来执行操作的,当某些情况发生时,它需要有一个后备机制, 比如 CMS 之后的 Serial GC 兜底(想象一下应用程序分配内存的速度大于垃圾回收速度会怎 样)。因此,部分并发的回收器可以与应用程序执行并发操作,并且只在偶尔需要时停止。 分类如下:
1、Concurrent Collector(并发回收器)——当应用程序执行时,并发地执行垃圾回收。
2、Parallel Collector(并行回收器)——使用多个线程并行回收
3、Stop-the-world (STW)——在应用程序完全停止时执行垃圾回收
4、Incremental(增量) ——将垃圾回收切割为一系列的增量执行,其中可能有很长的间隔。 应用程序在垃圾回收期间根据需要暂停,在两次增量之间运行,这也可以称之为夹缝中生存
5、Moving(对象转移) ——回收器在垃圾回收期间移动活动对象,并且必须更新对这些活动 对象的引用
6、Conservative(保守 GC) ——大多数非托管运行时都是保守 GC 的。在这种模型中,回收器 不能确定某个字段是否是引用,所以它假设它是引用。这与精确式回收器相反
7、Precise(精确 GC) ——精确回收器精确地知道每个可能的对象引用的位置。因为如果不精
确,回收器就不能成为进行对象转移,因为你必须知道在移动活动对象时要更新哪些引用。
精确回收器识别内存堆中的活动对象,回收死对象持有的资源,并定期重定位活动对象的位 置。
精确地说,虚拟机所做的大部分工作实际上都在编译器中,而不是回收器本身。今天所有的 商业 jvm 都是可对象转移且精确的。

四、垃圾回收的步骤

在垃圾回收器回收内存之前,它必须确保应用程序处于“GC 安全点”。GC 安全点是线 程执行中的一个点或一片区域,在这个点上回收器可以识别线程执行堆栈中的所有引用,此 时应用线程不应该再修改这些信息。术语“安全点”和“GC 安全点”通常可以互换使用。 存在许多类型的安全点,其中一些需要比 GC 安全点更多的信息,“全局安全点”是指所有 的应用程序线程都处于一个安全点上。代码中的安全点检测应该经常出现。如果垃圾回收器 必须等待几分钟(或更长)以外的安全点,那么应用程序可能会在垃圾回收之前耗尽内存并崩 溃。到达 GC 安全点后,就可以开始垃圾回收了。

1.Mark Phase(标记阶段)

此阶段也称为“跟踪”,直到堆中的所有活动对象。这个过程从“GC ROOT”开始,包 括线程栈、静态变量、来自 JNI 代码的特殊引用和其他可能找到活动对象的区域(比如年轻 代收集时,老年代就可以作为 GC ROOT)。如果对象在 GC ROOT 引用链上,那么对象就不 会被回收器回收。垃圾回收器将它能到达的任何对象标记为活动对象。在这一步结束时留下 的任何对象都是“垃圾对象”。如果开发人员认为任何对象已经失效,但仍然可以访问,那 就是对象泄漏(内存泄漏的一种形式)。标记的工作时间与活动对象和引用的数量是线性的, 与对象的大小无关。换句话说,标记器标记 1000 个 10KB 对象所花费的时间与标记 1000 个 1MB 的对象所用的时间相同。
在并发标记过程中,所有可到达的对象都被标记为活动的,但是如果对象在标记工作时 发生变化,这可能会导致标记错误,比如:应用线程可以将一个对象引用到一个已经访问过 的对象中,但是这个对象已经被标记过了,这时引用的这个对象也会被当做垃圾回收,导致 错误,也即漏标。如果不以某种方式截获或阻止此更改,则可能导致应用程序发生异常:对 象将被回收,即使对它的引用仍然存在。
通常使用“写屏障 ” 来 防止这种情况 。写障碍拦截对象引用的修改(例如记录在卡表中),有了这些信息,标记器就可以重新标记所有由于应用线程修改的引用的对象,当这些修改信息较 小时,可以使用 STW 暂停应用线程,来完成这些重标记工作,通过这种方式就可以使回收 器与应用程序部分并发执行。但需要注意的是,回收器对应用线程的更改堆的操作很敏感, 完成的回收时间会随着应用程序所更改的信息增加而增加,最后可能导致无法完成 GC 工作 然后发生 Full GC。

2.Sweep Phase(清除阶段)

在这个阶段,垃圾回收器扫描堆以识别“死亡”对象的位置,通常以空闲列表的方式并 回收这些对象。与标记阶段不同,清除阶段所做的工作与堆的大小是线性的,而不是与活动 对象的大小相关,扫描仍然需要扫描整个堆。

3.Compact Phase(压缩阶段)

随着时间的推移,Java 内存堆变得“碎片化”,对象之间的碎片空间不足以存放新对象, 这使得新对象分配更慢甚至失败。如果你的应用程序创建的对象大小不一致,那么碎片化的 速度会更快。XML 就是一个很好的例子。格式是定义了的,但对象中信息的大小是不受控制 的,这通常会导致对象大小变化很大,并且会产生碎片化的堆。
在压缩阶段,垃圾回收器重定位所有的活动对象以获取连续的空间。当这些对象被移动 时,回收器必须修复线程中对这些活动对象的所有引用,称为“重映射”。重映射必须覆盖 所有指向对象的引用,所以它通常会扫描所有对象。这一阶段所做的工作量通常与活动对象 的数量成线性关系。
增量压缩在几个商业回收器(Oracle G1 和 IBM Balance)得以实现。这种技术总是假设某 些内存区域比其他区域热点更高,但因为应用程序的不同,情况并非总是如此。GC 算法通 过记忆集来跟踪跨区域的对象引用(即哪个区域指向哪个区域)。这允许回收器一次压缩单个 区域,并且在重映射更新引用时,只扫描指向该区域的区域。这样回收器就可以识别满足设 定的有限暂停时间的区域集(G1 中这称之为 Collected Set),从而允许控制应用程序暂停的 最大时间。指向单个区域的对象数量往往与堆的大小成线性关系,因此压缩的工作量会随着 堆大小的增加而增加。

4.垃圾回收器的分类

1、Mark/Sweep/Compact Collector——将这三个阶段作为三个独立的步骤执行
2、Mark/Compact Collector——跳过清除阶段,直接将活动对象移动到堆的连续区域
3、Copying Collector——在一次 GC 工作中执行所有三个阶段。它使用 from 和 to 空间,移 动所有活动对象,然后一次性更新所有引用。当 from 区域为空时,表示回收已完成。复制 回收器中所做的工作时间与活动对象的大小和数量成线性关系。
在这里插入图片描述

5.分代回收器

分代回收器基于大多数对象朝生夕死的假设,也即应用程序创建了它们,但很快就不再 需要它们了。通常,一个方法会创建许多对象,但不会将它们存储在字段中。当方法退出时, 这些对象就准备好被垃圾回收了。开发者可以设置一个“分代过滤器”,以减少分配给老年 代的对象比例,比如使用对象年龄。这个过滤器目前是提高应用程序吞吐量的唯一方法,因 为应用程序创建对象的速度要比垃圾回收它们的速度快得多。对于适用于这种假设的应用程 序,将垃圾回收工作集中在年轻代上是有意义的,并将寿命足够长的对象提升到“老年代”, 因为后者在被回收时可以被垃圾回收得更少。这些年轻代对象很快就会死亡,所以年轻代中 的活动对象只占可用空间的一小部分,因此重定位活动对象的回收器是有意义的,因为我们 有空间放置活动对象,并且所消耗的时间与活动对象的大小是线性相关的,而又因为活动对 象很少,那么耗费的时间也很少。分代回收器通常不需要活动对象集两倍的内存,因为对象 可能会晋升到老年代空间。这弥补了复制回收器的主要缺点,因此年轻代可以在回收前完全 被填满。
决定何时提升对象是极大影响提高回收效率的因素。在年轻代中保存对象的时间长一点 可能会让更多对象死亡,从而节省回收时间。如果你把它们保留得太久,年轻代就会失去空 间,或者完全破坏朝生夕死的假设,但是等待提升时间过长又会显著增加复制活动对象所需 的工作,从而增加执行 GC 所需的时间,所以在调优时需要灵活考虑这个因素。

6.Remembered Set(记忆集)

分代回收器使用“记忆集”从外部跟踪对年轻代的所有引用,因此回收器不必全部扫描 这些外部引用了年轻代的区域,只需要扫描 RSet 的区域即可,当然 RSet 必然也用作垃圾回 收器的“GC ROOT”的一部分,一种常见的技术是“卡表标记”,它使用一个位(或字节)来 表明老年代中的一个字或一个区域有对象指向年轻代的引用。这些“标记”可以精确也可以 不精确,这意味着它可能记录下确切的位置,或者只是其中的一个区域。写障碍用于跟踪从 年老代到年轻代的引用,并使记忆的集合保持最新。Oracle 的 HotSpot 使用了所谓的“blind store”,每次你存储一个引用,它就会标记一张卡片为脏,这工作得很好,因为检查引用 需要更多的 CPU 时间,所以系统通过标记卡片节省时间。

7.商业版实现

1.开发人员和架构师可以做什么

首先,理解应用程序的特征和垃圾回收工作原理的基本知识。

2.垃圾回收度量标准

应用程序的许多特性将直接影响运行时的垃圾回收和应用程序的性能,首先我们先了解 一下,有哪些指标。首先是了解应用程序在内存中分配对象的速度,也称为对象分配率。你 曾经遇到过一个对象完全朝生夕死的应用程序吗?或者你曾经遇到过很多对象需要一直存 活的应用程序吗?当然没有这样的程序,所以我们需要分而治之,每一代采取不同的算法。 你的程序也可以在内存中更新对象的引用,那么更新的这些引用的速度称为更改率,更改率 一般与应用程序所做的工作量成正比。最后,随着对象的创建和消亡,那么就存在一张存活 对象图,也即从 GC ROOT 一直能跟踪到的对象,他们组成一张对象图,这些对象也称之为 堆对象存活率,而这些对象所占有的空间也形成了堆的形状:比如规整或者碎片堆。
标记时间和压缩时间是决定总体垃圾回收时间的最重要的指标。标记时间是回收器在堆 上找到所有活动对象所需的时间,压缩时间是指重定位对象并释放他们占用的内存所需的时 间,当然这只针对于 Mark/Compact 的回收器。对于 Mark/Sweep/Compact 回收器来说,Sweep 所消耗的时间也很重要,它表明回收器定位所有死对象所花的时间,回收器完成一次垃圾回 收的时间指的是:从开始垃圾回收,直到释放内存并获得可用内存的总时间。

3.GC 时所需要的的空闲内存

垃圾回收器需要一定数量的空内存才能工作,充足的空闲内存能使垃圾回收器更容易 (和更快)地执行任务,将空闲内存加倍,那么回收器完成的工作将减半,且运行所需的 CPU 消耗也减半,这通常是提高性能的最简单有效的方法。当然,这里有几个非常直观的悖论: 如果我们有无限的空闲内存,我们就永远不需要回收,GC 也永远不会消耗任何 CPU 时间, 如果我们在任何时候都恰好有 1 个字节的空闲内存,回收器将不得不一直不间断的工作去取 得空闲内存,GC 将占用 100%的 CPU 全部时间。所以,总的来说,在这两个限制之间,垃 圾回收 CPU 时间大致遵循 1/x 的曲线,随着空闲内存的增加,GC 的压力也相应的减少。
Mark/Compact 和 Copy 回收器的回收时间与活动对象的大小线性相关。每一次垃圾回收 需要多久,取决于空闲内存的数量。因为每次回收的回收的对象数量几乎是固定的,所以减 少回收的次数会更有效率。注意:在这两种类型的回收器中,可用的空闲内存的大小并不控 制垃圾回收暂停的时间,而只控制垃圾回收的频率。而对于 Mark/Sweep/Compact 而言,它 的回收时间随着堆的大小增长而增长。
所以,对于需要 STW 暂停工作线程去扫描的回收器 来说,更多的空内存意味着更少的回收频率,但却需要更长的暂停时间。 所以如果我们充分了解了应用程序的特性,那么我们就可以进行改进应用程序的性能、 可伸缩性和可靠性。

4.GC 策略:推迟不可避免的事情

虽然压缩在实际场景下是不可避免的,但许多 GC 调优技术关注点是尽可能延迟需要长 时间 STW 的完全压缩的时机,并尽可能快地释放容易回收的内存域,比如存活对象较少的 区域。分代的垃圾回收器可以部分延迟不可避免的事情,因为它可以经常回收年轻代对象, 这不会花费太多时间,但随着时间的推移,老年代的空间必须通过一个全局 STW 的压缩来 回收利用空闲内存。另一种延迟策略是执行并发标记和扫描、清理,但跳过压缩阶段,释放 的内存可以在空闲列表中跟踪并重用,而不需要移动活动对象。但随着时间的推移,这将导 致碎片化,
最后导致压缩。 最后,一些回收器依赖于将堆分割为不同的区域,根据设定的停顿时间动态调整区域的 大小,部分区域采取部分压缩的策略来减少内存碎片,由于压缩区域的减少从而也减少了垃 圾回收时间。然后随着时间推移,如果大量对象均未释放或者应用线程分配对象的速率超过
增量 GC 回收内存的速度,最终也会来一次全局 STW 来压缩内存。
最终结论是:因为有通过许多技术和创新的方法去减少垃圾回收的暂停时间,所以大多 数商业垃圾回收器之间的竞争是不可避免的,这就要求开发人员和架构师在需要时做出正确 的决策,决定使用哪种回收器来最大化应用程序性能。

5.选择垃圾回收器

下表总结了一些流行的商业垃圾回收器
在这里插入图片描述

6.流行的商业垃圾回收器

Oracle’s HotSpot Parallel
GC 这是 HotSpot 的默认回收器。它的年轻代使用了一个全局的、STW 的复制回收器和一个 全局的、STW 的老年代的 Mark/Sweep/Compact 回收器。
Oracle’s HotSpot CMS
并发标记/Sweep 回收器(CMS)是 HotSpot 中的一个选项。它试图通过在不压缩的情况 下,标记和清除老年代来尽可能地减少老年代的暂停时间,一旦老年代产生了太多碎片,他 们就会退回到使用一个全局的、STW 的压缩算法。
CMS 主要执行并发标记。它在应用程序运行时同时进行标记,并同时跟踪引用线程对 于已标记过的对象引用的更改。CMS 还执行并发清理,这将释放垃圾对象所占用的空间, 并创建一个用于分配新对象的空闲列表,当对象不能从年轻代晋升到老年代或并发标记失败 时,系统将抛出 promotion failure 消息,然后执行压缩算法,这可能导致应用暂停一秒或更 长时间。
年轻代使用一个全局的、STW 的复制回收器,就像 ParallelGC 一样。
Oracle’s HotSpot G1GC (Garbage First)
G1 是 HotSpot 中的一个选项。它的目标是避免或者说尽可能减少 Full GC 的发生。G1 的老年代使用了一个“大部分”并发标记的算法,它尽可能多地与应用线程并发完成标记工 作,然后使用一个短暂的 STW 来处理应用线程对象引用更改的处理。G1 在 RSet 中保存区域 间的引用关系,而不是使用细粒度的对象列表,它尽可能地对访问频率较多的对象和区域使 用 STW、增量压缩和延迟压缩。当需要的时候,G1 又回到了全局的、STW 的完全压缩算法。
它的年轻代使用一个全局的、STW 的复制回收器,就像 ParallelGC 和 CMS 一样。
Azul Systems Zing C4 或者 ZGC
C4(连续并发压缩回收器)是 Azul 系统的默认的垃圾回收器。C4 同时适用于年轻代和年 老代。与其他算法不同的是,它不是“大部分”并发的,而是完全并发的,因此它永远不会 退回到 STW 压缩算法。C4 使用 Load Value Barrier (LVB)读取对象时验证每个堆引用是否正确, 任何指向重定位对象的堆引用的访问操作都会被捕获,并让这个线程在这个屏障中进行自我 修复,也即对象的重定位。C4 有单独的并发标记线程,无论你的应用程序改变堆的速度有 多快,C4 都能跟上。C4 回收器还并发执行引用处理(包括弱引用、软引用和 final 引用),对 象的重定位和重映射都是并发执行的。C4 还使用“快速释放”的方式,使已释放的内存能 够快速地用于应用程序和对象的重新定位,这就支持不需要空闲内存就能运行的“手动”压 缩。
在这里插入图片描述
在这里插入图片描述

8.GC 调优的观察

大多数回收器的垃圾回收调优都很难做到一定正确,即使你理解了应用程序的特性。下 图显示了 HotSpot JVM 中 CMS 回收器的两组调优参数。虽然它们可能使用相似的参数,但 它们是非常不同的,甚至在某些领域是完全相反的。然而,应用程序的性能可以通过任意一 种设置进行优化,这取决于其特定的特性。对于大多数垃圾回收器来说,没有“一刀切”的 答案存在。开发人员和架构师必须仔细调优,并在每次应用程序、环境或预期的负载变化时 重新调优。错误地获取这些参数可能会在峰值负载时间导致意外的长时间暂停。然而,Azul Zing C4 上运行的应用程序性能“通常”对调整参数不敏感,因为它在年轻代和年老代中都 是并行标记和压缩的,所以唯一重要的参数是堆大小。
当你在 Zing 运行时中设置堆大小时,C4 回收器自动计算它需要的 GC 时间,以跟上分 配率,不需要设置 GC 期望时间,Zing 的 C4 GC 将在需要时使用与应用程序线程分离的线程 工作,与其他 GC 策略相比,这允许将最坏情况下的暂停时间降低几个数量级。随着 GC 的 退出,到线程安全点的时间将成为下一个主要的延迟来源,当然我们可以使用其他方式来实 现更低的延迟,通常通过操作系统或者通过 JIT 编译器在 JVM 中调整。


总结

垃圾回收(GC)是 Java 平台上应用程序行为不可分割的一部分,但它经常被误解。Java 开 发人员可以通过理解 GC 的工作方式和做出更好的垃圾回收器选择来提高应用程序的性能、 可伸缩性和可靠性。大多数垃圾回收器的主要缺点是需要长时间的应用程序暂停,这些暂停 是不可避免地,因为我们需要压缩堆以释放垃圾对象占用的空间。回收器使用不同的策略来 延迟这些需要长时间 STW 的操作,但是压缩对于所有商业可用的回收器都是不可避免的, 除了 Azul C4,它使用连续并发压缩回收器,避免了所有的暂停。当然,我们目前已经有了 堪比 C4 的 ZGC,最终我们的 JVM 会变成不需要我们进行 GC 调优,一切交给垃圾回收器即 可。
最后配上一个神图:
在这里插入图片描述

  • 12
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 22
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 22
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值