天天看点

「后端」Java 程序员必知的 ZGC 垃圾回收器知识总结

作者:架构思考
在了解 Java 垃圾回收(GC)机制 和 G1 垃圾回收器 后,如何进一步降低 GC 的停顿时间,是当前垃圾回收算法领域研究的最热点话题之一。欢迎阅读~

今天就来学习这类旨在减少 GC 停顿的垃圾回收算法 ZGC,ZGC 对 Java 程序员的意义和 G1 是同样重要的。如果说 CMS 代表的是过去式,而 G1 是一种过渡(尽管这个过渡期会很长),那么 ZGC 无疑就是 JVM 自动内存管理器的未来。

一、什么是 ZGC

ZGC(The Z Garbage Collector)是 JDK 11 中推出的一款追求极致低延迟的垃圾收集器。核心是一个并发垃圾回收器,其设计的目标是:

  1. 停顿时间不超过10ms;
  2. 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;
  3. 支持堆范围为8MB~16TB。

并且官方明确指出 JDK15 中的 ZGC 不再是实验性质的垃圾收集器,而且建议投入生产了。

「后端」Java 程序员必知的 ZGC 垃圾回收器知识总结

ZGC 的停顿时间到底有多低呢?这里与 G1 做对比:

「后端」Java 程序员必知的 ZGC 垃圾回收器知识总结
「后端」Java 程序员必知的 ZGC 垃圾回收器知识总结

二、ZGC的内存布局

ZGC 完全抛弃了按代收集理论,它与 G1 一样将内存划分成各个小的区域的,但与 G1 有所不同的是,ZGC 的各个内存区域称为页面 (Page),页面也不是全部大小相等。ZGC按照页面大小将页面分为三类:小页面、中页面和大页面。

  • 小页面:容量固定为 2MB,用于存放小于 256KB 的对象;
  • 中页面:容量固定为 32MB,用于存放大于等于 256KB 但小于 4MB 的对象
  • 大页面:容量不固定,可以动态变化,但必须为 2MB 的整数倍,用于存放大于等于 4MB 的对象。
「后端」Java 程序员必知的 ZGC 垃圾回收器知识总结

三、ZGC 停顿时间的真相

ZGC 和 G1 有很多相似的地方,它的主体思想也是采用复制活跃对象的方式来回收内存。在回收策略上,它也同样将内存分成若干个区域,回收时也会选择性地先回收部分区域。

ZGC 与 G1 的区别在于:它可以做到并发转移(拷贝)对象。并发转移指的是在对象拷贝的过程中,应用线程和 GC 线程可以同时进行,这是其他 GC 算法目前没有办法做到的。

其他的垃圾回收算法,在进行对象转移时都是需要 Stop The World (STW),而对象转移往往是垃圾回收过程最耗时的环节,并且耗时会随着堆的增大而增加。ZGC 则不同,在应用线程运行的同时,GC 线程也可以进行对象转移,这样就相当于把整个 GC 最耗时的环节放在应用线程后台默默执行,不需要一个长时间的 STW 来等待。这也正是 ZGC 停顿时间很小的主要原因。

如何能在应用线程修改对象引用关系的同时,GC 线程还能正确地转移对象,或者说 GC 线程将对象转移的过程中,应用线程是如何访问正在被搬移的对象呢?

四、读屏障 read barrier

我们知道 CMS 算法和 G1 算法都使用了 write barrier 来保证并发标记的完整性,防止漏标现象。ZGC 的并发标记也不例外。除此之外,ZGC 提升效率的核心关键在于并发转移阶段使用了 read barrier。

当应用线程去读一个对象时,GC 线程刚好正在搬移这个对象。如果 GC 线程没有搬移完成,那么应用线程可以去读这个对象的旧地址;如果这个对象已经搬移完成,那么可以去读这个对象的新地址。那么判断这个对象是否搬移完成的动作就可以由 read barrier 来完成。

「后端」Java 程序员必知的 ZGC 垃圾回收器知识总结

上图中,对象 a 和对象 b 都引用了对象 foo,当 foo 正在拷贝的过程中,线程 A 可以访问旧的对象 foo 得到正确的结果,当 foo 拷贝完成之后,线程 B 就可以通过 read barrier 来获取对象 foo 的新地址,然后直接访问对象 foo 的新地址。

如果这里只用 write barrier 是否可行?当 foo 正在拷贝的过程中,线程 A 如果要写这个对象,那么只能在旧的对象 foo 上写,因为还没有搬移完成;如果当 foo 拷贝完成之后,线程 B 再去写对象 foo,是写到 foo 的新地址,还是旧地址呢?

如果写到旧地址,那么对象 foo 就白搬移了,如果写到新地址,那么又和线程 A 看到的内容不一样?所以使用 write barrier 是没有办法解决并发转移过程中线程访问一致性问题,从而无法保证应用线程的正确性。因此,为了实现并发转移,ZGC 使用了 read barrier。

