写点什么

Java 最前沿技术——ZGC

用户头像
AI乔治
关注
发布于: 2021 年 04 月 23 日
Java最前沿技术——ZGC

ZGC 介绍

ZGC(The Z Garbage Collector)是 JDK 11 中推出的一款追求极致低延迟的实验性质的垃圾收集器,它曾经设计目标包括:

  • 停顿时间不超过 10ms;

  • 停顿时间不会随着堆的大小,或者活跃对象的大小而增加;

  • 支持 8MB~4TB 级别的堆(未来支持 16TB)。

当初,提出这个目标的时候,有很多人都觉得设计者在吹牛逼。

但今天看来,这些“吹下的牛逼”都在一个个被实现。

基于最新的 JDK15 来看,“停顿时间不超过 10ms”和“支持 16TB 的堆”这两个目标已经实现,并且官方明确指出 JDK15 中的 ZGC 不再是实验性质的垃圾收集器,且建议投入生产了。

ZGC 已经熟了,面试题还会远吗?

本文会从 ZGC 的设计思路出发,讲清楚为何 ZGC 能在低延时场景中的应用中有着如此卓越的表现。

核心技术

多重映射

为了能更好的理解 ZGC 的内存管理,我们先看一下这个例子:

你在你爸爸妈妈眼中是儿子,在你女朋友眼中是男朋友。在全世界人面前就是最帅的人。你还有一个名字,但名字也只是你的一个代号,并不是你本人。将这个关系画一张映射图表示:



  • 在你爸爸的眼中,你就是儿子;

  • 在你女朋友的眼中,你就说男朋友;

  • 站在全世界角度来看,你就说世界上最帅的人;

假如你的名字是全世界唯一的,通过“你的名字”、“你爸爸的儿子”、“你女朋友的男朋友”,“世界上最帅的人”最后定位到的都是你本人。

现在我们再来看看 ZGC 的内存管理。

ZGC 为了能高效、灵活地管理内存,实现了两级内存管理:虚拟内存和物理内存,并且实现了物理内存和虚拟内存的映射关系。这和操作系统中虚拟地址和物理地址设计思路基本一致。

当应用程序创建对象时,首先在堆空间申请一个虚拟地址,ZGC 同时会为该对象在 Marked0、Marked1 和 Remapped 三个视图空间分别申请一个虚拟地址,且这三个虚拟地址对应同一个物理地址。



图中的 Marked0、Marked1 和 Remapped 三个视图是什么意思呢?

对照上面的例子,这三个视图分别对应的就是"你爸爸眼中",“你女朋友的眼中”,“全世界人眼中”。

而三个视图里面的地址,都是虚拟地址,对应的是“你爸爸眼中的儿子”,“你女朋友眼中的男朋友”…

最后,这些虚地址都能映射到同一个物理地址,这个物理地址对应上面例子中的“你本人”。

用一段简单的 Java 代码表示这种关系:



在 ZGC 中这三个空间在同一时间点有且仅有一个空间有效。

为什么这么设计呢?这就是 ZGC 的高明之处,利用虚拟空间换时间,这三个空间的切换是由垃圾回收的不同阶段触发的,通过限定三个空间在同一时间点有且仅有一个空间有效高效的完成 GC 过程的并发操作,具体实现会在后面讲 ZGC 并发处理算法的部分再详细描述。

染色指针

在讲 ZGC 并发处理算法之前,还需要补充一个知识点——染色指针。

我们都知道,之前的垃圾收集器都是把 GC 信息(标记信息、GC 分代年龄…)存在对象头的 Mark Word 里。举个例子:

如果某个人是个垃圾人,就在这个人的头上盖一个“垃圾”的章;如果这个人不是垃圾了,就把这个人头上的“垃圾”印章洗掉。

而 ZGC 是这样做的:

如果某个人是垃圾人。就在这个人的身份证信息里面标注这个人是个垃圾,以后不管这个人在哪刷身份证,别人都知道他是个垃圾人了。也许哪一天,这个人醒悟了不再是垃圾人了,就把这个人身份证里面的“垃圾”标志去掉。

在这例子中,“这个人”就是一个对象,而“身份证”就是指向这个对象的指针。

ZGC 将信息存储在指针中,这种技术有一个高大上的名字——染色指针(Colored Pointer)。



