深入探究JVM之垃圾回收算法实现细节

@

前言

本篇紧接上文,主要讲解垃圾回收算法的实现细节以及对目前最前沿的低延迟GC(Shenandoah、ZGC)做个介绍。

垃圾回收算法实现细节

根节点枚举

我们知道目前的JVM的垃圾回收器都是采用可达性分析算法标记存活对象,该算法首先需要找到GC Roots,然后通过这些根节点向下搜索,能搜索到的就标记为存活对象,未被标记的最后就会被垃圾回收器回收。那你是否想过垃圾回收器怎么找到GC Roots呢?对于在方法区的根节点难道需要将方法区中的类、常量等信息一个不漏的都扫描一遍么?
虚拟机当然不会这么做,否则即使CMS和G1在初始标记这个环节都会停顿较长时间。实际上虚拟机在类加载完成后就会将对象引用维护到一组成为OopMap的数据结构中,在GC进行初始标记这个环节时直接从该数据结构中获取根节点即可。
另外在进行根节点枚举时,这些根节点必然是不能变化的(不可能为每条指令都生成对应的OopMap),即必须冻结在开始扫描之前的某个时间点,这也是为什么初始标记时都会需要STW的原因。

安全点

在上一篇简单提到过安全点的概念,虚拟机开始GC时,不能随时随地立马暂停用户线程,必须跑到合适的位置才能暂停,这个位置就是安全点。那么用户线程应该在何时何点暂停呢?有以下两个原则:

  • 安全点不能太多,太多的话用户线程就会暂停比较频繁,给系统增加负担。
  • 安全点也不能太少,太少的会导致垃圾虚拟机需要等待较长时间才能开始GC标记

从以上两个原则我们可以总结出,安全点的选择应以是否具有让程序长时间执行的特征为标准。什么是长时间执行?这需要从指令角度考量,因为单条指令的执行时间都比较短,不可能以指令流的长度作为标准,只有指令序列的复用才能最明显地体现出程序将要长时间执行,而方法调用(如递归调用)循环跳转(循环次数可能比较多)异常跳转这些指令就属于指令序列复用。
安全点如何确定我们明白了,但是如何让用户线程跑到最近的安全点呢?有两种方案:抢先式中断主动式中断。前者就是系统首先会暂停所有的用户线程,然后挨个检查是否已经在安全点,如果不在就恢复线程让它跑到安全点。而后者则是由线程执行过程中自己去轮询判断是否是安全点,是就暂停,否则继续运行直到跑到安全点。目前虚拟机基本上采用的都是主动式中断

安全区域

在执行过程中的用户线程可以响应系统的中断请求,但是还有些处于SleepBlock等非运行中的线程是无法响应中断请求的,这个就没法用安全点来保证了,因为虚拟机不可能等待线程被系统分配时间片,为此引入了安全区域概念。
在这里插入图片描述
如图所示,安全区域指的是一个范围,当用户线程进入该区域时,首先会标记自己进入了安全区域,当执行完该区域内的代码后,需要判断垃圾收集器的STW阶段已经完成(初始标记、重新标记等),如果已完成,线程继续运行即可,否则则需要等待STW的结束。
通过上文我们很容易理解,安全区域需要该区域内不能有引用关系变化。

记忆集和卡表

记忆集的概念在上一篇的也提到过,它是用来解决跨代引用问题的,维护在垃圾收集区域,其中存储了从非收集区域指向收集区域的指针集合(这部分引用需要作为GC Roots)。该指针的精度如果都是指向具体的跨代引用对象的话,维护成本非常高,另外和扫描整个非收集区域是一样的。=指针的精度有以下几种选择:

  • 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个
    精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
  • 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
  • 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

上面最细粒度的就是字长精度,最粗粒度的是卡精度,目前最常用的实现就是第三种,也被称为卡表。在HotSpot虚拟机中使用的是字节数组来实现的卡表:

CARD_TABLE[this address >> 9] = 0;

