JVM之(Shenandoah、ZGC收集器)

引言

衡量GC性能的最重要指标

  1. 内存占用
  2. 吞吐量
  3. 低延时

a而随着计算机硬件的发展,其内存大了,性能好了,我们对内存的要求也放宽了限制(可以容忍GC器多占用一点内存),自然吞吐量也上去了(因为性能好了)。可是,内存大了那么时延必定相应增大了。

Shenandoah收集器

地位:shenandoah是一款只有 OpenJDK才会包含,而 OracleJDK里反而不存在的收集器,因为它不是由Oracle(包括以前的Sun)公司的虚拟机团队所领导开发的HotSpot垃圾收集器。

G1与Shenanndoah的比较

相同点

Shenandoah和G1收集器都有着相似的堆内存布局,在初始标记,并发标记等许多的阶段处理思路都是一样的,并共享了一部分实现代码,比如它们都有在并发失败后作为逃生门的fullGC.都是使用基于Region的内存布局,同样有着用于存放大对象的HumongousRegion,默认的回收策略也同样是优先处理回收价值最大的Region等等

不同点

  1. Shenandoah支持并发的"复制整理"
  2. Shenandoah默认不使用分代收集,也就是说不会有新生代老年代
  3. Shenandoah摒弃了在G1中耗费大量内存和计算资源维护的记忆集,改用名为"连接矩阵(Connection Matrix)"的全局结构来记录跨Region的引用关系,降低了处理器跨带指针的记忆集维护消耗,也降低了伪共享问题的发生率

连接矩阵的使用"连接矩阵可以理解为一个二阶矩阵,如果Region N有对象指向Region M,就在矩阵的N行M处做标记

Shenandoah收集器的工作过程

  1. 初始标记(同G1一样):标记GC Roots直接关联的对象,STW,但不是与堆大小无关,只和GC Roots的数量有关
  2. 并发标记(同G1一样)遍历对象图,标记出全部可达的对象,和用户线程并发
  3. 最终标记(同G1一样):处理剩余的SATB扫描,并回收价值最高的Region,令其构成回收集,STW;
  4. 并发清理:清理整个区域内连一个对象都没有找到的Region
  5. 并发回收(与G1的核心差异):Shenandoah要把回收集里面的存活的对象先复制一份到其他未被使用的Region中

如何做到并发复制呢?先看看难点:在移动对象的同时,用户线程任然可能不听对被移动对象进行读写访问,移动对象是一次性行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象地址,这是很难一瞬间全部改变过来的针对这以困难,Shenandoah的处理方式:通过读屏障和被称为Brooks Poninters的转发指针来解决.并发回首阶段运行的时间取决于回收集的大小.

  1. 初始引用更新:把堆中所有指向就对象的引用修正到复制后的新地址的这个操作称为引用更新

    • 注:引用更新的初始化阶段实际上并未做什么具体的处理,设立这个阶段只是为了建立一个线程的集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已
  2. 并发引用更新:正在开始执行引用更新操作,这个阶段是与用户线程一起并发的,它不需要再沿着对象图搜索,只需要按照内存物理地址的顺序,线性的搜索出引用类型,把旧值改为新值即可
  3. 最终引用更新:修正存在于GC Roots中的引用.这个阶段是Shenandoah的最后一次停顿,停顿时间只与GC Roots的数量相关
  4. 并发清理:最后再调用一次并发清理过程来回收这些Region的内存空间,供以后对象分配使用

注:

Shenandoah收集器的工作过程大致可以划分为以上九个阶段(这是Shnandoah的早期版本划分的),不过在Shenandoah2.0中进一步强化了"部分手机的特性",初始标记钱还要Initial Partial,Concurrent Partial和Final Partial阶段,他们可以不太严谨的理解为对于以前分代集中的Minor GC 的工作

Brooks Pointer

Brooks是一个人的名字,1984提出了使用转发指针(forwarding Pointer 也被称为Indirection Pointer)来实现对象移动与用户线程并发的一种解决方案

在这之前,我们要实现类似的并发操作的大致思路如下

  • 通常是在被移动对象,原有的内存上设置保护陷阱
  • 一旦用户程序访问到归属于旧对象的内存空间就会产生自陷中断,进入预设好的异常处理器中,再由其中的代码逻辑把访问转发到复制后的新对象上

缺点

虽然确实能够实现对象移动与用户线程并发,但是如果没有操作系统层面的直接支持,这种方案将导致用户频繁切换到核心态,代价是非常大的, 不能频繁使用