在 64 位的机器中,对象指针是 64 位的。

  • ZGC 使用 64 位地址空间的第 0~43 位存储对象地址,2^44 = 16TB,所以 ZGC 最大支持 16TB 的堆。

  • 而第 44~47 位作为颜色标志位,Marked0、Marked1 和 Remapped 代表三个视图标志位,Finalizable 表示这个对象只能通过 finalizer 才能访问。

  • 第 48~63 位固定为 0 没有利用。

读屏障

读屏障是 JVM 向应用代码插入一小段代码的技术。当应用线程从堆中读取对象引用时,就会执行这段代码。千万不要把这个读屏障和 Java 内存模型里面的读屏障搞混了,两者根本不是同一个东西,ZGC 中的读屏障更像是一种 AOP 技术,在字节码层面或者编译代码层面给读操作增加一个额外的处理。

读屏障实例:



Object o = obj.FieldA      // 从堆中读取对象引用,需要加入读屏障<load barrier needed here>  Object p = o               // 无需加入读屏障,因为不是从堆中读取引用o.dosomething()            // 无需加入读屏障,因为不是从堆中读取引用int i =  obj.FieldB        // 无需加入读屏障,因为不是对象引用
复制代码

ZGC 中读屏障的代码作用:

GC 线程和应用线程是并发执行的,所以存在应用线程去 A 对象内部的引用所指向的对象 B 的时候,这个对象 B 正在被 GC 线程移动或者其他操作,加上读屏障之后,应用线程会去探测对象 B 是否被 GC 线程操作,然后等待操作完成再读取对象,确保数据的准确性。具体的探测和操作步骤如下:



这样会影响程序的性能吗?

会。据测试,最多百分之 4 的性能损耗。但这是 ZGC 并发转移的基础,为了降低 STW,设计者认为这点牺牲是可接受的。

ZGC 并发处理算法

ZGC 并发处理算法利用全局空间视图的切换和对象地址视图的切换,结合 SATB 算法实现了高效的并发。

以上所有的铺垫,都是为了讲清楚 ZGC 的并发处理算法,在一些博文上,都说染色指针和读屏障是 ZGC 的核心,但都没有讲清楚两者是如何在算法里面被利用的,我认为,ZGC 的并发处理算法才是 ZGC 的核心,染色指针和读屏障只不过是为算法服务而已。

ZGC 的并发处理算法三个阶段的全局视图切换如下:

  • 初始化阶段:ZGC 初始化之后,整个内存空间的地址视图被设置为 Remapped

  • 标记阶段:当进入标记阶段时的视图转变为 Marked0(以下皆简称 M0)或者 Marked1(以下皆简称 M1)

  • 转移阶段:从标记阶段结束进入转移阶段时的视图再次设置为 Remapped



标记阶段

标记阶段全局视图切换到 M0 视图。因为应用程序和标记线程并发执行,那么对象的访问可能来自标记线程和应用程序线程。



在标记阶段结束之后,对象的地址视图要么是 M0,要么是 Remapped。

  • 如果对象的地址视图是 M0,说明对象是活跃的;

  • 如果对象的地址视图是 Remapped,说明对象是不活跃的,即对象所使用的内存可以被回收。

当标记阶段结束后,ZGC 会把所有活跃对象的地址存到对象活跃信息表,活跃对象的地址视图都是 M0。



转移阶段

转移阶段切换到 Remapped 视图。因为应用程序和转移线程也是并发执行,那么对象的访问可能来自转移线程和应用程序线程。



至此,ZGC 的一个垃圾回收周期中,并发标记和并发转移就结束了。

为何要设计 M0 和 M1

我们提到在标记阶段存在两个地址视图 M0 和 M1,上面的算法过程显示只用到了一个地址视图,为什么设计成两个?简单地说是为了区别前一次标记和当前标记。

ZGC 是按照页面进行部分内存垃圾回收的,也就是说当对象所在的页面需要回收时,页面里面的对象需要被转移,如果页面不需要转移,页面里面的对象也就不需要转移。



如图,这个对象在第二次 GC 周期开始的时候,地址视图还是 M0。如果第二次 GC 的标记阶段还切到 M0 视图的话,就不能区分出对象是活跃的,还是上一次垃圾回收标记过的。这个时候,第二次 GC 周期的标记阶段切到 M1 视图的话就可以区分了,此时这 3 个地址视图代表的含义是:

  • M1:本次垃圾回收中识别的活跃对象。

  • M0:前一次垃圾回收的标记阶段被标记过的活跃对象,对象在转移阶段未被转移,但是在本次垃圾回收中被识别为不活跃对象。

  • Remapped:前一次垃圾回收的转移阶段发生转移的对象或者是被应用程序线程访问的对象,但是在本次垃圾回收中被识别为不活跃对象。