上面这段代码的意思是每个数组元素存储的是每个卡页(内存块)的内存起始地址,右移9位即代表除以512,及每个卡表大小为512字节(在HotSpot中是2的9次幂,其它虚拟机中也需要保证是2的N次幂)。为帮助理解,我画了一张对应关系图,图中数字都已转化为十进制数。
在这里插入图片描述
当卡页中只要存在至少一个跨代引用对象,对应卡表中的元素就会被标识为1,标识该卡页变“脏”,在进行可达性分析时,就会将变“脏”的内存页加入GC Roots中一并扫描。
在CMS和G1中都使用了卡表,在使用CMS时,只在新生代中维护了一个卡表(老年代中也有可能存在新生代对其的跨代引用,但新生代的对象大都朝生夕死,所以没有必要),而G1是每个Region都需要维护一个卡表,因此G1比CMS更浪费空间,换言之这也是为什么G1更适合堆空间较大的情况。

写屏障

有了卡表,就能很轻松地解决跨代引用的问题,但是卡表在什么时候去维护呢?考虑到并发问题,肯定需要在跨代引用字段赋值完成的那一刻将对应的内存页变“脏”,即字段赋值和卡表的维护应该保证原子性(多个操作是不可分割的一个操作)。那么要如何实现呢?在HotSpot虚拟机中是使用的写屏障技术实现的,可以理解为对字段赋值的AOP环形通知。既然是环形通知,那么就存在写前屏障写后屏障,在维护卡表这一操作上所有的垃圾回收器都使用的是写后屏障,而G1还使用写前屏障实现原始快照(稍后分析)。
写屏障虽好,但也有其缺陷,一是会增加额外的开销,所以的赋值操作都会增加维护卡表的逻辑;二是在高并发场景下卡表会存在伪共享(现代的处理器是以缓存行为单位存储的,如果存储的两个或多个独立的变量位于同一缓存行,就会彼此影响,导致缓存失效,性能大大降低。)问题。

假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏,即将卡表更新的逻辑变为以下代码所示:
if (CARD_TABLE [this address >> 9] != 0) CARD_TABLE [this address >> 9] = 0;
在JDK 7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。

上面内容引用自《深入理解Java虚拟机》,这里博主仍存在一个疑问:CARD_TABLE[0]~CARD_TABLE[64]处于同一缓存行,任意一个元素的值改变都会导致该缓存行失效,那这里是不是只是解决了同一缓存行所有元素第一次维护完之后的伪共享问题呢?

并发的可达性分析

通过前面的学习我们知道GC停顿最耗时的阶段是在深入遍历对象图的时候,所以CMS和G1都是将该阶段实现为与用户线程并发执行,降低STW的时间,而要降低用户线程的停顿的前提是必须要保证整个可达性分析过程处于一个一致性的快照中。那要如何保证处于一致性快照呢?在非并发垃圾回收器中都是采用让整个回收过程STW实现的,而现在为了降低这个延迟,需要将其中一些过程改为与用户线程并发执行,为此JVM使用了一个三色标记的算法来实现一致性快照三色标记就是将扫描过程中的标记状态分为了三种颜色(以前只有1和0,可以理解为黑色和白色):

  • 黑色:对象和该对象中的所有引用都已经被扫描过,表示存活对象,一开始只有GC Roots是黑色的。
  • 白色:还没有被扫描过的对象,直到整个扫描完成后还是白色的对象就会被回收。
  • 灰色:当前对象已被访问过,但其内至少还有一个引用没被垃圾回收器扫描过的对象就回标记为灰色。黑色和白色不能直接相连,中间必须要有灰色对象。

既然是与用户线程并发执行,那么就必然存在引用变化的问题,所以需要思考怎么正确地标记对象的颜色。这有两种情况,一是多标,将本来应该回收的对象标记为黑色(在扫描过程中有其它线程修改了删除了对黑色对象的引用),这种情况是可以容忍的,只需要在下一次GC时一起回收就可以了;另外还有一个主要要解决的问题——漏标,即本来应该存活的对象没有标记为黑色,导致应存活对象最后被回收,这种情况是非常危险的。
在这里插入图片描述
如图所示,当垃圾回收线程扫描到灰色对象的那一刻,突然有其它的用户线程将指向下面白色对象的引用删除掉,并赋值给已经扫描过的黑色对象,那么最终扫描完成后就会漏标一个或多个(此处只列出的最简单的情况)对象,导致被回收。“对象消失”的问题于1994年在Wilson中被证明需要同时满足下面两个条件才会出现:

  • 赋值器插入了一条或多条从黑色对象到白色对象的新引用;
  • 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。