同时还可以维护一张映射表(下称 forwarding table)。在这个映射表中,key 是旧地址,value 是新地址。当对象再次被访问时,通过插入的 read barrier 来判断对象是否被搬移过。如果 forwarding table 中有这个对象,说明当前访问的对象已经转移,read barrier 这时就会将对这个对象的引用直接更改为新地址。

「后端」Java 程序员必知的 ZGC 垃圾回收器知识总结

上图中,当 foo 对象发生转移之后,对象 a 再访问 foo 时就会触发 read barrier。read barrier 会查找 forwarding table 来确定对象是否发生了转移,确定 foo 被转移到新地址 foo(new)之后,直接将这一次对 foo 的访问更改为 foo(new)。由于整个过程是依托于 read barrier 自动完成的,这个过程也叫“自愈”。

五、染色指针 colored pointer

之前的垃圾收集器都是把GC信息(标记信息、GC分代年龄..)存在对象头的Mark Word里。

如果某个对象是垃圾对象。ZGC将对象信息存储在指针中,这种技术叫做——染色指针(Colored Pointer)。以后不管这个对象在哪儿使用,都知道他是个垃圾对象。

在 64 位系统下,当前 Linux 系统上的地址指针只用到了 48 位,寻址范围也就是 256T (2^48 = 256T)。但实际上,当前的应用根本就用不到 256T 内存,也没有哪台服务器机器上面可以一下插这么多内存条。所以, ZGC 就借用了地址的第 44 ~ 47 位作为标记位,第 0 ~ 44 位共 16T (2^44 = 16T) 的地址空间留做堆使用。

「后端」Java 程序员必知的 ZGC 垃圾回收器知识总结
  • Marked0 / Marked1:判断对象是否已标记
  • Remapped:判断应用是否已指向新的地址
  • Finalizable:判断对象是否只能被Finalizer访问

这几个 bits 在不同的状态,就代表这个引用的不同颜色。对象标记过程就是打个三色标记,这些标记本质上只和对象引用有关,和对象本身无关。某个对象只有它的引用关系才能决定它的存活。

染色指针也会带来问题,就是修改指针后,操作系统就不认识了。因为染色指针只是重新定义内存中某些指针的其中几位,OS 又不支持,OS 只会把整个指针当做一个内存地址来对待。为了解决这个问题,ZGC 使用了内存多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是一种多对一映射。

六、ZGC 的回收原理

ZGC 的回收过程大致分为三个主要阶段:

  • Mark 阶段:标记活跃对象
  • Relocate 阶段:活跃对象转移
  • ReMap 阶段:地址视图统一

Mark — 初始标记

在进行初始标记时,需要进行短暂的 STW。不过在这个阶段,ZGC 只会扫描 root,整个初始标记阶段停顿时间很短。停顿时间不会随着堆的增大而增加。

在 GC 开始之前,地址视图是 Remapped。那么在 Mark 阶段需要做的事情是,将遍历到的对象地址视图变成 Marked1,1、2、4 对象已经是 marked1,其他对象还是 remapped。

「后端」Java 程序员必知的 ZGC 垃圾回收器知识总结

初始标记

Mark — 并发标记

这里会继续遍历整个堆中的存活对象,并将其指针进行染色。5、8 的指针也会被染色为当前视图下指针,3、6、7 则不变。

「后端」Java 程序员必知的 ZGC 垃圾回收器知识总结

并发标记

至此,所有标记为 Marked1 的对象都认为是活跃对象。而视图仍是 Remapped 的对象,就认为是垃圾。接下来进入 Relocate 阶段,也就是转移阶段

Relocate — 迁移阶段

「后端」Java 程序员必知的 ZGC 垃圾回收器知识总结

准备迁移

根据一定规则去选择需要迁移的页,如果页中有存活的对象,则注册成存活页,如果没有存活对象则直接回收页。最后再从存活页中按一定策略选择需要迁移的页,并按一定顺序填充进迁移集合。填充的操作其实是将页的信息封装成一个个 Forwarding 存到 RlocationSet 中。

Relocate — 初始迁移

这个阶段会先切换视图,从 marked1 切换到 remapped。

之后会扫描与根节点相关的对象,判断其指针是好是坏。如果是好指针则直接返回。如果是坏指针,则会先判断是否在 Forwarding Tables 中,如果没有就代表不需要迁移,如果有则代表需要进行迁移。

「后端」Java 程序员必知的 ZGC 垃圾回收器知识总结

初始迁移1

先申请块内存,然后将对象 copy 过去,之后把映射关系记录在 Forwarding Table 中,最后修改 root 指针到新对象上。

「后端」Java 程序员必知的 ZGC 垃圾回收器知识总结

初始迁移2

Relocate — 并发迁移