现在,我们可以回答“使用地址视图和染色指针有什么好处”这个问题了

使用地址视图和染色指针可以加快标记和转移的速度。以前的垃圾回收器通过修改对象头的标记位来标记 GC 信息,这是有内存存取访问的,而 ZGC 通过地址视图和染色指针技术,无需任何对象访问,只需要设置地址中对应的标志位即可。这就是 ZGC 在标记和转移阶段速度更快的原因。

当 GC 信息不再存储在对象头上时而存在引用指针上时,当确定一个对象已经无用的时候,可以立即重用对应的内存空间,这是把 GC 信息放到对象头所做不到的。

ZGC 步骤

ZGC 采用的是标记-复制算法,标记、转移和重定位阶段几乎都是并发的,ZGC 垃圾回收周期如下图所示:



ZGC 只有三个 STW 阶段:初始标记再标记初始转移

其中,初始标记和初始转移分别都只需要扫描所有 GC Roots,其处理时间和 GC Roots 的数量成正比,一般情况耗时非常短;

再标记阶段 STW 时间很短,最多 1ms,超过 1ms 则再次进入并发标记阶段。即,ZGC 几乎所有暂停都只依赖于 GC Roots 集合大小,停顿时间不会随着堆的大小或者活跃对象的大小而增加。与 ZGC 对比,G1 的转移阶段完全 STW 的,且停顿时间随存活对象的大小增加而增加。

ZGC 的发展

ZGC 诞生于 JDK11,经过不断的完善,JDK15 中的 ZGC 已经不再是实验性质的了。

从只支持 Linux/x64,到现在支持多平台;从不支持指针压缩,到支持压缩类指针…



在 JDK16,ZGC 将支持并发线程栈扫描(Concurrent Thread Stack Scanning),根据 SPECjbb2015 测试结果,实现并发线程栈扫描之后,ZGC 的 STW 时间又能降低一个数量级,停顿时间将进入毫秒时代。



ZGC 已然是一款优秀的垃圾收集器了,它借鉴了 Pauseless GC,也似乎在朝着 C4 GC 的方向发展——引入分代思想。

Oracle 的努力,让我们开发者看到了商用级别的 GC“飞入寻常百姓家”的希望,随着 JDK 的发展,我相信在未来的某一天,JVM 调优这种反人类的操作将不复存在,底层的 GC 会自适应各种情况自动优化。

ZGC 确实是 Java 的最前沿的技术,但在 G1 都没有普及的今天,谈论 ZGC 似乎为时过早。但也许我们探讨的不是 ZGC,而是 ZGC 背后的设计思路。

希望你能有所收获!

写在最后

为了对每一篇发出去的文章负责,力求准确,我一般是参考官方文档和业界权威的书籍,有些时候,还需要看一些论文,看一部分源代码。而官方文档和论文一般都是英文,对于一个英语四级只考了 456 分的人来说,非常艰难,整个过程都是谷歌翻译和有道词典陪伴着我的。因为一些专业术语翻译的不够准确,还需要英文和翻译对照慢慢理解。

但即使这样,也难免会有纰漏,如果你发现了,欢迎提出,我会对其修正。

看完三件事❤️

  • 如果你觉得这篇内容对你还蛮有帮助,我想邀请你帮我三个小忙:

  • 点赞,转发,有你们的 『点赞和评论』,才是我创造的动力。

  • 关注公众号 『 java 烂猪皮 』,不定期分享原创知识。

  • 同时可以期待后续文章 ing🚀


作者:coderW

出处:https://club.perfma.com/article/2379190

用户头像

AI乔治

关注

分享后端技术干货。公众号【 Java烂猪皮】 2019.06.30 加入

一名默默无闻的扫地僧!

评论 (2 条评论)

发布
用户头像
设计理念是本质

但也许我们探讨的不是 ZGC,而是 ZGC 背后的设计思路。

6 小时前
回复
用户头像
期待,自动优化

随着 JDK 的发展,我相信在未来的某一天,JVM 调优这种反人类的操作将不复存在,底层的 GC 会自适应各种情况自动优化。

6 小时前
回复
没有更多了
Java最前沿技术——ZGC