Z 垃圾收集器 (ZGC)
动机
ZGC(JEP 333)被设计用于低延迟和高可扩展性,并自 JDK 15(JEP 377)起已可用于生产环境。
ZGC 的大部分工作是在应用线程运行时完成的,只会短暂地暂停这些线程。ZGC 的停顿时间通常以微秒计;相比之下,默认垃圾收集器 G1 的停顿时间范围从毫秒到秒级不等。ZGC 的低停顿时间与堆大小无关:工作负载可以使用从几百 MB 到数 TB 的堆大小,并仍然保持低停顿时间。
对于许多工作负载而言,仅使用 ZGC 就足以解决所有与垃圾回收相关的延迟问题。这在具备足够资源(即内存和 CPU),以确保 ZGC 回收内存的速度快于并发运行的应用线程消耗内存的速度时效果良好。然而,ZGC 当前会将所有对象统一存储,不区分年龄,因此每次运行时都必须对所有对象进行回收。
弱分代假说(weak generational hypothesis)指出:年轻对象往往很快死亡,而老对象则会长期存活。因此,回收年轻对象所需资源更少且能回收更多内存,而回收老对象则需要更多资源且回收的内存较少。因此,通过更频繁地回收年轻对象,可以提升使用 ZGC 的应用性能。
描述
启用分代 ZGC
为了确保平稳过渡,我们最初将使分代 ZGC 与非分代 ZGC 并存。命令行选项 -XX:+UseZGC 将选择非分代 ZGC;要选择分代 ZGC,需要额外添加 -XX:+ZGenerational 选项:
| |
在未来的某个版本中,我们计划将分代 ZGC 设为默认值,此时 -XX:-ZGenerational 将用于选择非分代 ZGC。在更晚的版本中,我们计划移除非分代 ZGC,此时 ZGenerational 选项将被废弃。
设计
分代 ZGC 将堆划分为两个逻辑分代:年轻代用于存放新分配的对象,而老年代用于存放长期存活的对象。每个分代都独立于另一个分代进行回收,因此 ZGC 可以专注于回收更“高收益”的年轻对象。
与非分代 ZGC 一样,所有垃圾回收都在应用程序运行的同时并发执行,并且应用程序的停顿时间通常小于一毫秒。由于 ZGC 在应用运行的同时读取和修改对象图,因此必须确保应用程序始终看到一致的对象图视图。ZGC 通过染色指针(colored pointers)、读屏障(load barriers)和写屏障(store barriers)来实现这一点。
- 染色指针(colored pointer) 是一种指向堆中对象的指针,它在包含对象内存地址的同时,还携带用于编码对象已知状态的元数据。这些元数据描述了对象是否已知存活、地址是否正确等信息。ZGC 始终使用 64 位对象指针,因此可以为多 TB 的堆提供足够的元数据位和地址空间。当对象字段引用另一个对象时,ZGC 使用染色指针来实现该引用。
- 读屏障(load barrier) 是 ZGC 注入到应用程序中的一段代码,用于在应用读取对象字段(该字段引用另一个对象)时执行。读屏障会解析存储在染色指针中的元数据,并在应用使用该引用对象之前可能执行一些操作。
非分代 ZGC 同时使用染色指针和读屏障。分代 ZGC 还额外使用写屏障,以高效地跟踪不同分代之间的引用。
- 写屏障(store barrier) 是 ZGC 注入到应用程序中的一段代码,用于在应用向对象字段写入引用时执行。分代 ZGC 在染色指针中增加了新的元数据位,使写屏障能够判断该字段是否已经被记录为可能包含跨分代引用。染色指针使得分代 ZGC 的写屏障比传统分代写屏障更高效。
写屏障的引入,使分代 ZGC 可以将“可达对象标记”的工作从读屏障转移到写屏障中。也就是说,写屏障可以利用染色指针中的元数据位,高效判断在写入前该字段所引用的对象是否需要被标记。
将标记(marking)从读屏障中移出,使得对其进行优化变得更容易,这一点非常重要,因为读屏障通常比写屏障执行得更频繁。现在,当读屏障解释一个染色指针时,如果对象已被搬迁,它只需要更新对象地址,并更新元数据以表示该地址已被确认正确。后续的读屏障会根据这些元数据进行判断,不再重复检查该对象是否已被搬迁。
分代 ZGC 在染色指针中使用不同的标记与搬迁(relocation)元数据位集合,从而使各个分代可以独立进行回收。
接下来的章节描述了区分分代 ZGC 与非分代 ZGC,以及与其他垃圾收集器的关键设计概念:
- 无多重内存映射(No multi-mapped memory)
- 优化的屏障(Optimized barriers)
- 双缓冲记忆集(Double-buffered remembered sets)
- 无需额外堆内存的对象搬迁(Relocations without additional heap memory)
- 紧凑堆区域(Dense heap regions)
- 大对象(Large objects)
- 完整垃圾回收(Full garbage collections)
多重映射内存(multi-mapped memory)
非分代 ZGC 使用多重映射内存(multi-mapped memory)来降低读屏障的开销。而分代 ZGC 则改为在读屏障和写屏障中使用显式代码实现。
对于用户而言,这一变化的主要优势在于:更容易衡量堆所使用的内存量。在多重映射情况下,同一段堆内存会被映射到三个独立的虚拟地址范围中,因此像 ps 这样的工具报告的堆内存使用量大约是实际使用量的三倍。
对于 GC 本身而言,这一变化意味着染色指针中的元数据位不再需要位于与堆可访问地址范围对应的指针部分。这使得可以增加更多元数据位,并且也为将最大堆大小从非分代 ZGC 的 16TB 限制进一步提升提供了可能性。
在分代 ZGC 中,存储在对象字段中的对象引用以染色指针形式实现。而存储在 JVM 栈中的对象引用则以“无色指针”(colorless pointers)形式实现,即不包含元数据位,存在于硬件栈或 CPU 寄存器中。读屏障和写屏障负责在染色指针与无色指针之间进行相互转换。
由于在硬件栈或 CPU 寄存器中不会出现染色指针,因此可以采用更特殊的染色指针布局,只要染色指针与无色指针之间的转换足够高效即可。分代 ZGC 使用的染色指针布局将元数据放在指针的低位,而对象地址放在高位。这样可以最大程度减少读屏障中的机器指令数量。在 x64 架构上,通过精心设计的编码方式,一个移位指令即可同时完成“判断是否需要处理该指针”以及“移除元数据位”的操作。
优化屏障(Optimized barriers)
随着写屏障(store barriers)的引入,以及读屏障(load barriers)新增的职责,更多 GC 代码将与编译后的应用代码交织在一起。为了最大化吞吐量,这些屏障需要被高度优化。分代 ZGC 的许多关键设计决策都涉及染色指针(colored pointer)方案和屏障机制。
用于优化屏障的一些技术包括:
- 快速路径与慢速路径
- 最小化读屏障职责
- 记忆集屏障(remembered-set barriers)
- SATB 标记屏障(SATB marking barriers)
- 融合写屏障检查(fused store barrier checks)
- 写屏障缓冲区(store barrier buffers)
- 屏障补丁(barrier patching)
快速路径与慢速路径
ZGC 将屏障拆分为两部分。快速路径检查在应用程序使用引用对象之前是否需要执行额外的 GC 工作。慢速路径执行这些额外工作。所有对象访问都会执行快速路径检查。由于必须非常快,这部分代码会直接插入到即时编译(JIT)后的应用程序代码中。慢速路径只在少数情况下执行。当进入慢速路径时,被访问对象指针的颜色会被修改,使得后续对同一指针的访问在一段时间内不会再次触发慢速路径。因此,慢速路径不需要高度优化,为了可维护性,它们以 JVM 中的 C++ 函数形式实现。
在非分代 ZGC 中,这是读屏障(load barriers)的拆分方式。在分代 ZGC 中,同样的机制也应用于写屏障(store barriers)及其相关的 GC 工作。
最小化读屏障(load barrier)职责
在非分代 ZGC 中,读屏障负责:
- 更新 GC 已搬迁对象的过期引用
- 将加载的对象标记为存活(因为应用正在访问该对象,因此认为其存活)
在分代 ZGC 中,需要同时跟踪两个分代,并在染色指针(colored pointer)与无色指针之间进行转换。为了降低复杂度,并优化读屏障的快速路径,将标记职责从读屏障转移到写屏障。
在分代 ZGC 中,读屏障负责:
- 移除染色指针(colored pointer)中的元数据位
- 更新 GC 已搬迁对象的过期引用
写屏障(store barrier)负责:
- 向指针添加元数据位以生成染色指针(colored pointer)
- 维护记忆集(remembered set),用于跟踪老年代到年轻代的引用
- 将对象标记为存活
记忆集屏障(remembered-set barriers)
当分代 ZGC 回收年轻代时,只会访问年轻代中的对象。但老年代中的对象可能包含指向年轻代对象的引用,这些引用在回收过程中必须被处理,原因有两个:
- GC 标记根(GC marking roots):这些引用可能是使年轻代对象仍然可达的唯一路径,因此必须作为对象图的根来处理,以确保所有存活对象都能被标记
- 过期引用:在回收年轻代时,对象会被移动,但老年代中的引用不会立即更新,而是在应用访问时由读屏障惰性更新。如果应用未访问这些引用,GC 需要在后续阶段统一修复这些过期引用
老年代到年轻代的引用集合称为记忆集(remembered set)。记忆集包含所有位于老年代中的内存地址,这些地址可能包含指向年轻代对象的引用。写屏障(store barrier)会向记忆集添加条目。当一个引用被写入对象字段时,该字段被认为可能包含老年代到年轻代的引用。写屏障的慢速路径会过滤掉对年轻代字段的写入,因为只有老年代中的地址是需要关注的。慢速路径不会根据写入的值进行过滤,该值可能指向年轻代或老年代对象。垃圾收集器在使用记忆集时,会检查对象字段的当前值。
这一切保证了写屏障(store barrier)在维护记忆集时具有“只执行一次”的特性。这意味着,在两个连续的年轻代标记阶段之间,每个被写入的对象字段,写屏障慢速路径只会执行一次。当某个字段第一次被写入时,会发生以下步骤:
- 快速路径检查即将被覆盖的字段原值
- 颜色信息表明该字段在上一次年轻代标记阶段之后未被写入
- 进入慢速路径
- 将该字段地址加入记忆集(remembered set)
- 将新的指针值染色后写入字段
新的指针值会被染色,使后续快速路径检查能够识别该字段已经执行过慢速路径处理。
SATB 标记屏障(SATB marking barriers)
与非分代 ZGC 不同,分代 ZGC 使用起始快照(SATB,snapshot-at-the-beginning)标记算法。在标记阶段开始时,垃圾收集器会对 GC 根(GC roots)进行快照;在标记阶段结束时,所有在标记开始时从这些根可达的对象都能被保证找到并标记为存活。
为实现这一点,当对象图中的引用关系被破坏时,垃圾收集器必须能够获知。因此,写屏障(store barrier)会将即将被覆盖的字段值上报给垃圾收集器;垃圾收集器随后会标记该引用对象,并继续访问并标记从该对象可达的其他对象。
在一次标记周期内,写屏障只需要在某个字段第一次被写入时,上报该字段即将被覆盖的值。对同一字段的后续写入,只是替换一个垃圾收集器已经保证能够找到的值,因为 SATB 特性保证了这些对象在本次标记中已经被处理或会被处理。SATB 特性进一步支持了写屏障在标记语义上的“只执行一次”性质。
融合写屏障检查(fused store barrier checks)
写屏障的记忆集维护和标记功能存在很多相似之处,它们都使用染色指针(colored pointer)的快速路径检查,并且都具有各自的“只执行一次”特性。因此,不再为每种条件设置独立的快速路径检查,而是将它们融合为一个统一的快速路径检查。如果任意一个条件不满足,就会进入慢速路径,并执行所需的 GC 工作。
写屏障缓冲区(store barrier buffers)
将屏障拆分为快速路径与慢速路径,并结合指针染色,可以减少对 C++ 慢速路径函数的调用次数。分代 ZGC 通过在快速路径与慢速路径之间引入一个由 JIT 编译的中间路径来进一步降低开销。该中间路径会将即将被覆盖的字段值以及对象字段地址存入写屏障缓冲区,然后直接返回到应用代码,而不会进入代价较高的慢速路径。只有当写屏障缓冲区被填满时,才会触发慢速路径。这种方式将从编译代码切换到 C++ 慢速路径的开销进行了摊销。
屏障补丁(barrier patching)
读屏障(load barrier)和写屏障(store barrier)在执行检查时,会访问垃圾收集器在不同阶段切换时更新的全局变量或线程本地变量。不同 CPU 架构在访问这些变量时的开销不同。
在分代 ZGC 中,通过在可能的情况下对屏障代码进行补丁优化来降低这部分开销。全局状态值会被编码到屏障的机器指令中作为立即数,而不再需要通过访问全局变量或线程本地变量来获取当前状态。当垃圾收集器切换阶段时(例如开始年轻代标记阶段),相关方法首次执行时会对这些立即数进行更新。这进一步降低了屏障的运行开销。
双缓冲记忆集(double-buffered remembered sets)
许多垃圾收集器使用一种称为卡表标记(card table marking)的记忆集技术来跟踪分代间引用。当应用线程写入对象字段时,也会同时将卡表(card table)中的某个字节标记为“脏”(dirty)。通常,卡表中一个字节对应堆中约 512 字节的地址范围。为了找到所有老年代到年轻代的引用,垃圾收集器必须定位并访问所有属于这些“脏字节”对应地址范围内的对象字段。
相比之下,分代 ZGC 使用位图(bitmap)来精确记录对象字段位置,其中每一位表示一个可能的对象字段地址。每个老年代区域都有一对记忆集位图(remembered-set bitmaps)。其中一个位图由应用线程通过写屏障(store barrier)进行写入并保持活跃,另一个位图则作为垃圾收集器的只读副本,保存当前所有可能指向年轻代的老年代引用。这两个位图会在每次年轻代收集开始时进行原子交换。该机制的一个好处是应用线程不需要等待位图被清空。垃圾收集器可以在处理并清空一个位图的同时,另一个位图继续被应用线程并发写入。另一个好处是由于应用线程和 GC 线程操作不同位图,因此减少了它们之间对额外内存屏障(memory barrier)的需求。其他使用卡表标记的分代收集器(如 G1)在标记卡表时需要执行内存屏障,这可能导致写屏障性能更差。
无需额外堆内存的对象迁移(relocations without additional heap memory)
其他 HotSpot 垃圾收集器中的年轻代收集通常使用“转移式回收(scavenging)”模型:在一次遍历中找到并移动存活对象。在垃圾收集器完全知道哪些对象存活之前,年轻代中的所有对象都必须被处理完成。这类收集器只能在所有对象迁移完成后才能回收内存。因此,它们需要预估存活对象所需的内存量,并确保在 GC 开始时有足够空间。如果估算错误,就需要更昂贵的回收方式,例如对未迁移对象进行原地固定(pinning)导致碎片化,或暂停所有应用线程执行全堆 Full GC。
分代 ZGC 使用两次遍历:第一次遍历用于访问并标记所有可达对象,第二次遍历用于迁移已标记对象。由于在迁移阶段开始之前 GC 已经拥有完整的存活信息,它可以按区域(region)粒度分配迁移工作。一旦某个区域内的所有存活对象都被迁移出去,该区域就被认为已完成回收,可以重新作为迁移目标区域或用于应用线程分配内存。即使没有新的空闲区域可用,ZGC 仍然可以通过将对象压缩到正在迁移的区域中继续执行。这使得分代 ZGC 可以在不依赖额外堆内存的情况下完成年轻代的迁移和压缩。
密集堆区域(dense heap regions)
当从年轻代迁移对象时,不同区域中的存活对象数量及其占用内存会存在差异。例如,最近分配的区域通常包含更多存活对象。
ZGC 会分析年轻代区域的密度(density),以决定哪些区域值得回收,哪些区域过于“满”或回收成本过高。未被选择回收的区域会被原地晋升:其中的对象保持在原位置不移动,这些区域要么继续保留在年轻代作为幸存者区域(survivor regions),要么被晋升到老年代。这些幸存区域中的对象会获得“第二次死亡机会”,希望在下一次年轻代回收开始时,其中更多对象已经死亡,从而使更多区域可以被回收。
这种对高密度区域进行原地老化(aging)的方式,可以降低年轻代回收的整体开销。
大对象(large objects)
ZGC 已经能够很好地处理大对象。通过将虚拟内存与物理内存解耦,并预留大量虚拟地址空间,ZGC 通常可以避免 G1 中可能导致大对象分配困难的碎片问题。
在分代 ZGC 中,这一点进一步增强:允许大对象直接在年轻代中分配。由于区域可以在不移动对象的情况下被老化,因此没有必要为了避免昂贵的迁移而将大对象强制分配到老年代。相反,如果大对象生命周期较短,可以在年轻代中直接回收;如果生命周期较长,也可以低成本地晋升到老年代。
全垃圾收集(full garbage collections)
当回收老年代时,年轻代中的对象可能会引用老年代中的对象,这些引用被视为老年代对象图的根。由于年轻代对象变化频繁,不对年轻代到老年代的引用进行持续跟踪。相反,这些引用通过在老年代标记阶段同时执行一次年轻代回收来发现。当年轻代回收发现指向老年代的引用时,会将其传递给老年代标记过程。
这次额外的年轻代回收仍然作为一次正常的年轻代回收执行,并会将存活对象保留在幸存区域中。这会带来一个影响:年轻代中的幸存对象不会参与老年代回收阶段中的引用处理和类卸载。因此,可能会出现这样的情况:应用释放了对象图的最后引用,调用系统级垃圾回收,然后期望某些弱引用被清除或入队,或者某些类被卸载,但实际上并没有立即发生。
为了缓解这个问题,当应用显式触发垃圾回收时,会在老年代回收开始之前先执行一次额外的年轻代回收,将所有存活对象提前晋升到老年代。
替代方案
更简单的屏障与指针染色方案
当前的读屏障(load barrier)和写屏障(store barrier)实现并不容易理解。一个更简单的版本可能更易维护,但代价是更高的读写屏障开销。我们评估了大约十种不同的屏障实现方式,但没有一种在性能上优于当前基于移位操作的读屏障设计。未来仍然值得继续研究并分析这种“性能与复杂度”之间的权衡。
继续使用多重映射内存(multi-mapped memory)
可以通过继续使用多重映射内存(multi-mapped memory)来避免颜色指针中“无色根”的设计,从而采用更简单的实现方案。如果相比非分代 ZGC 需要更多的指针元数据位,那么最大堆大小将受到限制。另一种方式是采用混合方案:部分元数据位通过多重映射内存实现,另一部分则由读写屏障进行移除和添加。
测试
ZGC 实现中为无色指针(colorless pointer)和染色指针(colored pointer)使用了不同的 C++ 类型,从而保证两者之间不会发生隐式转换。染色指针仅限于 GC 代码和屏障使用。只要运行时系统通过 HotSpot 的访问接口和屏障来访问对象指针,它就只会看到可解引用的无色指针。运行时可见的对象指针类型始终是无色指针。我们在不同的对象指针类型中注入了大量验证代码,以便快速发现指针损坏或屏障缺失的问题。
- 标准垃圾收集算法测试集将用于验证正确性。
风险与假设
实现复杂度
分代 ZGC(Generational ZGC)中的屏障和染色指针(colored pointer)比非分代 ZGC 更复杂。分代 ZGC 还会并发运行两个垃圾收集器;这两个收集器相对独立,但在一些复杂机制上仍然会相互交互,从而增加实现复杂度。
由于额外的复杂性,从长期来看,我们计划通过完全用分代 ZGC 替代原有的非分代 ZGC,从而降低维护成本。
分代 ZGC 与非分代 ZGC 的性能差异
我们认为分代 ZGC(Generational ZGC)比其前身更适合大多数使用场景。一些工作负载甚至可能由于资源使用更低而获得吞吐量提升。例如,在 Apache Cassandra 基准测试中,分代 ZGC 只需要四分之一的堆大小,却能达到四倍吞吐量,同时仍将停顿时间控制在 1 毫秒以内。
某些本质上是非分代特性的工作负载可能会出现轻微性能下降。我们认为这类工作负载的比例足够小,不足以支撑长期维护两套独立 ZGC 实现的成本。
另一个潜在开销来源是更复杂的 GC 屏障(GC barriers)。我们预计这部分开销大多会被避免频繁回收老年代对象所带来的收益抵消。
另一个开销来源是同时运行两个垃圾收集器。需要合理平衡它们的触发频率和 CPU 使用,以避免对应用造成过度影响。
与 GC 开发的常规情况一样,未来的改进和优化将由基准测试和用户反馈驱动。我们计划在首次发布之后仍持续改进分代 ZGC。