1 前言
这篇文章将介绍垃圾收集算法的具体落地方案——垃圾收集器。介绍经典垃圾收集器,是因为这些垃圾收集器已经经过各个公司的实际应用,得到了生产的验证,属于可以放心使用的垃圾收集器,所以更有介绍的价值。
各款经典垃圾收集器之间的关系如下
这里先前置我的一个观点:不存在最好,可以统一江山的垃圾收集器,至少目前为止这个“王者”尚未出现,所以我们需要针对自己的实际场景选择最适合的垃圾收集器。
观点证明:如果真有这样一个“王者”,那么 HotSpot 虚拟机一定会废弃所有“Out”的垃圾收集器,不再保留这么多垃圾收集器。
下面是 OpenJDK20 仍然保留且推荐使用的垃圾收集器:
- G1
- 优点
- 能够预测性的暂停时间控制
- 并行和并发处理
- 适用场景
通用的服务器应用程序,以前适用 CMS 的场景基本都可以用 G1 取代。使用 G1,推荐内存4GB以上,这样才能更好发挥 G1 的性能。
- 优点
- ZGC
- 优点
- 极低的停顿时间,通常在数毫秒内
- 支持大堆内存(TB 级)
- 并发标记和压缩
- 适用场景
要求极低延迟的应用,特别是那些拥有大内存(建议16GB以上)的应用,但是管理内存的上限是4TB。
- 优点
- Shenandoah GC
- 优点
- 低停顿时间,通常小于 10 毫秒
- 并发标记和压缩
- 更快速的堆内存回收
- 适用场景
要求低延迟和快速内存回收的应用。
PS:这里咋一看感觉 Shenandoah 可以干掉 G1,但是它们的使用场景还是有一定区别的。G1并不是一味地追求低停顿,它会平衡考虑低停顿和吞吐量的问题(这个是可以调节的);而 Shenandoah 追求的就是极致的低停顿了,如高频交易系统、在线游戏和实时响应的服务等等。
- 优点
- Serial + Serial Old
- 优点
- 实现简单
- 单线程处理,适用于单处理器机器
- 适用场景
小型应用程序或单线程应用。
- 优点
- Parallel Scavenge + Parallel Old
- 优点
- 高吞吐量
- 多线程垃圾收集
- 优点
- 适用场景
吞吐量优先的应用程序。
2 Serial 收集器
Serial 收集器是最基础,历史最悠久的收集器,在 JDK1.3.1 之前是 HotSpot 新生代收集器的唯一选择。
特点:单线程工作的收集器,它一旦运行,会暂停除它自己以外所有线程,直到它完成垃圾收集。
虽然 Serial 是最早出现的收集器,但是它依然保有自己一席之地。其中原因就是“相较其他收集器单线程下,更简单、更高效”。对于内存资源受限的环境,它是所有收集器里额外内存占用最小的;另外,处理器核心数较少的环境下,由于 Serial 没有线程交互的额外开销,因此收集效率是最高的。
一般来说,如果分配给新生代的内存在一两百兆以内,Serial 进行垃圾收集而导致的停顿最多就一百多毫秒,只要发生不频繁,对用户的影响就可以忽略不计。
3 ParNew 收集器
ParNew 实质上是 Serial 的多线程并行版本,ParNew 除了多线程进行垃圾收集以外,其余的都和 Serial 一样的,比如控制参数(-XX:SurvivoRatio、-XX:PretenureSizeThreshold、-XX:HandlePromotionFailure等)、收集算法、需要 Stop The World 的地方、对象分配规则、回收策略等都与 Serial 收集器完全一致。
在实际工作中,很长一段时间中很多企业级别 BS 系统都是采用了 ParNew + CMS,因为在中国的公司很长一段时间采用的都是 JDK6 和 JDK8,随着 G1 等低延迟垃圾收集器的成熟逐渐退出了市场。
4 Parallel Scavenge
Parallel Scavenge 也是一款新生代收集器,它是基于复制算法实现的。
Parallel Scavenge 的诸多特性从表面上看和 ParNew 非常相似,下面是它自己的特点:Parallel Scavenge 的关注点和其他收集器不同,CMS 等收集器是希望尽可能地缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 则时达到一个可控制的吞吐量,也就是处理器用于运行用户代码的时间和处理器总消耗时间的比值。
吞吐量 = 运行用户代码时间 ➗ (运行用户代码时间 + 运行垃圾收集时间)
高吞吐量的好处是可以最高效地利用处理器资源,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的分析任务。
5 Parallel Old
Parallel Old 可以配合 Parallel Scavenge 使用,采用的是标记-整理算法,支持并行收集。顾名思义前者负责处理老年代,后者负责新生代。
6 Serial Old
Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用标记-整理算法。
前面已经介绍过了,它可以和 Serial 或者 Parallel Scavenge 搭配使用,它还有另一种用法就是作为 CMS 收集失败时的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
7 CMS
CMS 是一个很重要的里程碑,理应了解它。但是 JDK9 开始不再推荐使用 CMS,这个需要注意。
CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS 是基于标记-清除算法实现的,它的收集垃圾的整个过程分为四个步骤,包括:
- 初始标记(需要 Stop The World)
初始标记仅仅是标记 GC Roots 能直接关联到的对象,速度很快。 - 并发标记
并发标记阶段就是从 GC Roots 的直接关联对象开始遍历整个对象图的过程。这个过程耗时较长但是不需要停顿用户线程。 - 重新标记(需要 Stop The World)
这个阶段主要是为了修正 并发标记 期间因用户继续运作而导致标记产生标动的那一部分对象的标记记录(这部分记录是 CMS 采用了增量更新记录的)。重新标记 的耗时比 初始标记 稍长,但也远比 并发标记 短。 - 并发清除
清理被判断为已经死亡的对象,由于不需要移动存活对象(因为采用了标记-清除算法),所以这个阶段是可以和用户线程并发运行的。
CMS 的优点,上面已经都体现出来了,这里再总结下:并发收集、低停顿。
CMS 是HotSpot 追求低停顿的第一次成功尝试,正因为是第一次,所以它还有许多地方不够完善,明显的缺点至少有三个:
- CMS 对处理器资源非常敏感(事实上,面向并发设计的程序都对处理器资源比较敏感)
并发阶段虽然不会停止用户线程,但是会因为占用 CPU 资源而导致降低总吞吐量,从而导致用户程序变慢。CMS 默认的 回收线程数 =(CPU核心数 + 3) / 4
。因此,如果处理器的核心数在 4 个或者以上,并发回收垃圾的线程只占用不少于 25% 的处理器资源,核心数越多,对用户程序的影响就越小;如果核心数不足 4 个时,对用户程序的影响就可能变得很大。另外,如果本来用户程序就是 CPU 敏感的程序,那么还要分一部分运算能力去执行收集器线程,就可能导致用户程序的执行速度忽然大幅度下降。 - CMS 无法处理“浮动垃圾”,有可能出现“Concurrent Mode Failure”失败从而导致出现完全“Stop The World”的 Full GC 产生(前面也说过 Serial Old 是 CMS 发生“Concurrent Mode Failure”的预备方案,这个 Full GC 就是使用 Serial Old 进行垃圾回收)
并发清除阶段,用户线程仍在执行,就会生成新的垃圾对象,这些垃圾对象需要等待下一次 GC 方可清理,这些垃圾对象被成为“浮动垃圾”。另外,垃圾收集时用户线程需要持续运行,那就意味着需要预留足够的内存空间给用户程序使用。因此,CMS 不能等待老年代几乎填满了再来收集回收,需要通过 -XX:CMSInitiatingOccu-pancyFraction 设置阈值。这曾经是很考验程序员的地方,设置的好,可以减少内存回收的频率,获得更好的性能。 - CMS 采用的是“标记-清除”,会导致产生空间碎片,过多的空间碎片会导致无法分配内存给大对象,从而触发 Full GC(Serial Old 进行垃圾回收)
8 G1(Garbage First)
G1 开创了收集器面向局部收集的设计思路和基于 Region 的内存布局形式。从 JDK9 开始,G1 取代了 Parallel Scavenge + Parallel Old 组合,而 CMS 则被声明为不推荐使用的垃圾收集器。
G1 有一个重大目标:实现“停顿预测模型”(Pause Prediction Model)。
停顿预测模型 是指 “能够支持指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过 N 毫秒” 的目标,这已经是实时 Java(RTSJ)中的实时垃圾收集器特征了。
为了实现这个“停顿预测模型”,很重要的改变之一就是:“G1 不再区分新生代,老年代,分开管理这两个逻辑划分的内存区域,等到实在不行了(触发 Full GC 的各种情况),再来搞个 Full GC。G1 是面向堆内存任何部分来组成回收集(Collection Set,一般简称 CSet)进行回收,G1 以“哪个内存块垃圾对象最多,回收收益最大”作为衡量标准,这就是 G1 的 Mixed GC 模式。”
G1 能实现“停顿预测模型”的关键就是因为它实现了基于 Region 的堆内存布局。虽然 G1 仍是遵循分代收集理论设计的,但是起堆内存的布局与其之前的收集器有非常明显的差异:G1 不再坚持固定大小以及固定数量的分代区域划分,而是把连续的 Java 堆划分为多个大小相等的独立区域(Region),每个 Region 都可以根据需要去扮演 “新生代的 Eden、Survivor” 或者 “老年代”。收集器能够对扮演不同角色的 Region 采用不同的策略去处理。这样无论是新对象,还是存活了一段时间、熬过多次收集的旧对象都能获取很好的收集效果(简单地说就是对症下药)。
Region 中还有一类特殊的 Humongous 区域,专门用来存储大对象。G1 认为只要大小超过了一个 Region 容量的一半的对象既可以判定为大对象。每个 Region 的大小可以通过参数 -XX:G1HeapRegionSize
设定,取值范围为 1MB~32MB,且应为 2 的 N 次幂。而对于那些超过了整个 Region 容量的超级大对象,将会被存放在 N 个连续的 Humongous Region 之中,G1 的大多数行为都把 Humongous Region 作为老年代的一部分来进行看代。
虽然 G1 仍保留新生代和老年代的概念,但是新生代和老年代的内存区域不再是固定的,他们只是一系列区域的动态集合(不需要连续的内存区域)。因为 G1 将 Region 作为单次回收的最小单元,这样可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集,所以 G1 能够建立 “停顿预测模型”。具体思路是让 G1 记录各个 Region 里的垃圾堆积的“价值”大小,即回收所获得的空间大小及回收所需时间的经验值,然后后台维护着一个优先级列表,每次根据用户所设定的收集停顿时间(使用 -XX:MaxGCPauseMillis
指定,默认是 200 毫秒),优先处理回收价值最高的那些 Region,这也是为什么叫 "Garbage First" 的原因。
这种内存布局加上具有优先级的区域回收策略,保证了 G1 在有限的时间内获取尽可能高的收集效率。
G1 全局来看是基于 “标记-整理算法”,局部上看采用的则是 “标记-复制算法”。因此,G1 不会产生空间碎片。
这个看似不复杂的方案里面有很多关键细节是需要好好处理的,包括且不限于以下的问题:
- Region 里面存在的跨区域引用对象如何解决?
这个问题我们可以采用 记忆集 解决,但是由于 G1 的 Region 内存布局,所以每个 Region 都需要维护自己的记忆集才行(进行回收 Region 时需要知道哪些 Region 指向当前回收的 Region,并标记这些指针分别在哪些卡页的范围内)。G1 的记忆集在存储结构上本质是哈希表,Key 是别的 Region 的起始地址,Value 是一个集合,里面存储的元素是卡表的索引号(自己指向谁)。这种“双向”卡表结构的设计及实现复杂度大大上升。另外,由于 Region 数量较多,所以 G1 所需的内存资源比过去传统的分代回收的垃圾收集器要更多。据前人的经验总结,G1 至少要耗费相当于 Java 堆容量的 10% ~ 20% 的额外内存来维持收集工作。 - 在并发标记阶段如何保证收集线程与用户线程互不干扰地运行?
这个问题首先要解决的是“用户线程改变对象引用关系时,必须保证其不能打破原本的对象关系图”。CMS 采用的 增量标记 是一种方案,而 G1 采用的是 SATB(原始快照) 实现的。
另外,在内存分配上要避免新创建的对象出现在回收范围内(这里指的不是改变对象关系图,而是要避免影响回收 Region 的速度,比如复制清除算法,那多一个存活对象就要复制多一个)。G1 为每个 Region 设计了两个 TAMS(Top at Mark Start)的指针,把 Region 中的一部分空间划分出来用于并发回收过程中创建的新对象(G1 回把这部分对象直接标记成存活对象,当前回收是不会处理这些对象的),这些对象地址都必须在这两个指针上。如果回收速度赶不上内存分配速度,那么 G1 也要 “Stop The World” 进行长时间的 Full GC。 - 怎样建立可靠的停顿预测模型?
用户通过-XX:MaxGCPauseMillis
指定停顿时间,这个时间只是期望值,但是 G1 要怎么才能实现满足这个期望值呢?G1 的停顿预测模型是以衰减均值(衰减均值对新数据更为敏感,可以更好地体现“最近的平均状态”,后台维护的 Region 的回收价值更为真实)为理论基础实现的,在垃圾收集过程中,G1 会记录每个 Region 的回收耗时、每个 Region 记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得到平均值、标准偏差、置信度等统计信息。因此,通过这些信息预测现在开始回收的话,可以更为准确知道回收哪些 Region 组成的回收集能够在不超过期望停顿时间的约束下获得最高收益。
在不考虑用户线程如何使用 写屏障 维护记忆集的操作,G1 的整个收集运作过程大致可以划分为以下四个步骤:
- 初始标记
仅仅是标记 GC Roots 能直接关联到的对象,并且修改 TAMS 指针的值,让下一阶段用户线程并发运行时,能正确地在可用的 Region 中分配新对象。这个阶段需要停顿用户线程,但耗时很短,而且是借用进行 Minor GC 的时候同步完成的。 - 并发标记
从 GC Roots 直接关联的堆对象进行可达性分析,递归扫描整个堆里的对象图,找出要回收的对象,这个阶段耗时很长,不过可以与用户线程并发执行。另外,因为是和用户线程并发执行的,所以还需要使用 SATB 记录下的在并发时有引用变动的对象。 - 最终标记
对用户线程做一个短暂的暂停,用于并发阶段结束后仍遗留下来的最后那少量的 SATB 记录。 - 筛选回收
负责更新 Region 的统计数据,对各个 Region 的回收价值和成本进行排序,根据用户设置的“期望停顿时间”制定回收计划,可以自由选择任意多个 Region 构成回收集,然后把决定回收的那部分 Region 里的存活对象复制到空的 Region 中,再清除整个旧 Region。这里的操作涉及 移动存活对象,所以需要暂停用户线程,由多个收集线程并行完成。
这也不难看出 G1 并不是一味地追求低延迟,它希望的是在满足用户设定目标的同时尽可能地提高吞吐量。因此,这里需要注意设置“期望停顿时间”,这个值设置必须是符合实际情况的,毕竟 G1 是要停止用户线程来复制对象的,这个停顿时间再怎么低也是有限度的。它的默认值是 200 毫秒,已经符合大多数应用场景了,如果强硬改的更小,比如几十毫秒之类的,那么很有可能就是回收效果不理想,导致垃圾慢慢堆积,运行时间长了,从而引发 Full GC 进行兜底。
G1 作为 CMS 的继承者,取代者自然有着不少超越 CMS 的优秀设计,但是要说完全超越,目前则是没有做到的。上面也提到了 G1 为了实现“停顿预测模型”需要更多的额外资源,在小内存应用上,CMS 大概率还是要比 G1 更为优秀的(因为没有足够的资源支持 G1 的回收过程),如果应用可使用的堆内存在 6 G或者以上,那么 G1 的优势就会凸显出来了。当然,随着时代的变迁,G1 会不断被优化,最后完全超越 CMS 也是正常的。
9 低延迟垃圾收集器
衡量垃圾收集器的三项重要指标是:内存占用、吞吐量和延迟。这三者构成一个“不可能三角”。在这三个指标中,延迟的重要性日益凸显,是因为我们的硬盘、内存越来越大,所以内存占用大点我们越来越能容忍,而且硬件性能的提高,在相同时间,相同资源的情况下,吞吐量自然而然提高了。我们服务器的内存从过去的 256MB 到现在的 1T,延迟方面还不优化的话,内存回收毫无疑问会消耗更多的时间。因此,延迟的重要性日益升高。下面将介绍两款低延迟垃圾收集器:Shenandoah 和 ZGC。
9.1 Shenandoah
是一款低延迟垃圾收集器。其目标是:希望在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的最大停顿时间控制在十毫秒以内。
Shenandoah 在 JDK15 才摘掉“实验帽子”,所以要使用这个收集器的话,要注意下自己的 JDK 版本。Shenandoah 在代码实现上复用了一部分 G1 的代码,所以它们会有部分特性重叠。比如,Shenandoah 复用了 G1 在并发失败后的兜底方案(Full GC),不过它顺手优化了这一块逻辑——实现了多线程 Full GC,这也使得 G1 变得支持多线程 Full GC 了。
我们来看下相较于 G1,它做了哪些改进。虽然 Shenandoah 也是采用了基于 Region 的堆内存布局,同样有用于存放大对象的 Humongous Region,默认的回收策略也是优先处理回收价值最高的 Region,但是在管理堆内存上,它与 G1 至少有三个明显的不同之处:
- 支持并发的整理算法,G1 的“筛选回收”可以并发执行但是不支持和用户线程并发执行,而 Shenandoah 支持(这是很重要的改进)
- Shenandoah 默认是不使用分代收集的,也就是说不存在新生代和老年代角色的 Region。 没有分代,也就不用像 G1 那样为了维护记忆集而耗费大量资源。
- 对于跨 Region 引用问题,Shenandoah 则是使用 “连接矩阵” 的全局数据结构来解决。
Shenandoah 的工作工程大致可以划分为以下九个阶段:
- 初始标记
与 G1 一样,首先标记与 GC Roots 直接关联的对象,这个阶段仍是“Stop The World”的,但停顿时间与堆大小无关,只与 GC Roots 的数量有关 - 并发标记
与 G1 一样,遍历对象图,标记全部可达的对象,这个阶段是与用户线程一起并发的,时间长短取决于堆中存活对象的数量以及对象图的复杂程度 - 最终标记
与 G1 一样,处理剩余的 SATB 扫描,并在这个阶段统计出回收价值最高的 Region,将这些 Region 构成一组回收集。这个阶段也会有一小段短暂的暂停 - 并发清理
这个阶段用于清理那些整个区域内连一个存活对象都没找到的 Region - 并发回收
并发回收阶段是 Shenandoah 与之前 HotSpot 中其它收集器的核心差异。在这个阶段,Shenandoah 会把回收集里面存活的对象先复制到其它未被使用的 Region 之中。复制对象在传统的做法中会暂停用户线程,但是 Shenandoah 通过读屏障和 “Brooks Pointers”(一种转发指针的技术方案)来实现这个阶段的用户线程并发 - 初始引用更新
并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。引用更新的初始阶段实际上没有做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已。初始引用更新时间很短,会产生一个非常短暂的停顿 - 并发引用更新
真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它不需要根据对象图搜索,只需要按照内存物理地址的顺序找到引用类型,将旧值改为新值即可 - 最终引用更新
解决了堆中的引用更新后,还要修正存在于 GC Roots 中的引用。这个阶段是 Shenandoah 的最后一次停顿,停顿时间只与 GC Roots 的数量相关 - 并发清理
经过并发回收和引用更新之后,整个回收集中所有 Region 已再无存活对象,再调用一次并发清理过程来回收这些 Region 的内存空间,供以后新对象分配使用
从上面可以看出 Shenandoah 大多数时间都是可以并发的,又继承了 G1 的内存布局管理策略,甚至共享了部分代码,所以我们一般认为 Shenandoah 是 G1 的继承者。
下图是 2016年 Shenandoah 的实际测试数据
从测试结果中可以看出 Shenandoah 虽然比以前乃至 G1,在停顿时间上仍未实现把最大停顿时间控制在10毫秒以内。而且 Shenandoah 压缩了最大停顿时间是用吞吐量作为代价的,通过实际测试发现在吞吐量方面是比较明显的下降了。
9.1.1 Brooks Pointers
Brooks Pointers 是一种通过指针转发来实现对象移动与用户线程并发的一种解决方案。
很久以前,如果想实现“对象移动与用户线程并发”,一般是在被移动的对象原有的内存上设置保护陷阱(Memory Protection Trap),一旦用户程序访问到已经被移动的旧对象内存时就会产生自陷中断,进入预设好的异常处理器中,再由其中的代码把访问转发到复制后的新对象上。虽然这样能达到目的,但是这样会导致用户态频繁切到核心态,所以不能频繁使用。
在 1984 年,一个叫 Brooks 的人提出了使用转发指针来实现对象移动与用户程序并发的一种解决方案。Brooks 设想在原有对象布局结构的最前面统一增加一个新的引用字段(通常是在对象头设置转发指针),在正常不处于并发移动的情况下,该引用指向自己,如下图
需要注意的是:转发指针在设计上决定了它是必然会出现多线程竞争问题。
- 在并发读时,只要保证返回结果的是一样的即可;
- 在并发写时,则需要保证写入操作只能发生在新对象上,而不是在旧对象上。
上面有提及 Shenandoah 垃圾回收的“并发回收阶段”是用户线程与对象迁移并发进行的,因此,大概率会在某个时间段同时处理以下三件事:
- 收集器线程复制新对象副本
- 用户线程更新对象某个属性
- 收集器线程更新转发指针的引用值为新副本地址
如果不做线程安全处理,那么可能会导致用户线程的更新操作发生在旧对象上,等收集器完成垃圾收集时,再去读取会发现“更新丢失”。为了避免这种情况的发生,我们可以想办法让用户线程与收集器线程对转发指针进行访问的行为互斥,同一时间只能是某一方成功,另一方进行等待,避免两方交替进行。实际上 Shenandoah 是通过 CAS 来保证并发时对象的“访问”的正确性的。对象的读取,计算哈希值,比较,加锁等等这些都是属于“访问”的范畴,而访问对象的相关代码实在是太多了,如果要全部拦截,让它都乖乖地走转发指针的流程,那么就不得不依赖读屏障、写屏障了。可是垃圾收集器在很多地方都用到了读屏障和写屏障,比如:“维护卡表” 或是 “实现并发标记” 。现在 Shenandoah 为了实现 Brooks Pointer,在读、写屏障中都加入了额外的转发处理,尤其是使用读屏障的代价比写屏障更大。因为程序中对象的读频率要远远高于对象写入的频率,所以读屏障的数量自然比写屏障多得多。因此,读屏障的使用需要更为谨慎,不允许任何重量级操作。
不过 Shenandoah 的开发者也意识到了数量庞大的读屏障带来的性能开销会是 Shenandoah 被诟病的关键点之一,所以在 JDK13 中将 Shenandoah 的内存屏障模型改进为基于引用访问屏障的实现,所谓的“引用访问屏障”是指内存屏障只拦截对象中数据类型为引用类型的读写操作,从而省去对原生数据类型等其他非引用字段的读写拦截。这样可以避免大量对原生类型、对象比较、对象加锁等场景设置内存屏障带来的开销。
9.2 ZGC
ZGC 是在 JDK11 中新加入带有实验性质的低延迟垃圾收集器,在 JDK17 中摘去了实验的帽子。ZGC 是一款基于 Region 内存布局的,不设分代的,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的,以低延迟为首要目标的垃圾收集器。
ZGC 虽然和 Shenandoah 都是低延迟垃圾收集器,有着相同目标“都希望在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的最大停顿时间控制在十毫秒以内”,但是它们的实现方式想去甚远,下面会介绍 ZGC 的技术特点。
9.2.1 内存布局
ZGC 的 Region 分为大、中、小三个等级的内存容量:
- 小型 Region
容量固定为 2MB,用于放置小于 256KB 的小对象 - 中型 Region
容量固定为 32MB,用于放置大于等于 256KB 但小于 4MB 的对象 - 大型 Region
容量不固定,可以动态变化,但必须为 2MB 的整数倍,用于放置 4MB 或以上的大对象。每个大型 Region 中只会存放一个大对象,这样意味着它虽然叫做“大型Region”,但它的实际容量是有可能小于中型 Region,最小容量可低至 4MB。大型 Region 在 ZGC 的实现中是不会被重分配的(重分配是 ZGC 的一种处理动作,用于复制对象的收集阶段),因为复制一个大对象的代价是非常高昂的。
9.2.2 并发整理
我们知道内存整理都需要移动对象,复制-整理需要把存活的对象复制到别的地方,标记-整理需要把存活对象移动到别的地方,而这些行为都会使这些被移动的对象有新的访问内存地址。垃圾收集线程进行移动对象,用户线程继续正常执行,要达到这种效果,Shenandoah 的解决方案是 Brooks Pointer 和内存屏障;而 ZGC 是采用了 读屏障 + 染色指针(在一些国外技术书籍上有的叫 Colored Pointer 或者 Tag Pointer,又或者 Version Pointer)。
我们对象设计本身就会在对象头存储一些额外信息,比如:哈希码、分代年龄、锁记录等。这种记录方式在有对象访问的场景下是很自然流畅的,不会有什么额外负担。现在 ZGC 就想利用对象头知道“对象是否被移动过,还有希望了解某个对象的某些信息的应用场景”。
9.2.2.1 染色指针
ZGC 的染色指针是直接把标记信息记在引用对象的指针上
染色指针是一种直接将少量额外信息存储在指针上的技术,可是为什么指针本身也可以存储额外信息呢?在64位系统中,理论上可以访问的内存高达 16EB(1EB=1024PB,1PB=1024TB)。实际上,基于需求(用不到那么多内存)、性能(地址越宽在做地址转换时需要的页表级数越多)和成本(消耗更多晶体管)的考虑,在 AMD64 架构(这个不是指 AMD 处理器,而是指现在主流的 x86-64 架构,现行的64位标准是由 AMD 公司率先制定的)中只支持到 52位(4PB)的地址总线和 48位(256TB)的虚拟地址空间,所以目前 64位 的硬件实际能够支持的最大内存只有 256TB。此外,操作系统方面也有自己的限制,64位的 Linux 支持 47位(128TB)的进程虚拟地址空间和 46位(64TB)的物理地址空间,64位 的 Windows 则只支持 44位(16TB)的物理地址空间。
尽管 Linux 下 64位 指针的高 18 位不能用来寻址,但剩余的 46 位指针所能支持的 64TB 内存在今天仍然能够充分满足大型服务器的需要。鉴于此,ZGC 的染色指针技术继续盯上了这剩下的 46 位指针宽度,将其高 4 位提取出来存储四个标志信息。通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态、是否进入了重分配集(即被移动过)、是否能通过 finalize() 方法才能被访问到。
显然,46 位的指针宽度被染色指针占用了 4 位,那么只剩下 42 位可以用来实际寻址,所以也限制了 ZGC 管理的内存不能超过 4TB(2的42次幂)。不仅如此,染色指针的设计还不支持 32 位平台,不支持压缩指针(-XX:+UseCompressedOops)等等。即便如此,染色指针带来的优势也是十分明显的,简单地说就是“瑕不掩瑜”。
总体来说,染色指针有三大优势:
- 染色指针可以使得一旦某个 Region 的存活对象被移动后,这个 Region 立即就能被释放和重用,而不必等待整个堆中所有指向该 Region 的引用都被修正后才能清理。这和过去的复制整理的实现的最大的区别就是,过去需要 1 : 1 的内存空间才能复制移动,而 ZGC 理论上只要有一个空闲 Region 就能继续完成垃圾回收。
- 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障,尤其是写屏障的目的通常是为了记录对象引用的变动情况,如果将这些信息直接维护在指针中,那么就可以省去一些专门的记录操作。实际上 ZGC 没有使用写屏障,只用了读屏障。减少了内存屏障的数量,那么对性能的影响,对吞吐量的影响也会小很多。
- 染色指针可以作为一种可扩展的存储结构,用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。现在 Linux 下的 64 位指针还有前 18 位并未使用,它们虽然不能用来寻址,却可以通过其他手段用于信息记录。如果开发了这 18 位,既可以腾出已用的 4 个标志位,将 ZGC 可以支持的最大堆内存从 4TB 变成 64TB,也可以利用其余位置再存储更多标志,譬如存储一些追踪信息来让垃圾收集器在移动对象时能将低频次使用的对象移动到不常访问的内存区域。
不过,要使用染色指针就必须解决一个前置问题:Java虚拟机作为一个普通的进程,这样随意重新定义内存中某些指针的其中几位,操作系统是否支持?处理器是否支持?因为我们代码最终都是要转换成机器指令流交付给处理器执行,处理器的厂家可没必要迁就一个普通的进程做出特殊处理的开发,对于处理器而言指令流中的指针都将被视为一个完整的内存地址,可以没有什么前几位,后几位的区分。因此,ZGC 还需要通过虚拟内存映射技术来解决这个问题。
9.2.2.2 虚拟地址映射
在此前,我们先简单复习下 x86 中遇到类似问题的解决方案。很老的 x86 系统,所有进程都是共用一块物理内存空间的,这样会导致不同进程之间的内存无法互相隔离,当一个进程污染了另一个进程的内存时,就只能堆整个系统进行复位后才能得以恢复。为了解决这个问题,从 Intel 80386 处理器开始,提供了“保护模式”用于隔离进程。在保护模式下,386处理器的全部 32 条地址寻址线都有效,进程可访问的内存空间高达 4 GB。另外,在这个模式下处理器会使用分页管理机制把线性地址空间和物理地址空间分别划分为大小相同的块,这样的内存块称之为“页(Page)”。通过在线性虚拟空间的页与物理地址空间的页之间建立的映射表,分页管理机制会进行线性地址到物理地址空间的映射,完成地址转换(实际上 x86 操作系统中,进程访问的逻辑地址需要通过 MMU 中的分段单元翻译成线性地址,然后再通过分页单元翻译成物理地址)。
ZGC 就是利用了现代 CPU 和操作系统提供的虚拟内存能力解决了染色指针的问题。Linux/x86-64 平台上的 ZGC 使用了多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到同一个物理内存地址上。
这种多对一的设计有什么好处呢?好处包括且不限于以下 4 个:
- 减少对象移动的开销
在垃圾回收时,ZGC 不需要直接复制对象,而是更新虚拟地址的映射表。这避免了实际数据的移动,提高效率。 - 支持并发性
ZGC 利用地址映射表实现低停顿时间,所有的对象访问都可以通过地址重定位完成,回收和程序运行可并行进行。 - 高效碎片整理
由于虚拟内存空间很大,ZGC 可以避免物理堆内存碎片化问题,通过地址重新映射整理出连续的虚拟内存区域,而不受物理内存限制。 - 适应未来硬件扩展
虚拟地址空间远大于物理内存,这种设计可以轻松支持未来更大的堆。
9.2.3 ZGC 的运作过程
ZGC 的运作过程大致可以划分为四大阶段,并且四个阶段都是可以并发执行的,仅仅是两个阶段中会存在短暂的停顿。
- 并发标记(Concurrent Mark)
与 Shenandoah 一样,并发标记时遍历对象图做可达性分析的阶段,前后也有类似 Shenandoah 的初始标记、最终标记的短暂停顿。不过 ZGC 的标记是在指针上而不是在对象上进行的,标记阶段会更新染色指针中的 Marked0、Marked1 标志位。 - 并发预备重分配(Concurrent Prepare for Relocate)
这个阶段需要根据特定的查询条件统计得出本次收集过程需要清理哪些 Region,将这些 Region 组成重分配集(Relocation Set)。重分配集与 G1 收集器的回收集(Collection Set)是有区别的,ZGC 划分 Region 的目的并非像 G1 那样做收益优先的增量回收。相反,ZGC 每次回收都会扫描所有的 Region,用更大的扫描成本避免像 G1 中记忆集的维护成本。ZGC 的重分配集只是决定了里面的存活对象会被重新复制到其他 Region中,里面的 Region 会被释放。这个标记过程是针对全堆的。 - 并发重分配(Concurrent Relocate)
重分配是 ZGC 执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的 Region 上,并为重分配集中的每个 Region 维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。ZGC 可以通过对象的染色指针的 Remap 标记位知道对象是否处于重分配集中,如果处于重分配集中,那么对该对象访问的用户线程将会被读屏障拦截,然后根据 Region 上的转发表记录将访问转发到新对象上,并同时修正该引用的值,使其直接指向新对象。这种修正引用的值的能力在 ZGC 中称为指针的“自愈(Self-Healing)”能力。这带来的好处就是:- 只有第一次访问旧对象会陷入转发,只慢一次。
对比 Brooks Pointer 的每次访问都会有一个既定的开销(最终引用更新阶段前),自然 ZGC 在类似行为的并发阶段下的性能会比 Shenandoah 会更好一些。 - 一旦重分配集中某个 Region 的存活对象都复制完毕后,这个 Region 就可以立即释放用于新对象的分配(但是转发表暂时还需要保留转发映射关系)。哪怕还有其他对象引用了该对象,引用都没有更新也没关系,因为旧指针具备“自愈”能力。
- 只有第一次访问旧对象会陷入转发,只慢一次。
- 并发重映射(Concurrent Remap)
重映射就是要修正整个堆中指向重分配集中旧对象的所有引用。从这一点看的话,与 Shenandoah 并发引用更新阶段是一样的,但是 ZGC 的并发重映射并不是一个必须赶紧完成的任务,因为 ZGC 的染色指针具有“自愈”能力。重映射清理这些旧引用的主要目的是为了节约“第一次访问慢”的问题,另外还有就是清理结束后可以释放转发表的内存。因此,ZGC 把并发重映射阶段的工作合并到了下一次垃圾收集循环中的并发标记阶段去完成。
ZGC 只有在并发标记阶段中的“初始标记”、“最终标记”(ZGC 里并不是这么叫,但是这里为了好理解,选用了和 Shenandoah 有着类似目标的阶段来称呼了)是有一小段的停顿的,其他阶段都是可以和用户程序并发的。而且这个停顿的时间只与 GC Roots 大小有关,和堆内存大小无关(这个和虚拟内存映射有关系)。ZGC 真正实现了任何堆上停顿都小于十毫秒的目标。
9.2.4 ZGC 的不足
ZGC 不像 G1 那样保留了分代的概念,因而不需要通过写屏障来维护记忆集(记忆集是为了解决跨代指针的问题)。记忆集会占用大量的内存空间,写屏障也会增加额外的系统资源负担。但是没有十全十美的设计,这样的取舍也意味着 ZGC 的对象分配速率不会太高。
下面举个例子:ZGC 准备要对一个很大的堆做一次完整的并发收集,假设整个过程需要十分钟以上。这段时间里,由于应用的对象分配速率很高,将创造大量的新对象,这些对象很难进入当次收集的标记范围,那就只能当存活对象处理。可是大多对象都是朝生夕死的,这就产生了大量的浮动垃圾。如果这种高速分配持续很久的话,那么每一次完整的并发收集周期就会很长,回收到的内存甚至可能小于并发产生的浮动垃圾。
对于上面这种场景,目前 ZGC 就只能通过加大堆内存来缓解。如果想从根本解决,那么目前的技术方案上还是需要引入分代收集(新生代的内存区域需要进行更快、更频繁的收集)。
10 总结
还是要根据自己的实际业务场景来决定使用什么垃圾收集器,JVM的参数设置,没有万能垃圾收集器!