新方案

如今的新方案:不需要用到内存保护陷阱,而是在原有对象布局结构的最前面统一增加一个新的引用字段

性能分析

  1. 从结构上看,转发指针和早起JVM使用句柄定位比较像:俩者都是一种简介性对象访问方式,差别是句柄通常会统一存储在专门的句柄池中,而转发指针是分散存放在每一个对象头的前面
  2. 既然都是间接性对象访问的方式,那么就有特有的缺点:每次对象的访问,会带来一次额外的转向开销,尽管这个开销已经被优化到只有一行汇编指令的程度
  3. 虽然这是一笔不小的开销,但是对于内存保护陷阱方案已经有了很大的改善:因为当我们复制对象时,也就是指向新的对象,这样便可将所有对该对象的访问转发到了新副本上(这样旧对象的内存仍然存在,未被清理掉,虚拟机内存中所有通过旧引用地址访问的代码便仍然可用,都会被自动转发到新的对象上进行工作)

转发指针的相关操作的注意事项

  1. 对转发指针的访问操作采取同步措施:让收集器线程或者用户线程对转发指针的访问只有其中之一能成功,另一个必须要等待,避免俩者交替执行;二维码可以通过CAS操作保证这种行为
  2. 执行频率问题:监管通过对象头上的Brooks Pointer来保证并发时源对象与复制对象的访问一致性,但这个对象访问其实是很复杂的:包括对象的读取写入,对象的比较,对象哈希值计算,对象加锁等;而我们要覆盖全部对象访问操作,Shenandoah不得不同时设置,读,写屏障去拦截会导致一些问题

读写屏障的问题

  • 写屏障:无论是为了维护卡表,还是用于实现并发标记,写屏障已经被使用多次,累积了不少处理任务了,这些写屏障有相当一部分在Shenandoah收集器中依然要被用到
  • 读屏障:为了实现Books Pointer,Shenandoah在读,写屏障中加入了额外的转发处理,不过代码里的对象读取的出现频率要比对象的写入频率高很多,读屏障数量自然也比写屏障多得多,所以读屏障的使用必须更加谨慎,不允许任何的重量级操作.Shenandoah是我们分析的回收机中第一款用到读屏障的收集器,
  • 读写屏障的改进:JDK13中Shenandoah的内存屏障模型改进为基于引用访问屏障的实现,所谓"引用访问屏障"是指内存屏障只拦截对象中数据类型为引用类型的读写操作,而不去管原生数据类型等非引用字段的读写,这样能够省去大量对原生类型,对象比较,对象加锁等常见中设置内存屏障带来的消耗

Shenandoah收集器

强项:低延迟时间,建立量化的的概念

弱项:高运行负担使得吞吐量下降

ZGC

ZGC是一款在JDK11中新加入的具有实验性质的低延迟垃圾收集器,是由oracle公司开发的

目标:ZGC和Shenandoah的目标是高度相似的,都希望在尽可能对吞吐量影响不太大的前提下,实现在任意堆内存大小下都可以把垃圾收集的停顿时间限制在10毫秒以内的延迟

Oracle公司开发的ZGC就像是AzulSystem公司独步天下的PGC和C4收集器的同胞兄弟,造在2005年,运行在AzulVM上的PGC就实现了标记和整理阶段都全程与用户线程并发运行的垃圾收集,而ZGC几乎所有的关键技术上,与PGC和C4都只存在术语称谓上的差别

主要特征

GC采用基于Region的布局(暂时)不设分代,使用了读屏障,染色指针和内存多重映射等技术来实现可并发的标记整理算法,以低延迟为首要目标的一款垃圾收集齐器

Region的差异:ZGC的Region(在一些官方资料中将它称为page或者ZPage,本文一致继续称为Region)具有形态性--动态销毁和动态创建,以及动态的区域容量大小.在X64硬件平台下,ZGC的Region具有大中小三类容量

  1. 小型Region:固定容量为2MB,用于防止对象<=256KB
  2. 中型Region:固定容量为32MB,用于放置256KB<=对象<4mb的对象
  3. 大型Region:容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象.每个大型Region中只会存放一个大对象,这也预示虽然名字叫做大型Region,但它实际容量完全有可能小于中型Region,最小容量可低至4MB.大型Region在ZGC的实现中不会被重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段)

