Shenandoah首次出现在Open JDK12中,是由Red Hat开发,主要为了解决之前各种垃圾回收器处理大堆时停顿较长的问题。
相比较G1将低停顿做到了百毫秒级别,Shenandoah的设计目标是将停顿压缩到10ms级别,且与堆大小无关。它的设计非常激进,很多设计点在权衡上更倾向于低停顿,而不是高吞吐。
Shenandoah是OpenJDK中的垃圾处理器,但相比较Oracle JDK中根正苗红的ZGC,Shenandoah可以说更像是G1的继承者,很多方面与G1非常相似,甚至共用了一部分代码。
1.G1的回收是需要STW的,而且这部分停顿占整体停顿时间的80%以上,Shenandoah则实现了并发回收。
G1中每个Region都要维护卡表,既耗费计算资源还占据了非常大的内存空间,Shenandoah使用了连接矩阵来优化了这个问题。
连接矩阵可以简单理解为一个二维表格,如果Region A中有对象指向Region B中的对象,那么就在表格的第A行第B列打上标记。
相比G1的记忆集来说,连接矩阵的颗粒度更粗,直接指向了整个Region,所以扫描范围更大。但由于此时GC是并发进行的,所以这是通过选择更低资源消耗的连接矩阵而对吞吐进行妥协的一项决策。
想要达到并发回收,就需要在用户线程运行的同时,将存活对象逐步复制到空的Region中,这个过程中就会在堆中同时存在新旧两个对象。那么如何让用户线程访问到新对象呢?
此前,通常是在旧对象原有内存上设置保护陷阱(Memory Protection Trap),当访问到这个旧对象时就会发生自陷异常,使程序进入到预设的异常处理器中,再由处理器中的代码将访问转发到复制后的新对象上。
自陷是由线程发起来打断当前执行的程序,进而获得CPU的使用权。这一操作通常需要操作系统参与,那么就会发生用户态到内核态的转换,代价十分巨大。
所以Rodney A.Brooks提出了使用转发指针来实现通过旧对象访问新对象的方式:在对象头前面增加一个新的引用字段,在非并发移动情况下指向自己,产生新对象后指向新对象。那么当访问对象的时候,都需要先访问转发指针看看其指向哪里。虽然和内存自陷方案相比同样需要多一次访问转发的开销,但是前者消耗小了很多。
1.对象体增加了一个转发指针,这个指针的修改和对象本身的修改就存在了线程安全问题。如果通过被访问就可能发生复制了新对象后,转发对象修改之前发生了旧对象的修改,这就存在两个对象不一致的问题了。对于这个问题,Shenandoah是通过CAS操作来保证修改正确性的。
2.转发指针的加入需要覆盖所有对象访问的场景,包括读、写、加锁等等,所以需要同时设置读屏障和写屏障。尤其读操作相比单纯写操作出现频率更高,这样高频操作带来的性能问题影响巨大。所以Shenandoah在JDK13中对此进行了优化,将内存屏障模型改为引用访问屏障,也就是说,仅仅在对象中引用类型的读写操作增加屏障,而不去管原生对象的操作,这就省去了大量的对象访问操作。
处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集。
将回收集里面的存货对象复制到一个其他未被使用的Region中。并发复制存活对象,就会在同一时间内,同一对象在堆中存在两份,那么就存在该对象的读写一致性问题。Shenandoah通过使用转发指针将旧对象的请求指向新对象解决了这个问题。这也是Shenandoah和其他GC最大的不同。
并发回收后,需要将所有指向旧对象的引用修正到新对象上。这个阶段实际上并没有实际操作,只是设置一个阻塞点来保证上述并发操作均已完成。
ZGC是Oracle官方研发并JDK11中引入,并于JDK15中作为生产就绪使用,其设计之初定义了三大目标:
随着JDK的迭代,目前JDK16及以上版本,ZGC已经可以实现不超过1毫秒的停顿,适用于堆大小在8MB到16TB之间。
ZGC和G1一样也采用了分区域的堆内存布局,不同的是,ZGC的Region(官方称为Page,概念同G1的Region)可以动态创建和销毁,容量也可以动态调整。
3.大型Region容量为2MB的整数倍,存放4MB及以上大小的对象,而且每个大型Region中只存放一个大对象。由于大对象移动代价过大,所以该对象不会被重分配。
G1中的回收集用来存放所有需要G1扫描的Region,而ZGC为了省去卡表的维护,标记过程会扫描所有Region,如果判定某个Region中的存活对象需要被重分配,那么就将该Region放入重分配集中。
通俗的说,如果将GC分为标记和回收两个主要阶段,那么回收集是用来判定标记哪些Region,重分配集用来判定回收哪些Region。
和Shenandoah相同,ZGC也实现了并发回收,不同的是前者是使用转发指针来实现的,后者则是采用染色指针的技术来实现。
三色标记本质上与对象无关,仅仅与引用有关:通过引用关系判定对像存活与否。HotSpot虚拟机中不同垃圾回收器有着不同的处理方式,有些是标记在对象头中,有些是标记在单独的数据结构中,而ZGC则是直接标记在指针上。
64位机器指针是64位,Linux下64位中高18位不能用来寻址,剩下46位中,ZGC选择其中4位用来辅助GC工作,另外42位能够支持最大内存为4T,通常来说,4T的内存完全够用。
因为ZGC标记完成后并不需要等待对象指针重映射就可以进行下一次垃圾回收循环,也就是说两次垃圾回收的全过程是有重叠的,所以使用两个标记位分别用作两次相邻GC过程的标记,M0和M1交替使用。
3. 标记完成后,开始进行并发重分配。最终目标是将A、B、C三个存活对象都移动到新的Region中去。
复制完成的对象,指针就可以由M0改为Remapped,并将旧对象到新对象到映射关系保存到转发表中。
4. 如果此时系统访问对象C,会触发读屏障,将原引用修正到新的对象C的地址上去,并转发访问,最后删除转发表的记录。
实际上,如果没有对象D的存在,在上一步所有存货对象转移完成后,旧的Page就可以被回收了,依靠指针和转发表就可以将所有访问转发到新的Page中去。
6. 下一次并发标记开始后,由于上一次垃圾回收循环并没有完成,所以Remapped指针被标记为M1,用来和上一次的存活对象标记作区分。
可以看出,并发标记的过程中,ZGC是通过读屏障来保证访问的正确转发,并且由于染色指针采用惰性更新的策略,相比Shenandoah每次都要先访问转发指针的两次寻址来说快上不少。
1.由于染色指针提供的“自愈”能力,当某个Page被清除后可以立刻被回收,而无需等待修正全部指向该Page的引用。
2.ZGC完全不需要使用写屏障,原因有二:由于使用染色指针,无需更新对象体;没有分代所以无需记录跨代引用。
染色指针只是JVM定义的,操作系统、处理器未必支持。为了解决这个问题,ZGC在Linux/x86-64平台上采用了虚拟内存映射技术。
ZGC为每个对象都创建了三个虚拟内存地址,分别对应Remapped、Marked 0和Marked 1,通过指针指向不同的虚拟内存地址来表示不同的染色标记。
ZGC没有分代,这一点并不是技术权衡,而是基于工作量的考虑。所以目前来看,整体的GC效率还有很大提升空间。
ZGC使用了读屏障来完成指针的“自愈”,由于ZGC目前没有分代,且ZGC通过扫描所有Region来省去卡表使用,所以ZGC并没有写屏障,这成为ZGC一大性能优势。
多核CPU同时操作内存就会发生争抢,现代CPU把内存控制系统器集成到处理器内核中,每个CPU核心都有属于自己的本地内存。
ZGC和Shenadoah一样,几乎所有运行阶段都和用户线程并发进行。其中同样包含初始标记、重新标记等STW的过程,作用相同,不再赘述。重点介绍以下四个并发阶段:
在这个阶段,ZGC会扫描所有Region,如果哪些Region里面的存活对象需要被分配的新的Region中,就将这些Region放入重分配集中。
ZGC在这个阶段会将重分配集里面的Region中的存货对象复制到一个新的Region中,并为重分配集中每一个Region维护一个转发表,记录旧对象到新对象的映射关系。
如果在这个阶段用户线程并发访问了重分配过程中的对象,并通过指针上的标记发现对象处于重分配集中,就会被读屏障截获,通过转发表的内容转发该访问,并修改该引用的值。
ZGC将这种行为称为自愈(Self-Healing),ZGC的这种设计导致只有在访问到该指针时才会触发一次转发,比Shenandoah的转发指针每次都要转发要好得多。
另一个好处是,如果一个Region中所有对象都复制完毕了,该Region就可以被回收了,只要保留转发表即可。
这个阶段的迫切性不高,所以ZGC将并发重映射合并到在下一次垃圾回收循环中的并发标记阶段中,反正他们都需要遍历所有对象。
现代的垃圾回收器为了低停顿的目标可谓将“并发”二字玩到极致,Shenandoah在G1基础上做了非常多的优化来使回收阶段并行,而ZGC直接采用了染色指针、NUMA等黑科技,目的都是为了让Java开发者可以更多的将精力放在如何使用对象让程序更好的运行,剩下的一切交给GC,我们所做的只需享受现代化GC技术带来的良好体验。