一、引言
垃圾回收对于Javaer来说是一个绕不开的话题,工作中涉及到的调优工作也经常围绕垃圾回收器展开。面对不同的业务场景没有一个统一的垃圾回收器能保证可GC性能。因此对程序员来说不仅要会编写业务代码,同时也要卷一下JVM底层原理和调优知识。这种局面可能因为ZGC的出现而发生改变,新一代回收器ZGC几乎不需要调优的情况下GC停顿时间可以降低到亚秒级。
Oracle从JDK11开始正式引入ZGC,ZGC设计三大目标:
- 支持TB级内存 (8M~4TB) 。
- 停顿时间控制在10ms之内 (生产环境实际观测在微秒级) ,停顿不会随着堆的大小,或者活跃对象的大小而增加。
- 对程序吞吐量影响小于15%。
ZGC是如何设计怎么达到这个目标的呢?本文将从ZGC算法的关键特性入手,通过分析ZGC周期处理过程来理解这些特性,探索ZGC设计思想。
二、ZGC术语
非分代:将对内存划分为新生代和老年代 (G1已经逻辑分代) ,ZGC取消分代设计,每个GC周期都将标记整个堆中的所有活动对象。
页面:ZGC将堆空间分解成一块块区域,这些区域叫做页面,ZGC通过页面来回收内存。
并发性:GC和线程和业务线程同时运行。ZGC的高度并发设计,几乎所有GC工作、标记和堆碎片整理都是和业务线程 (mutators) 同时运行的,只包含了短暂的STW同步暂停。
并行:多个线程进行GC线程同时工作,加快回收速度。
标记-复制算法:标记-复制算法主要包括以下3个过程。
- 标记阶段,即从GC Roots集合开始,分析对象可达性,标记出活跃对象。
图1:可达性分析后对象的引用状态
- 对象转移阶段,即把活跃对象复制到新的内存地址上。
- 重定位阶段,因为转移导致对象的地址发生了变化,在重定位阶段,所有指向对象旧地址的指针都要调整到对象新的地址上。
标记-复制算法的最大优势就是防止堆内存碎片化的出现,复制的过程就可以对堆内存进行整理。ZGC、CMS和G1都是采用了标记-复制算法,但是不同的实现导致了很大的性能差异。
三、ZGC性能数据
ZGC设计致力于提供几毫秒的最大暂停时间,同时保证吞吐量不受影响。下面是SPECjbb2015针对OpenJDK中的不同收集器运行的性能测试数据。在128G堆内存下,无论是延迟还是吞吐量上面ZGC的性能表现都高于其他收集器。
图2:SPECjbb2015GC性能评分
图3: SPECjbb2015GC延迟比较
四、ZGC关键特性
ZGC的周期是高度并发的,并发性越高意味着GC工作时对业务线程的影响越小,SPECjbb2015的性能报告可以看出ZGC在延迟上比G1低10倍以上,ZGC的工作周期只有三个阶段是STW的,其他阶段完全并发。这得益于ZGC在堆视图并发一致性设计上的改进。我们都清楚在并发的场景下需要协调各个线程对共享资源达成一致性,常用的手段就是对资源加锁,而在垃圾回收器下的思路也是类似,如果GC线程工作是需要锁定对象资源进行处理,业务线程则需要全部暂停,这就产生了STW (Stop The Word) 。以往的垃圾回收器都是让GC线程和业务线程就堆中对象地址达成一致,对象在发生转移时业务线程是不能访问的 (因为对象的地址发生了变化) ,无论G1还是CMS对象在进行复制时都是需要STW。ZGC使用到的着色指针(Colored Pointer)和读屏障(Load Barrier)技术,可以让所有线程在并发的条件下就指针的颜色 (状态) 达成一致,而不是对象地址。因此,ZGC可以并发的复制对象,这大大的降低了GC的停顿时间。我们先对着色指针和读屏障有个初步的理解,然后在通过ZGC回收周期来看这2项技术的具体运用。
着色指针(Colored Pointer)
在指针中嵌入元数据(使用地址中的高阶位来实现),这种通过在指针存储元数据的技术就叫做着色指针 (Colored Pointer) 。ZGC中指针始终是64位结构,由元位(指针的颜色)和地址位组成。地址位数决定了理论上支持的最大堆大小,ZGC使用42位存储地址也就意味着ZGC最大支持4TB堆内存。如图所示,低42位是地址位,中间4位是元位,高18位未使用。四个元位是Finalized ( F )、Remapped ( R )、Marked1 ( M1 ) 和Marked0 ( M0 )。
图4: 64位地址使用示意图
ZGC中将指定上的标记通过颜色来表示,颜色可以是“good” (地址有效) 或“bad” (地址可能无效) 。指针的颜色由其元位的状态决定:F、R、M1和M0。“good”是R、M1、M0元位中的一个被设置,另外三个未设置,比如0100、0010和 0001属于“good”颜色。通过在指针上的颜色就能区分出对象状态,不用额外做内存访问,这使得ZGC在标记和转移阶段会更快。
通过设置地址元位的状态,可以形成不同地址视图,ZGC同一物理堆内存被映射到虚拟地址空间三次,从而产生同一物理内存的三个“视图”,GC活动的不同时期会只存在一个活跃视图,根据垃圾回收的周期ZGC通过切换不同视图标来记出对象的颜色。
下图是虚拟地址的空间划分:
图5:虚拟地址空间划分和多视图映射
[0~4TB) 对应Java堆;
[4TB ~ 8TB) 称为M0地址空间;
[8TB ~ 12TB) 称为M1地址空间;
[12TB ~ 16TB) 预留未使用;
[16TB ~ 20TB) 称为Remapped空间。
ZGC是不分代的,这意味着垃圾回收是需要扫描整个堆空间,地址视图将整个Java堆分成多个部分,并为每个部分分配一个虚拟内存段。在垃圾回收时,ZGC只需要扫描其中一个虚拟内存段,并将其作为当前视图映射到实际的内存位置。同时,ZGC会将其他虚拟内存段映射到虚拟地址上,这些内存段不会被收集器扫描。
读屏障(Load Barrier)
ZGC 通过利用读屏障而不是写入屏障,与HotSpot JVM中以前的GC (CMS,G1等) 算法显著不同。读屏障解决了并发转移时对象指针更新问题:在转移期间,如果移动对象而不用更新引用对象的传入指针(移动的对象可能被堆中的任何其他对象所引用),就会产生悬空指针 (已经被释放的内存空间或者无效的内存地址,访问悬空指针会出现问题) 。通过读屏障技术能够捕获此类悬空指针对象,并触发代码,更新对象的新位置,从而“修复”悬空指针。为了跟踪对象如何移动,以便在加载时固定悬空指针,ZGC中使用转发表 (forwarding tables ) 来将重定位前(旧)地址映射到重定位后(新)地址。无论是业务线程作为使用者访问对象,还是GC线程遍历堆中的所有活动对象(在标记期间)都有可能会触发读屏障。
ZGC读屏障如何实现呢?举个例子,代码 var x = obj.fieldvar x = obj.fieldvar x = obj.field。x是一个位于堆栈上的局部变量,field是一个位于堆上的指针。业务线程在操作堆对象时触发读屏障。读屏障的执行路径有快 (fast path) 和慢 (slow path) 两种,如果正在加载的指针有效状态 (good color) ,则采用加载屏障的快速路径,否则,采用慢速路径。快速路径实际上是空的,而慢速路径包含计算有效状态指针的逻辑:检查对象是否已经(或即将)重新定位,如果是,则查找或生成新的地址。读屏障除了能让触发读屏障的线程读取到最新地址,同时还具有自我修复指针(self-healed)的功能,这意味着读屏障会修改指针的状态,以便后续其他线程访问时能执行快速路径。无论采用哪条路径,都会返回正确状态的地址。下面用伪代码表示ZGC在执行读屏障时的大体逻辑:
/**
slot 是值线程栈中的局部变量,也就是屏障要操作的目标对象
*/
unintptr_t barrier(unintptr_t *slot,unintptr_t addr){
//快速路径,fast path
if(is_good_or_null(addr))return addr;
//慢速路径,slow path
good_addr = process(addr);
//自我修复
self_heal(slot,addr,good_addr);
return good_addr;
}
/*
自我修复,将指针恢复到正常状态
*/
void self_heal(unintptr_t *slot,unintptr_t old_addr,unintptr_t new_addr){
if(new_addr == 0)return;
while(true){
if(CAS(slot,&old_addr,new_addr)
return;
if(is_good_or_null(old_addr))
return;
}
}
ZGC的读屏障可能被GC线程和业务线程触发,并且只会在访问堆内对象时触发,访问的对象位于GC Roots时不会触发,这也是扫描GC Roots时需要STW的原因。
下面是一个简化的示例代码,展示了读屏障的触发时机。
Object o = obj.FieldA // 从堆中读取引用,需要加入屏障
<Load barrier>
Object p = o // 无需加入屏障,因为不是从堆中读取引用
o.dosomething() // 无需加入屏障,因为不是从堆中读取引用
int i = obj.FieldB //无需加入屏障,因为不是对象引用
五、ZGC执行周期
如下图 所示,ZGC 周期由三个 STW 暂停和四个并发阶段组成:标记/重新映射( M/R )、并发引用处理( RP )、并发转移准备( EC ) 和并发转移( RE )。为了读者能快速理解,下面对ZGC执行过程进行了大量简化。
图6:ZGC周期表示
初始标记(STW1)
ZGC 初始标记执行包含三个主要任务。
- 地址视图被设置成M0 (或M1) ,M0还是M1根据前一周期交替设置的。
- 重新分配新的页面给业务线程创建对象,ZGC只会处理当前周期之前分配的页面。
- 初始标记只会存活的根对象被标记为M0 (M1) ,并被加入标记栈进行并发标记。
GC周期中地址视图窗口
图7:ZGC周期中状态窗口划分
并发标记(M/R)
并发标记的任务有2个:
第一,并发标记线程从待标记的对象列表出发,根据对象引用关系图遍历对象的成员变量,递归进行标记。
第二,计算,并更新关联页面的活跃度信息。活动信息是页面上的活动字节数,用于选择将要回收的页面,这些对象将作为堆碎片整理的一部分进行重新定位。
下面伪代码是并发标记的主要过程:
while(obj in mark_stack){
//标记存活对象,当且仅当该对象未被标记并且当前线程成功标记该对象时才返回true
success = mark_obj(obj);
if(success){
for(e in obj->ref_fields()){
MarkBarrier(slot_of_e,e);
}
}
}
//GC线程调用
//EC是待回收页面的集合
void MarkBarrier(uintptr_t *slot,unintptr_t addr){
if(is_null(addr))return;
//判断是否在待回收集合内
if(is_pointing_into(addr,EC)){
//地址重映射到当前GC视图
good_addr = remap(addr);
} else {
good_addr = good_color(addr);
}
//访问的对象添加到标记栈
mark_stack->add(good_addr);
self_heal(slot,addr,good_addr);
}
//读屏障前面有介绍过,由业务线程调用
void LoadBarrier(uintptr_t *slot,unintptr_t addr){
if(is_null(addr))return;
if(is_pointing_into(addr,EC)){
good_addr = remap(addr);
} else {
good_addr = good_color(addr);
}
mark_stack->add(good_addr);
self_heal(slot,addr,good_addr);
return good_addr;
}
代码片段显示了并发标记阶段的GC线程主循环。mark_obj()当且仅当该对象未被标记并且当前线程成功标记该对象时才返回 true。它在内部使用原子操作(compare and swap,CAS)来设置位图中的位,因此它是线程安全的。MarkBarrier()遍历该对象的成员属性,完成对象引用的标记。并发标记时业务线程也在运行,此前如果业务线程访问对象将执行LoadBarrier()协助GC线程完成对象标记。
再标记阶段(STW2)
再标记阶段的主要任务有3个:
- 执行修复任务,指线程运行C2编译的代码,在进入再标记阶段时可能发生漏标。
- 结束标记,并发标记后业务线程本地标记栈可能存在待标记的对象,执行本步骤的目的就是对这些待标记对象进行标记。
- 执行部分非强根并行标记。
并发转移准备(EC)
并发转移准备任务:
- 筛选所有可以被回收的页面
- 选择垃圾比较多的页面作为页面转移集
初始转移(STW3)
初始转移主要以下过程:
- 调整地址视图:将地址视图从M0或者M1调整为Remapped,说明进入真正的转移,此后所有分配的对象视图都是Remapped。
- 重定位TLAB:因为地址视图调整,所以要调整TLAB中地址的视图。
- 开始转移:从根集合出发,遍历根对象的直接引用的对象,对这些对象进行转移。
初始转移是STW的,其处理时间和GC Roots的数量成正比,一般情况耗时非常短。
并发转移(RE)
初始转移完成了GC Roots对象重定位,在并发转移阶段将对前面步骤确定的转移集 (EC) ,对转移集的每一页执行转移。
并发转移的过程可以抽象成如下伪代码过程:
//GC线程主循环遍历EC的页面,将个将EC集页面中对象进行转移
for (page in EC){
for(obj in page){
relocate(obj);
}
}
//该方法GC和业务线程都有可能执行,如果是业务线程访问对象会先进行转移在进行操作
unintptr_t relocate(unintptr_t obj) {
//获取对象的地址转发表
ft = forwarding_tables_get(obj);
if (ft->exist(obj)){
return ft->get(obj);
}
new_obj = copy(obj);
//CAS写对象转发表数据
if(ft->insert(obj,new_obj)){
return new_obj;
}
//CAS发生竞争,写转发表失败,释放分配的内存
dealloc(new_obj)
return ft->get(obj);
}
转发表的作用是存储对转移后旧地址到新地址的映射,转发表的数据存储在页面中,转移完成的页面即可被回收掉。
并发转移完成之后整个ZGC周期完成。
六、ZGC算法演示
为了说明ZGC算法,下图演示了示例中的所有阶段。
图8:ZGC算法演示
图8(1)显示了堆的初始状态,应用启动后ZGC完成了初始化。
在图8(2)中,选择M0作为全局标记,并且所有根指针都被标记成M0。然后,所有根都被推送到标记堆栈,该标记堆栈在并发标记 (M/R) 期间由GC线程消耗。
如图8(3)所示,图中用合适的颜色绘制对象本身,以表明它们已被标记,即使指针有状态。
在图8(4) 中,选择存活对象最少的页面(中间的页面)作为转移候选集 (EC) 。
随后,在图8(5)中,全局标记被设置为Remmaped,并且所有根指针都已更新Remmaped。如果根指向EC,则相应的对象将被重新定位,并且根指针更新为新地址。
在图8(6)中,EC中的对象被转移,并且地址记录被逐出页面中转发表上,用于新旧地址转换。当并发转移阶段结束时,当前GC周期也会结束。当前周期内整个EC都会被回收。这里可能有个疑问,对象的旧地址还没有更新,页面如果被回收了如何还能访问对象呢?原因是回收的是页面中对象存储空间,转发表不会被回收,如果此时业务线程访问这些对象,会触发读屏障的慢路径位,失效指针会被修复。对于没有访问到的失效指针,直到下一个GC并发标记 (M/R) 阶段才会被修复。
在图8(7)中,下一个GC循环开始,M1被选择为全局状态(M0 和 M1 之间交替使用)。
在图8(8)中,并发标记阶段 (M/R) 通过查询转发表失效的指标被映射到新位置。
最后,在图8(9)中,上一周期EC页面的转发表被回收,为即将到来的并发转移 (RE) 阶段做准备。
七、总结
ZGC是一个十分复杂的JVM子系统,没办法通过一篇文章把所有的细节描述清楚。本文详细探讨了ZGC的着色指针和读屏障关键技术,他们也是ZGC中创新点,最后通过一个示例对ZGC算法过程做了一个简化版的演示。通过对ZGC这种复杂系统的学习,让我也体会到分析复杂系统时没必要一开始就过多的纠结实现细节,可以先从关键流程入手再层层深入。
ZGC的高并发设计造就了它的高性能,背后要归功于着色指针和读屏障运用,当然除了这2项还有其他精妙的设计比如:内存模型,并发模型,预测算法等这里不展开,读者可以参考其他文章。了解ZGC的基本原理可以帮助优化应用程序的性能,为应用调优做些知识储备。最后,ZGC有卓越的性能和稳定性表现,我们在选择GC选型时可以优先考虑使用ZGC。
参考内容:
[1]彭成寒:《新一代垃圾回收器ZGC设计与实现》.机械工业出版社, 2019.
[2]https://tech.meituan.com/2020/08/06/new-zgc-practice-in-meituan.html
[3]https://www.baeldung.com/jvm-zgc-garbage-collector
[4]https://openjdk.org/projects/zgc/
[5]https://www.jfokus.se/jfokus18/preso/ZGC--Low-Latency-GC-for-OpenJDK.pdf
*文 / byteyangyang
本文属得物技术原创,更多精彩文章请看:得物技术官网
未经得物技术许可严禁转载,否则依法追究法律责任!