在上文我们分析过,Shenandoah使用转发指针和读屏障来实现并发整理;而ZGC更加巧妙,那就是染色指针技术来替代转发指针

在上文我们已经说过对象访问相关的操作,如果我们要在对象上存储一些额外的,只供收集器或者虚拟机本身使用的数据,通常会在对象童中增加额外的字段,如对象的哈希码,分代年龄,锁记录就是这样存储的.那我们考虑一个问题:当这种在对象头记录信息的方式,在有对象访问的情况下自然是很好的一种选择,但是如果这个对象被移动了呢?如果我们只是单纯了解某些信息比如对象是否被移动过,比如对象是否被引用(三色标记)而不是访问对象呢?那么我们就要考虑别的记录信息的方式,比如通过指针或者与内存无关的地方来得到这些信息

我们接下来看一下hospot虚拟机不同收集器的不同标记实现方式

  1. serial收集器:把标记直接记录在对象头上,
  2. G1,Shenandoah:吧标记记录在于对象相互独立的数据结构上(一种相当于堆内存1/64大小的bitmap的结构来记录标记的数据)
  3. ZGC染色指针技术,是最直接的,最纯粹的,它直接吧标记信息记在引用对象的指针上,这时,与其说可达性分析是遍历对象图来标记对象,不如说是遍历引用图来标记引用了

染色指针技术到底是什么

染色指针

是一种直接将少量额外的信息存储在指针上的技术

指针怎么存储呢?

​ 不同的操作系统采取不同的存储方式,大致都一样,只不过位数不一样.比如在64位系统中,理论上可以存储2*64字节, 不过实际上是做不到的,当然也用不到那么多内存(因为位数越长,在做地址转换的时候需要的页级数越多,成本也更高)在AMD64架构中只支持到52位 4PB的总线地址和256TB的虚拟地址空间,所以目前64位硬件实际能够支持的内存只有256TB.此外操作系统一侧还会施加自己的约束,所以使用的位数就更小了

比如

  • 64位的linux分别支持128TB的虚拟地址空间和46位(64TB)的物理空间地址
  • 64位的windows系统只支持44位(16T)的物理地址空间

监管linux下64位搞18位不能用来寻址,但剩余的46位指针能支持的64TB内存仍然能够充分满足大型服务器需要.鉴于此,ZGC的染色指针技术继续盯上了这剩下的46位指针宽度,将其高4位提取出来存储四个标志信息.通过这些标志位,虚拟机可以直接从指针中看到其引用对象的三色标记状态,是否进入了重分配集(即被移动过)是否通过finalize()方法才能被访问到.当然由于这些标志位进一步压缩了只有46位的地址空间,也直接导致,ZGC能够管理的内存不超过4TB

染色指针的三大优势

  1. 染色指针可以使得一旦某个Region的存活对象被移走之后,这个Region立即就能被释放和重用,而不必等待整个堆中所有指向该Region的引用修正后才被清理.这点比起Shenandoah是一个颇大的优势,使得理论上只要还有一个空闲的regionZGC就能完成收集,而Shenandoah需要等到引用更新阶段结束后才能释放回收集中的Region,这意味着对中几乎所有对象都存活的极端情况,需要1:1复制对象到新Region的话,就必须要有一半的空闲Region来完成收集
  2. 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,设置内存屏障尤其是写屏障的目的通常是为了记录对象引用变动的情况,如果将这些信息直接维护在指针中,显然可以省去一些专门的记录操作.实际上ZGC都并未使用任何写屏障(这也说明ZGC对吞吐量影响很小),只是用了读屏障(一部分是染色指针的功劳,一部分是ZGC现在还不支持分代收集,天然就没有跨带引用问题)

    • 内存屏障(Memory Barrier)的目的是为了指令不因编译优化,cpu执行优化等原因导致乱序执行,她也可以细分为仅确保读操作顺序正确和仅确保写操作顺序正确性的内存屏障
  3. 染色指针可以作为一种可扩展的存储结构用来记录更多与对象标记,重定位过程相关的数据,以便日后进一步提高性能.现在linux下64位指针后移前18位未使用,它们虽然不能用来寻址,缺口由通过其他手段用于记录信息.如果开发了这18位,既可以腾出4个标志位,将ZGC可以支持的最大堆内存从4TB扩展到64TB,也可以利用其余位置再存储更多的标致,譬如存储一些追踪信息来让垃圾收集齐在移动对象时能将低频次使用的对象移动到不常访问的内存区域

