本文的阅读有必定的门槛,请先了解 GC 的基本只知识。前端
现代垃圾收集器的演进大部分都是往减小停顿方向发展。java
像 CMS 就是分离出一些阶段使得应用线程能够和垃圾回收线程并发,固然还有利用回收线程的并行来减小停顿的时间。面试
基本上 STW 阶段都是利用多线程并行来减小停顿时间,而并发阶段不会有太多的回收线程工做,这是为了避免和应用线程争抢 CPU,反正都并发了慢就慢点(不过仍是得考虑内存分配速率)。算法
而 G1 能够认为是打开了另外一个方向的大门:只回收部分垃圾来减小停顿时间。微信
不过为了达到只回收部分 reigon,每一个 region 都须要 RememberSet 来记录各 region 之间的引用。这个内存的开销其实仍是挺大的,可能会占据整堆的20%或以上。多线程
而且 G1 还有写屏障的开销,虽然说用了 logging wtire barrier,但也仍是有开销的。架构
固然 CMS 也用了写屏障,不过逻辑比较简单,啥都没判断就单纯的记录。并发
其实 G1 相对于 CMS 只有在大堆的场景下才有优点,CMS 比较伤的是 remark 阶段,若是堆太大须要扫描的东西太多。app
而 G1 在大堆的时候能够选择部分收集,因此停顿时间有优点。负载均衡
今天的主角 ZGC 和 G1 同样是基于 reigon 的,几乎全部阶段都是并发的,整堆扫描,部分收集。
并且 ZGC 还不分代,就是没分新生代和老年代。
那它为啥比 G1 要牛皮?今天我们就来盘一盘。
本文会先介绍 ZGC 的特性,或者说几个关键点,而后再简述下总体回收流程。
基本上看下来对 ZCG 心中就有数了,做为普通的 Javaer,了解到这个程度就差很少了。
好了,让咱们进入今天的正题!
ZGC 的目标
垃圾收集器设计出来都有目标的,有些是为了更高的吞吐,有些是为了更低的延迟。
因此咱们先看看 ZGC 的目标:
能够看到它的目标就是低延迟,保证最大停顿时间在几毫秒以内,无论你堆多大或者存活的对象有多少。
能够处理 8MB-16TB 的堆。
我们就按 openjdk 的 wiki 来展开今天的内容。
关键字:并发、基于Region、整理内存、支持NUMA、用了染色指针、用了读屏障,对了 ZGC 用的是 STAB。
Concurrent
这个 Concurrent 的意思是和应用线程并发执行,ZGC 一共分了 10 个阶段,只有 3 个很短暂的阶段是 STW 的。
能够看到只有初始标记、再标记、初始转移阶段是 STW 的。
初始标记就扫描 GC Roots 直接可达的,耗时很短,从新标记通常而言也很短,若是超过 1ms 会再次进入并发标记阶段再来一遍,因此影响不大。
初始转移阶段也是扫描 GC Roots 也很短,因此能够认为 ZGC 几乎是并发的。
并且之因此说停顿时间不会随着堆的大小和存活对象的数量增长而增长,是由于 STW 几乎只和 GC Roots 集合大小有关,和堆大小没啥关系。
这其实就是 ZGC 超过 G1 很关键的一个地方, G1 的对象转移须要 STW 因此堆大须要转移对象多,停顿的时间就长了,而 ZGC 有并发转移。
不过并发回收有个状况就是回收的时候应用线程仍是在产生新的对象,因此须要预留一些空间给并发时候生成的新对象。
若是对象分配过快致使内存不够,在 CMS 中是发生 Full gc,而 ZGC 则是阻塞应用线程。
因此要注意 ZGC 触发的时间。
ZGC 有自适应算法来触发也有固定时间触发,因此能够根据实际场景来修改 ZGC 触发时间,防止过晚触发而内存分配过快致使线程阻塞。
还有设置 ParallelGCThreads 和 ConcGCThreads,分别是 STW 并行时候的线程数和并发阶段的线程数来加快回收的速度。
不过 ConcGCThreads 数量须要注意,由于此阶段是和应用线程并发,若是线程数过多会影响应用线程。
其实 ZGC 的每一个阶段都是串行的,因此理论上其实能够不须要分两类线程,那为何分了这两类线程?
就是为了灵活设置。分红两类就能够经过配置来调优,达到性能最大值。
对了上面提到 ZGC 的 STW 和 GC Roots 集合大小有关系,因此若是在会生成不少线程、动态加载不少 ClassLoader 等状况下会增长 ZGC 的停顿时间。
这点须要注意。
Region-based
为了能更细粒度的控制内存的分配,和 G1 同样 ZGC 也将堆划分红不少分区。
分了三种:2MB、32MB 和 X*MB(受操做系统控制)。
下图为源码中的注释:
对于回收的策略是优先收集小区,中、大区尽可能不回收。
Compacting
和 G1 同样都分区了因此确定从总体来看像是标记-复制算法,因此也是会整理的。
所以 ZGC 也不会产生内存碎片。
具体的流程下文再作分析。
NUMA-aware
之前的 G1 是不支持的,不过在 JDK14 G1 也支持了。
可能有的同窗对 NUMA 不太熟悉,没事我先来解释一波。
在早期处理器都是单核的,由于根据摩尔定律,处理器的性能每隔一段时间就能够成指数型增加。
而近年来这个增加的速度逐渐变缓,因而不少厂商就推出了双核多核的计算机。
早期 CPU 经过前端总线到北桥到内存总线而后才访问到内存。
这个架构被称为 SMP (Symmetric Multi-Processor),由于任一个 CPU 对内存的访问速度是一致的,不用考虑不一样内存地址之间的差别,因此也称一致内存访问(Uniform Memory Access, UMA )。
这个核心越加越多,渐渐的总线和北桥就成为瓶颈,那不可以啊,因而就想了个办法。
把 CPU 和内存集成到一个单元上,这个就是非一致内存访问 (Non-Uniform Memory Access,NUMA)。
简单的说就是把内存分一分,每一个 CPU 访问本身的本地的内存比较快,访问别人的远程内存就比较慢。
固然也能够多个 CPU 享受一块内存或者多块,以下图所示:
可是由于内存被切分为本地内存和远程内存,当某个模块比较“热”的时候,就可能产生本地内存爆满,而远程内存都很空闲的状况。
好比 64G 内存一分为二,模块一的内存用了31G,而另外一个模块的内存用了5G,且模块一只能用本地内存,这就产生了内存不平衡问题。
若是有些策略规定不能访问远程内存的时候,就会出现明明还有不少内存却产生 SWAP(将部份内存置换到硬盘中) 的状况。
即便容许访问远程内存那也比本地内存访问速率相差较大,这是使用 NUMA 须要考虑的问题。
ZGC 对 NUMA 的支持是小分区分配时会优先从本地内存分配,若是本地内存不足则从远程内存分配。
对于中、大分区的话就交由操做系统决定。
上述作法的缘由是生成的绝大部分都是小分区对象,所以优先本地分配速度较快,并且也不易形成内存不平衡的状况。
而中、大分区对象较大,若是都从本地分配则可能会致使内存不平衡的状况。
Using colored pointers
染色指针其实就是从 64 位的指针中,拿几位来标识对象此时的状况,分别表示 Marked0、Marked一、Remapped、Finalizable。
咱们再来看下源码中的注释,很是的清晰直观:
0-41 这 42 位就是正常的地址,因此说 ZGC 最大支持 4TB (理论上能够16TB)的内存,由于就 42 位用来表示地址。
也所以 ZGC 不支持 32 位指针,也不支持指针压缩。
而后用 42-45 位来做为标志位,其实无论这个标志位是啥指向的都是同一个对象。
这是经过多重映射来作的,很简单就是多个虚拟地址指向同一个物理地址,不过对象地址是 0001.... 仍是0010....仍是0100..... 对应的都是同一个物理地址便可。
具体这几个标记位怎么用的,待下文回收流程分析再解释。
不过这里先提个问题,为何就支持 4TB,不是还有不少位没用吗?
首先 X86_64 的地址总线只有 48 条 ,因此最多其实只能用 48 位,指令集是 64 位没错,可是硬件层面就支持 48 位。
由于基本上没有多少系统支持这么大的内存,那支持 64 位就不必了,因此就支持到 48 位。
那如今对象地址就用了 42 位,染色指针用了 4 位,不是还有 2 位能够用吗?
是的,理论上能够支持 16 TB,不过暂时认为 4TB 够了,因此暂作保留,仅此而已没啥特别的含义。
Using load barriers
在 CMS 和 G1 中都用到了写屏障,而 ZGC 用到了读屏障。
写屏障是在对象引用赋值时候的 AOP,而读屏障是在读取引用时的 AOP。
好比 Object a = obj.foo;
,这个过程就会触发读屏障。
也正是用了读屏障,ZGC 能够并发转移对象,而 G1 用的是写屏障,因此转移对象时候只能 STW。
简单的说就是 GC 线程转移对象以后,应用线程读取对象时,能够利用读屏障经过指针上的标志来判断对象是否被转移。
若是是的话修正对象的引用,按照上面的例子,不只 a 能获得最新的引用地址,obj.foo 也会被更新,这样下次访问的时候一切都是正常的,就没有消耗了。
下图展现了读屏障的效果,其实就是转移的时候找地方记一下即 forwardingTable,而后读的时候触发引用的修正。
这种也称之为“自愈”,不只赋值的引用时最新的,自身引用也修正了。
染色指针和读屏障是 ZGC 能实现并发转移的关键所在。
ZGC 回收流程解析
ZGC 的步骤大体可分为三大阶段分别是标记、转移、重定位。
- 标记:从根开始标记全部存活对象
- 转移:选择部分活跃对象转移到新的内存空间上
- 重定位:由于对象地址变了,因此以前指向老对象的指针都要换到新对象地址上。
而且这三个阶段都是并发的。
这是意识上的阶段,具体的实现上重定位实际上是糅合在标记阶段的。
在标记的时候若是发现引用的仍是老的地址则会修正成新的地址,而后再进行标记。
简单的说就是从第一个 GC 开始经历了标记,而后转移了对象,这个时候不会重定位,只会记录对象都转移到哪里了。
在第二个 GC 开始标记的时候发现这个对象是被转移了,而后发现引用仍是老的,则进行重定位,即修改为新的引用。
因此说重定位是糅合在下一步的标记阶段中。
我再简单说一下十个步骤。
不过步骤里有些不影响总体回收流程的,我就很少加分析了。
这篇文章的目的不是深刻 ZGC 实现的细节,而是了解 ZGC 大体的突出点和简单流程便可。
所以想知道细节的自行查阅,或者能够看看我文末推荐的书籍。
初始标记
这个阶段其实你们应该很熟悉,CMS、G1 都有这个阶段,这个阶段是 STW 的,仅标记根直接可达的对象,压到标记栈中。
固然还有其余动做,好比重置 TLAB、判断是否要清除软引用等等,不作具体分析。
并发标记
就是根据初始标记的对象开始并发遍历对象图,还会统计每一个 region 的存活对象的数量。
这个并发标记其实有个细节,标记栈其实只有一个,可是并发标记的线程有多个。
为了减小之间的竞争每一个线程其实会分到不一样的标记带来执行。
你就理解为标记栈被分割为好几块,每一个线程负责其中的一块进行遍历标记对象,就和1.7 Hashmap 的segment 同样。
那确定有的线程标记的快,有的标记的慢,那么先空闲下来的线程会去窃取别人的任务来执行,从而实现负载均衡。
看到这有没有想到啥?没错就是 ForkJoinPool 的工做窃取机制!
再标记阶段
这一阶段是 STW 的,由于并发阶段应用线程仍是在运行的,因此会修改对象的引用致使漏标的状况。
所以须要个再标记阶段来标记漏标的那些对象。
若是这个阶段执行的时间过长,就会再次进入到并发标记阶段,由于 ZGC 的目标就是低延迟,因此一有高延迟的苗头就得扼制。
这个阶段还会作非强根并行标记,非强根指的是:系统字典、JVMTI、JFR、字符串表。
有些非强根能够并发,有些不行,具体不作分析。
非强引用并发标记和引用并发处理
就是上一步非强根的遍历,而后引用就软引用、弱引用、虚引用的一些处理。
这个阶段是并发的。
重置转移集
还记得标记时候的重定位么?在写读屏障时候提到的 forwardingTable 就是个映射集,你能够理解为 key 就是对象转移前的地址,value 是对象转移后的地址。
不过这个映射集在标记阶段已经用了,也就是说标记的时候已经重定位完了,因此如今没用了。
但新一轮的垃圾回收须要仍是要用到这个映射集的。
所以在这个阶段对那些转移分区的地址映射集作一个复位的操做。
回收无效分区
回收那些物理内存已经被释放的无效的虚拟内存页面。
就是在内存紧张的时候会释放物理内存,若是同时释放虚拟空间的话也不能释放分区,由于分区须要在新一轮标记完成以后才能释放。
因此就会有无效的虚拟内存页面存在,在这个阶段回收。
选择待回收的分区
这和 G1 同样,由于会有不少能够回收的分区,会筛选垃圾较多的分区,来做为此次回收的分区集合。
初始化待转移集合的转移表
这一步就是初始化待回收的分区的 forwardingTable。
初始转移
这个阶段其实就是从根集合出发,若是对象在转移的分区集合中,则在新的分区分配对象空间。
若是不在转移分区集合中,则将对象标记为 Remapped。
注意这个阶段是 STW,只转移根直接可达的对象。
并发转移
这个阶段和并发标记阶段就很相似了,从上一步转移的对象开始遍历,作并发转移。
这一步很关键。
G1 的转移对象总体都须要 STW,而 ZGC 作到了并发转移,因此延迟会低不少。
至此十个步骤就完毕了,一次 GC 结束。
能够还能同窗对染色指针的几个标记位有点懵,没事看了下文就懂了。
染色指针的标记位
来分析下几个标记位,M0、M一、Remapped。
先来介绍个名词,地址视图:指的就是此时地址指针的标记位。
好比标记位如今是 M0,那么此时的视图就是 M0 视图。
在垃圾回收开始前视图是 Remapped 。
在进入标记标记时。
标记线程访问发现对象地址视图是 Remapped 这时候将指针标记为 M0,即将地址视图置为 M0,表示活跃对象。
若是扫描到对象地址视图是 M0 则说明这个对象是标记开始以后新分配的或者已经标记过的对象,因此无需处理。
应用线程 若是建立新对象,则将其地址视图置为 M0,若是访问的对象地址视图是 Remapped 则将其置为 M0,而且递归标记其引用的对象。
若是访问到的是 M0 ,则无需操做。
标记阶段结束后,ZGC 会使用一个对象活跃表来存储这些对象地址,此时活跃的对象地址视图是 M0。
并发转移阶段,地址视图被置为 Remapped 。
也就是说 GC 线程若是访问到对象,此时对象地址视图是 M0,而且存在或活跃表中,则将其转移,并将地址视图置为 Remapped 。
若是在活跃表中,可是地址视图已是 Remapped 说明已经被转移了,不作处理。
应用线程此时建立新对象,地址视图会设为 Remapped 。
此时访问对象若是对象在活跃表中,且地址视图为 Remapped 说明转移过了,不作处理。
若是地址视图为 M0,则说明还未转移,则须要转移,并将其地址视图置为 Remapped 。
若是访问到的对象不在活跃表中,则不作处理。
那 M1 什么用?
M1 是在下一次 GC 时候用的,下一次的 GC 就用 M1来标记,不用 M0。
再下一次再换过来。
简单的说就是 M1 标识本次垃圾回收中活跃的对象,而 M0 是上一次回收被标记的对象,可是没有被转移,在本次回收中也没有被标记活跃的对象。
其实从上面的分析以及得知,若是没有被转移就会停留在 M0 这个地址视图。
而下一次 GC 若是仍是用 M0 来标识那混淆了这两种对象。
因此搞了个 M1。
至此染色指针这几个标志位应该就很清晰了,我在用图来示意一下。
不清晰的同窗建议再多看几遍标记位的变动,不复杂的。
最后
简单的总结下,ZGC 就是经过多阶段的并发和几个短暂的 STW 阶段来达到低延迟的特性。
利用指针染色技术和读屏障实现并发转移对象,利用 STAB 保证并发阶段不会漏标对象。
这一波一下相信你们对于 ZGC 有了必定的了解。
我我的认为重点就掌握官网罗列的那几个要点就行,毕竟我们也不是写 GC 的,做为了解便可。
到时候和学妹呀,或者在面试官前面呀均可以小吹一下。
若是想深刻了解固然能够,可先看看《新一代垃圾回收器ZGC设计与实现》这本书,而后再源码走起。
ZGC 的不分代实际上是它的缺点,由于分代比较难实现,不过之后应该会加上吧。
其实从现代垃圾收集器的演进能够看出就是往并发上面靠,目标就是减小停顿的时间。
不过并发须要注意内存分配的速率,由于并发致使一次垃圾回收总的时间变长了。
若是内存分配过快那就回收不过来了,所以都须要预留内存空间或者说要更大的内存空间来应对快速的分配速率。
可能大伙还惦记这标题吧?ZGC 的 Z 是什么意思?
其实没啥意思,就是个名字而已。
欢迎关注个人公众号【yes的练级攻略】,更多硬核文章等你来读。
巨人的肩膀
https://www.iteye.com/blog/user/rednaxelafx R大的博客 https://malloc.se/blog/zgc-jdk15 https://wiki.openjdk.java.net/display/zgc/Main 《新一代垃圾回收器ZGC设计与实现》
微信搜索【yes的练级攻略】,关注 yes,回复【123】一份20W字的算法刷题笔记等你来领,从一点点到亿点点,咱们下篇见。