ZGC收集器
欢迎来到我的博客:TWind的博客
我的CSDN::Thanwind-CSDN博客
我的掘金:Thanwinde 的个人主页
0.前言
ZGC收集器完全可以说是Java收集器的一个跨时代的收集器,他真正意义上实现了停顿时间在10ms以内并且几乎全时段都是并发的
而且其性能比之前的所有的收集器都更加优越,但初代由于没有分代机制,导致了其不能承受太高的新建对象的速率
但这些在JDK21中被解决,分代ZGC具有更快的速度以及更低的停顿成为了当之无愧的面向延迟的收集器之王
但值得的一提的是,这并不意为着ZGC是所有情况下的最优解,如你所见,ZGC是一个面向延迟的收集器,它实现了在把延迟降到极致的情况下保持不错的吞吐量,而且和ShenandoahGC一样是针对于超大堆(几百G更高的这种),每个场景都有自己适合的收集器(G1全能的含金量还在上升)
尽管它因为没有分代设计而导致可能无法适应高内存分配速度的场景,但仍然瑕不掩瑜,而且在JDK21, Generational ZGC 横空出世,完美解决了这个问题
ZGC收集器
ZGC(Z Garbage Collector),出现于JDK11,主打低延迟,高性能,能控制停顿时间在10ms以内,并实现了基本全程并发
ZGC采用了类似G1的Region的内存分区,参考
但抛弃了分代理论,这带来了优劣:好处是不用像G1那样维护庞大且复杂的卡表,坏处是无法享受分区带来的高效率清理那些“浮动的”对象
为什么抛弃,并不是分代理论不好,而是过于复杂暂时无法实现(在JDK21实现)
这导致了ZGC不能应对高速率创建新对象的场景:如果是分代的话,这些会被划到新生代针对性处理,而ZGC只会全局扫描一起处理
这就可能导致在清理期间内存又被撑满,导致不得不全局停顿来清理
但即便如此,ZGC仍然瑕不掩瑜:它低到10ms的延迟,不低的吞吐量非常适合大流量注重延迟的业务,比如:新一代垃圾回收器ZGC的探索与实践 - 美团技术团队
现在,让我们来具体了解一下ZGC
内存布局
ZGC采用了类似于G1的内存布局,将内存分为一个个Region,但是区别在于:
-
没有新生代,老年代等
-
没有卡表(没有跨代)
-
具体大小固定:
-
小型区/页(
Small
):固定大小为2MB
,用于分配小于256KB
的对象。中型区/页(
Medium
):固定大小为32MB
,用于分配>=256KB ~ <=4MB
的对象。大型区/页(
Large
):没有固定大小,容量可以动态变化,但是大小必须为2MB
的整数倍,专门用于存放>4MB
的巨型对象。但每个Large只能存放一个对象,无论你这个对象多大。而且Large是不会重分配(后面解释)除了大型区,其他两个区都可能会容纳不止一个对象,且不一定装满:剩下的空间会被浪费掉
ZGC抛弃了分代换来了更简单的内存布局,但是代价就是无法应对高速产生的对象,逻辑分区是这个问题的最优解,可惜ZGC没有实现,但可喜的是,jdk21 时ZGC补全了这最后一块拼图
染色指针
染色指针是什么?可以类比java的对象头:对象头存储了一个对象的基本信息,譬如哈希值,偏向锁等等,这样就可以在不实际访问这个对象的前提下得到这个对象的信息。
在GC中,确定回收哪些对象时要用到三色标记,在ZGC之前都是要用其他的数据结构来维护这个对象的状态:已遍历(黑),未遍历(白),未完全遍历(灰)这增加了不少了负担。而染色指针将这个信息直接设法集成到了这个对象的指针当中:省去了查表的操作
具体是:
对于64位的Linux系统来说,一个指针有64位,却只会用到46位来寻址:46位已然达到了64TB的大小,ZGC就从中提取出了四位用来mark,即使这样,剩下的42位也有4TB:
- 第一位Finalizable,用来标志这个对象是否是用finaliza,这个功能目前已然废弃
- 第二位Remapped,意为重映射,可以简单理解为是用来标志这个对象是否是未活跃:象征着不活跃,会被回收
- 二三位M1和M0,都是用来标记活跃的,区别在于两个只使用一个,另外一个表示上一次GC的结果,举个例子,假如对象A第一轮被标记了M0,第二轮时如果没有被标记,那他还是M0,但这时判断的是M1,就会把对象A回收,如果都是一个M标记的话,就无法处理这种情况
但这会有一个问题:需要其他的方法来处理指针,让其能够正确寻址
一般来说,采用了染色指针会导致内存看上去为原来的三倍
为什么?因为0 1 0 0 + 42位寻址指针,0 0 1 0 + 42位寻址指针 , 0 0 0 1 + 42位寻址指针指向的是同一个地址
其它的内存软件不会去额外的解析,就会看成三个地址
这意味着JVM必须要对这个地址进行特殊的解析才能正常使用
这样子的代价除了减少了可用内存,还有不能采用指针压缩:正常情况下,64位的指针会被压缩到32位以节省一半的空间
但影响并不大:当内存超过4G本身就会禁用指针压缩,all in all,染色指针非常有用
具体流程
让我们再具体的分析:
首先,第一阶段:
并发标记
这里和其他的收集器最开始的行为一样:遍历所有对象来找到GC root,这里会触发STW,同时,如果不是第一次GC,这里会顺便更新引用
一开始,所有的对象都是Remapped状态,随着被标记会变成M0,并且会维护一个的 RSet(回收集),会记录下要回收的Region,到时候就会把这里面存活的对象移走(如果有)然后回收掉原本的区域
并发预备重分配
这里是并发的,具体来说,这里会扫描整个内存区域以看那些Reign要回收:像G1一样,会去回收最有价值的Region:比如一个全是待回收的对象的Region显然是最有价值的
对于这时新建的对象,会默认被标成Remapped
并且对于类卸载以及弱引用也是在这个阶段处理的
与G1不同点就出来了:用扫描范围换取了维护卡表的负担
并发重分配
这里做的主要就是把并发标记的回收集中的Reigon回收,听上去简单,但做着可不见得简单,具体来说:
会把要留下的对象复制到新的Regio中,同时会维护一个转发表来记录对象的老地址和新地址
如果此时有线程来访问这个老地址,会被JVM的内存屏障捕获,查看其指针,如果是M0(M1),也就是存活对象,就会被查转发表转发到他的新地址,并把这个指针修改成新地址,这被称之为指针的“自愈”,而且这种比较慢的转发只会发生一次,因为以后的访问都会被修正,这个做法比起Shenandoah的转发指针大幅降低了负载且减少了屏障的使用
如果这时候用户线程新建了对象,也是Remapped状态
并发重映射
严格来说,这个阶段是和并发标记重合的,因为这个阶段会去把转发表里面那些还没有“自愈”的引用修复
这个行为不是很迫切,因为如果其他线程随时访问都能正常访问到新的地址
目的是在于释放掉这个转发表并且避免再访问老地址会变慢一次的缺陷
因此,ZGC将其合并到了最开始的并发标记阶段之中:反正都要遍历所有的对象,随便修复了
优势点
不难看出,ZGC是基于标记-整理的,一定程度上牺牲了清理效率(虽然是被迫的)而带来了极短的停顿时间
它在中负荷的场景中性能极其优异,但是由于并没有分代策略,导致其无法应付大量对象快速创建的情景,可能会发生全局停顿导致极大的延时
但它仍然是卓越的,新颖的,而更令人兴奋的是,在JDK21, Generational ZGC 横空出世。