因此只需要破坏这两个条件中的任意一个,就能解决漏标问题。由此产生了下面两种解决方案:

  • 增量更新(Incremental Update):破坏的是第一个条件,每当黑色的对象插入一个白色对象时,就记录下这个引用,等待并发扫描完成后,再重新扫描一下这些引用,CMS采用的是这种方式。
  • 原始快照(Snapshot At The Beginning,SATB):破坏的是第二个条件,当灰色对象要删除指向白色对象的引用的时候,就会记录下这个引用,在扫描结束后,再逐个扫描这些引用,好比删除时留下了一个快照信息,G1、Shenandoah则是用原始快照来实现。

通过上文就能理解为什么目前并发收集器中都会有一个最终或重新标记的过程,并且这个阶段也是STW的。

低延迟GC

前面所讲的GC在回收阶段都还需要显著的停顿时间,主要问题在于整理阶段还不支持和用户线程并发执行,所以虚拟机的开发者们一直在想方法设法如何让GC的停顿只与根节点数量有关,而不是堆中所有对象的数量,由此产生了几款非常优秀的垃圾回收器,这里主要讨论Shenandoah收集器ZGC,由于它们目前都还不够成熟,实现也非常复杂,所以不会过多的讨论实现细节。

Shenandoah

Shenandoah并非Oracle开发的垃圾收集器,所以受到官方的打压,只能在OpenJDK中使用。相比于G1它有以下区别:

  • 也是采用Region布局
  • Shenandoah目前默认不使用分代回收(以后可能会支持)
  • 整理回收阶段支持与用户线程并发执行
  • 每个Region不再单独维护记忆集,而是维护了一个全局的连接矩阵数据结构。可以看作是一个二维表格,横竖都表示Region的编号,当Region 2引用了Region 3,Region 5引用了Region 1中的对象时,对应表格的2行3列和5行1列就会打上标记。

Shenandoah的运行原理比较复杂,包含了以下9个阶段:

  • 初始标记:标记与GC Roots直接关联的对象,会有极短的STW时间
  • 并发标记:并发的可达性分析
  • 最终标记:处理剩余的SATB记录,并统计出回收价值最高的Region,这个阶段也会有一小段暂停时间。
  • 并发清理:清理整个堆中一个存活对象都没有的Region。
  • 并发回收:这个阶段会把存活对象复制到其它Region。因为与用户线程并发执行,所以需要解决对象引用变动问题,Shenandoah采用的是Brooks Pointers转发指针来解决的(稍后分析)。
  • 初始引用更新:更新回收阶段变动的引用的指针,不过在初始阶段只是设定了一个类似安全点的线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务,这个阶段也会有短暂的STW时间。
  • 并发引用更新:真正执行引用更新操作,时间长短与引用数量有关。
  • 最终引用更新:修正存在于GC Roots中的引用,需要停顿,与GC Roots数量有关。
  • 并发清理:清理掉之前的Region。

了解了Shenandoah的运行原理,再来看转发指针是如何支持并发整理的。转发指针是在对象头中新增了一个引用字段,该字段指向当前对象最新的内存地址,默认情况就是指向自己,一旦对象地址发生改变,即被复制到新的Region中,则需要同时修改头部中的引用指向,注意这两部操作必须保证连续,即中间不能有其它操作,避免并发竞争。
从上面我们可以看到Shenandoah虽然解决了并发清理,但实际运行过程中也有4个需要停顿的地方,另外由于使用转发指针,对于内存地址改变的对象在引用更新完成之前对其访问都会产生额外的开销,所以经测试Shenandoah在总的垃圾回收运行时间上相较以前的垃圾回收器是最长的,但是停顿时间的降低确实有很大的提高。

ZGC

ZGC相较于Shenandoah又是一革命性的垃圾回收器,它的垃圾回收停顿时间只和根节点数量有关,目前任意大小的堆空间回收停顿时间都能控制在10ms内,但是由于它使用染色指针标记对象是否重分配(重分配是ZGC的一种处理动作,用于复制对象的收集器阶段,稍后会介绍到)导致目前ZGC在64位系统最大可管理4TB的堆空间。ZGC同样采用Region布局,不过Region大小分为三种类型:

  • 小型:容量固定为2MB,用于放置小于256KB的小对象。
  • 中型:容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。
  • 大型:容量不固定,可以动态变化,但必须为2MB的整数倍,用于放置4MB或以上的大对象。每个大型Region中只会存放一个大对象,最小容量可低至4MB。大型Region在ZGC的实现中是不会被重分配的,因为复制一个大对象的代价非常高昂。