在这个阶段会先遍历 RelocationSet 中所有的 forwarding,从中获取需要回收的页信息,从页信息中遍历存活的对象,并对其进行迁移

「后端」Java 程序员必知的 ZGC 垃圾回收器知识总结

最后则会把之前 relocationSet 中记录的页进行回收,这时候红色箭头的都是失效的指针都是坏指针,如果用户访问这些指针会触发读屏障进行指针修复。

「后端」Java 程序员必知的 ZGC 垃圾回收器知识总结

回收

Remap 阶段

Remap 阶段主要是对地址视图和对象之间的引用关系做修正。因为在 Relocate 阶段,GC 线程会将活跃对象快速搬移到新的区域,但是却不会同时修复对象之间的引用。

这样就会导致活跃视图不统一,需要再对对象的引用关系做一次全面的调整,这个过程也是要遍历所有对象的。不过,因为 Mark 阶段也需要遍历所有对象,所以,可以把当前 GC 周期的 Remap 阶段和下一个 GC 周期的 Mark 阶段复用。

「后端」Java 程序员必知的 ZGC 垃圾回收器知识总结

但是由于 Remap 阶段要处理上一轮的 Marked1 视图指针,又要同时标记下一轮的活跃对象,为了区分,可以再引入一个 Mark 标记,这就是 Marked0 标志。所以 Marked0 和 Marked1 在每一轮 GC 中是交替使用的。

Mark — 第二次 GC 初次标记

第二次 GC 的初次标记阶段,由于之前的 marked 标记是 1,现在会切换到 0,所以视图是从 remapped 切换到 marked0,所以1 2 4 的指针都被染色成 marked0。

「后端」Java 程序员必知的 ZGC 垃圾回收器知识总结

第二次 GC 初次标记

第二此并发标记会将没有被读屏障修复的指针进行修复并染色,现在1 2 4 5 8 都被染成 marked0 视图的指,会将之前保存的 relocationSet 和 Forwarding Table 都清空。之后的阶段就和第一次 GC 一样。

「后端」Java 程序员必知的 ZGC 垃圾回收器知识总结

第二此并发标记

到这里,关于 ZGC 的回收流程就说完了,大致分为三个主要阶段:其中 Mark 阶段负责标记活跃对象、Relocate 阶段负责活跃对象转移、ReMap 阶段负责地址视图统一。因为 Remap 阶段也需要进行全局对象扫描,所以 Remap 和 Mark 阶段是重叠进行的。

七、ZGC存在的问题

ZGC最大的问题是浮动垃圾。

ZGC 的停顿时间是在 10ms 以下,但 ZGC 的执行时间还是远大于这个时间的。假如 ZGC 全过程需要执行 10 分钟,在这个期间由于对象分配速率很高,将创建大量的新对象,这些对象很难进入当次 GC,所以只能在下次 GC 的时候进行回收,这些只能等到下次 GC 才能回收的对象就是浮动垃圾。

ZGC没有分代概念,每次都需要进行全堆扫描,导致一些“朝生夕死”的对象没能及时的被回收。

目前唯一的办法是增大堆的容量,使得程序得到更多的喘息时间,但这个也是一个治标不治本的方案。

八、总结

ZGC 之所以能够做到这么低的停顿时间,是因为它的大部分工作都是并发执行的,其中也包括了垃圾回收过程中最耗时的对象转移阶段。

ZGC 能够做到并发转移,背后有两大关键技术,分别是 read barrier 和 colored pointer。

read barrier 的作用在于应用线程可以在对象转移之后,通过 forwarding table 实现"自愈"。而 colored pointer 实现了地址视图,高效地完成了 read barrier 需要完成的工作,在实现并发转移的同时,保证吞吐率不出现大幅下降。

ZGC 的回收原理,整个回收过程可以大致分为 Mark、Relocate、Remap 三个阶段,其中 Mark 和 Remap 阶段是可以重叠的。

GC 开始时,地址视图为 Remapped,Mark 阶段的主要工作是标记活跃对象,然后将地址视图向 Marked1 迁移,处于 Marked1 的对象都被认为是活跃对象。

Relocate 阶段开始时,地址视图为 Marked1,该阶段主要做对象搬移工作,将地址视图向 Remapped 迁移。应用线程如果访问一个已经被转移的对象,就会触发 read barrier,完成“自愈”,最终访问的是 Remapped 视图的新对象。

而 Remap 阶段是地址视图的修复阶段。在 Remap 阶段开始时,地址视图为 Remapped。Remap 阶段的功能是做地址视图统一,对于仍处于 Marked0 和 Remaped 视图的活跃对象,将其地址视图更新为 Marked1。当然也可以是对于仍处于 Marked1 和 Remaped 视图的活跃对象,将其地址视图更新为 Marked0。Remap 和 Mark 阶段交替进行,交替操作 Marked0 和 Marked1 视图。

文章来源:https://www.jianshu.com/p/9a6be2e5e246

继续阅读