限制:只能在64位系统上,因为ZGC设置就是用的42-46位,32位明显不够嘛。。并且不支持压缩指针(这一块可以参考Java对象模型中的OOP,meta中有一个Klass直接指向Klass,还一个压缩指针)如下。

union _metadata {
    之前都是oop,现在直接指向Klass了
    Klass*      _klass;
    narrowKlass _compressed_klass;
} _metadata;

染色体指针技术带来的问题:java虚拟机作为一个普普通通的进程,这样随意重新定义内存中某些指针的其中几位,操作系统是否支持?处理器是否支持(多重映射技术)?

无论中间过程如何,程序代码最终都要转化为机器

指令流交付给处理器运行,处理器可不会管指令流中的指针哪部分的是标志位,哪部分才是真正寻址的地址,只会把整个指针都看做一个内存地址来对待

这个问题在Solaris/SPARC平台上比较容易解决,因为SPARC硬件层面本身就支持虚拟地址掩码,设置之后器及其指令直接就可以忽略掉染指针中的标志位.但在x86=64平台上并没有提供类似的黑科技,ZGC的设计者就只能采取其他补救措施了,这里的解决方案设计虚拟内存映射技术

  • ZGC使用了内存多重映射(Multo-Mapping)将多个不同的虚拟内存地址映射到一个屋里内存地址上这是一种多对一映射.
  • 因为染色指针指示重新定义内存中某些指针的其中几位,OS有不支持,OS只会把整个指针当做一个内存地址来对待,为了解决这个问题,又使用了现代处理器的虚拟内存映射技术
  • 现代处理器一般使用请求分页机制+虚拟内存映射技术
  • 请求分页机制把线性地址空间和物理地址空间分别划分为大小相等的快.这样的块称为页.通过在线性虚拟空间的页和物理地址空间的页建立映射表,分页机制会进行线性地址到物理地址的映射,完成线性地址到物理地址的转换
  • Linus,/X86-64平台上的ZGC使用额多重映射讲多个不同的虚拟内存地址映射到同一个物理内存地址上,多对一映射.意味着ZTGC在虚拟内存空间中看到的地址空间比实际的堆内存容量更大
  • 吧染色指针的标志位看作是地址的分段符,只要吧这些不同的地址段映射到同一个物理地址空间就行了,结果多重映射转换后,就可以使用染色指针正常寻址了
  • 标志位就是上图的Remapped,Marked1,Marked0。 ZGC多重映射下的寻址

在某些场景下,多重映射技术确实可能会带来一些诸如复制大对象时会更容易这样的好处,可从根源上讲,ZGC的多重映射只是它采用染色指针技术的半生产物,

运作过程

ZGC的运作过程主要分为以下阶段

并发标记Concurrent Mark

  1. 初始标记先STW,先记录下GCRoots直接引用的对象
  2. 并发标记 根据初始的结果,基于GCroots可达性分析算法找出所有被引用对象,在G1,Shenandoah中使用bitmap结构来记录三色标记,ZGC使用染色指针做标记
  3. 最终标记先STW,然后修复一些并发标记过程中状态出现变化的对象

并发预备重分配Concurrent Prepare for Relocate

这个阶段ZGC会根据特定的查询条件扫描一下Region并得出本次收集过程中需要清理哪些Region,将他们组成重分配集(Relocation)用范围更大的扫描去省去G1中记忆集的维护成本.

并发重分配Concurrent Relocate

初始重分配:做一些并发重分配需要的初始化动作

并发标记:这个阶段需要将并发与准备重分配阶段计算出来的重分配几种的Region复制到新的Region并为每一个Region维护一个转发表(forward Table)

记录从旧对象到新对象的转向关系,从转发表ZGC就可以明确的知道哪些对象是否处于重分配集中,在这个阶段时,如果有用户线程访问这个对象,这次访问将会被预制的内存屏障(读屏障)所截获,然后根据Region的转发表找出新的地址并访问,如果有更新再更新新地址上面的值,并使其指向新对象(这样只有第一次访问时会变慢,后面就可以不通过读屏障和转发表直接访问),ZGC将这种行为称为指针的自愈能力

一旦一个Region中的对象全部复制完成,旧的Region就可以清理释放掉了,但是转发表不能立即释放,因为可能还有访问在使用这个转发表,因为旧地址转新地址是在对象被引用之后才会进行的操作

并发重映射