什么是染色指针?ZGC的标记区别于其它的垃圾回收器,既不是单独维护在记忆集中,也不是维护在对象头中,而是直接标记在引用指针上。受限于硬件和操作系统的限制,目前ZGC只能用于64位系统,而64位系统高18位是不能使用的,剩余的46位中ZGC使用了4位来存储三色标记、是否进入了重分配集(即被移动过)、是否只能通过finalize()方法才能被访问等状态信息,这也是为什么目前ZGC最多只能管理4TB的堆空间(2的42次幂)的原因。
ZGC的运行过程包含了4个阶段:

  • 并发标记:对象图的遍历,也存在初始标记和最终标记两个需要短暂停顿的阶段。
  • 并发预备重分配:扫描所有的Region(不再维护记忆集),统计得出需要清理的Region,将这些Region组成重分配集。
  • 并发重分配:将重分配集中存活对象复制到新的Region中,并且会为重分配集中每个Region维护一个转发表,指向对象的新地址,得益于染色指针的支持,ZGC只需要从引用指针上就能得知对象最新的内存地址。如果用户线程此时访问一个移动了的对象,只有第一次会根据转发表找到新地址,并同时修正引用指向,这称为指针的自愈性。另外由于染色指针的存在,任何一个Region中存活对象复制完毕,该Region就可以直接释放并分配新对象,因此在ZGC中至多只会浪费一个空间(需要一个空的Region完成复制对象的存放)。
  • 并发重映射:重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但这个过程由于染色指针的存在,引用是可以“自愈”的,所以ZGC将这个过程放到下一次GC的并发标记过程中。当所有引用修正后,原先得转发表记录就可以释放掉了。

以上就是ZGC得运行原理,从上面我们可以发现ZGC也是没有分代的,所以它不需要维护记忆集,即少了写屏障带来的运行负担以及没有了记忆集占用大量的内存空间,但同时不分代也带来新的问题,ZGC不适用于高速分配对象的系统中,因为ZGC当对一个较大堆执行一次完整的收集时,会运行较长时间(非停顿时间),这时系统如果创建对象的速度较快,就会产生大量浮动垃圾,堆中可用的空间就会越来越少,目前只能通过增大堆空间来缓解,但终究是治标不治本的方法,这也是为什么ZGC适合较大堆空间的垃圾回收的原因。

转发指针和染色指针比较

转发指针是在对象头中添加一个引用字段,指向最新的地址,因此,在引用更新之前,那么对象的访问都需要根据该字段进行转发访问,虽然这个转发操作已经被优化了很多,但对象的访问是非常频繁的操作,因此累计起来也会对系统性能造成不小的影响(需要注意是每个对象都需要转发,而不仅仅是被移动的,因为不知道哪些对象被移动过);需要注意的是为了支持转发指针,Shenandoah不得不在读、写屏障中都加入转发的逻辑,尤其是读屏障的使用会大大降低系统的性能(Shenandoah是第一款用到读屏障的收集器)。同时转发指针还需要避免并发竞争的问题,即多线程中的写入和查询拿到的必须是同一个对象,不能一个操作旧对象,一个操作新对象,那样数据就不一致了,为此Shenandoah是采用的CAS实现的。
与转发指针不同的是,染色指针是直接标记在引用上的,没有上述的问题,并且它还具有自愈的特性,使得只有第一次转发有额外的性能开销,这也是为什么ZGC比Shenandoah更加优秀的原因,但其最大的问题就是需要操作系统的支持。

总结

垃圾回收算法实现的细节是面试的重点,重要性自然不言而喻,但主要是要理解每个算法要解决的问题以及它的思想,明白我们平时使用的虚拟机在哪些情况下会发生停顿,深刻理解,在进行调优时也才有更好的思路。最后介绍的两款低延迟的垃圾回收器可根据自身情况进行了解,最好是能理解其设计思想,本文也只是简单的介绍了一下,详细细节可翻阅《深入理解JVM虚拟机第三版》。

posted @ 2020-07-27 16:00  夜勿语  阅读(907)  评论(0编辑  收藏  举报