重映射其实就是讲旧地址转化为新的地址,由于ZGC中对象引用存在自愈功能,所以这个阶段其实不做也是可以的,ZGC很巧妙的将这一阶段合并到了下一次的并发标记阶段,反正他们都是要遍历对象的,这样也减少了一次遍历对象的开销,一个Region的所有对象都被修改后,那么这个Region就会被销毁掉

相比G1、Shenandoah,ZGC的优劣

相比G1,Shenandoah等先进的垃圾收集齐,ZGC在实现细节上做了一些不同的权衡选择:

比如G1要通过写屏障来维护记忆集,才能处理跨带指针,得以实现Region的增量回收.而记忆集要占用大量内存空间,写屏障也对正常程序运行造成额外负担,这些都是权衡的代价

ZGC就完全没有使用记忆集,它甚至连分代都没有,连像CMS中那样只记录新生代老年代间引用的卡表也不需要,因而完全没有用到写屏障,所以给用户线程带来的运行负担要小很多.

可是必定有优劣才会称作权衡,ZGC的这种选择业限制了它能承受的对象分配速率不会太高.可以想象以下场景来理解ZGC的这个劣势:ZGC准备要要对一个很大的堆做一次完整的并发收集,假设其全过程要持续十分钟以上(切勿混淆并发时间与丁顿时间,ZGC是停顿时间不超过10毫秒),在这段时间里面,由于应用的对象分配速率很高,将创造大量的新对象,这些新对象很难进入档次手机的标记范围,通常只能全部当做存活对象来看待,监管其中绝大部分对象都是朝生夕死的,这就产生了大量的浮动垃圾.如果这种高速分配持续维持的话,每一次完整的并发收集周期都会很长,回收到的内存空间持续小于期间并发产生浮动垃圾所占的空间,堆中剩余可腾挪的空间就越来越小了

目前唯一的办法就是 尽可能地增加堆容量大小,获得更多喘息的时间。但是若要从根本上提升ZGC能够应对的对象分配速率,还是需要 引入分代收集,让新生对象都在一个专门的区域中创建, 然后专门针对这 个区域进行更频繁、更快的收集。Azul的C4收集器实现了分代收集后,能够应对的对象分配速率就比 不分代的PGC收集器提升了十倍之多。

NUMA-Aware

NUMA(Non-Uniform Memory Access,非统一内存访问架构)是一种 为多处理器或者多核处理器的计算机所设计的内存架构。由于摩尔定律逐渐失效,现代处理器因频率发展受限转而向多核方向发展,以前原本在北桥芯片中的内存控制器也被集成到了处理器内核中,这样 每个处理器核心所在的裸晶(DIE)都有 属于自己内存管理器所管理的内存,如果要访问被其他处理器核心管理的内存,就必须通过InterConnect通道来完成,这要比访问处理器的本地内存慢得多。

在NUMA架构下,ZGC收集器会优先尝试在请求线程当前所处的处理器的本地内存上分配对象,以保证高效内存访问。在ZGC之前的收集器就 只有针对吞吐量设计的Parallel Scavenge支持NUMA内存分配,如今ZGC也成为另外一个选择。

  • 在性能方面,尽管目前还处于实验状态,还没有完成所有特性,稳定性打磨和性能调优也仍在进行,但即使是这种状态下的ZGC,其性能表现已经相当亮眼,从官方给出的测试结果来看,用“令人震惊的、革命性的ZGC”来形容都不为过。

ZGC与Parallel Scavenge、G1三款收集器通过SPECjbb 2015的测试结果。在 ZGC的“弱项”吞吐量方面,以低延迟为首要目标的ZGC已经达到了以高吞吐量为目标Parallel Scavenge 的99%,直接超越了G1。 如果将吞吐量测试设定为面向SLA(Service Level Agreements)应用 的“Critical Throughput”的话,ZGC的表现甚至还反超了Parallel Scavenge收集器。
而在ZGC的强项停顿时间测试上,它就毫不留情地与Parallel Scavenge、G1拉开了两个数量级的差距。不论是平均停顿,还是95%停顿、99%停顿、99.9%停顿,抑或是最大停顿时间,ZGC均能毫不费劲地控制在十毫秒之内,以至于把它和另外两款停顿数百近千毫秒的收集器放到一起对比,就几乎显示不了ZGC的柱状条,必须把结果的纵坐标从线性尺度调整成对数尺度才能观察到ZGC的测试结果。

Last modification:January 4, 2023
如果觉得我的文章对你有用,请随意赞赏