JVM中串行回收器、并行回收器、并发回收器(主要指新生代使用串行回收或者ParNew,老生代使用CMS)和G1都是代际回收器。什么是代际回收器?简单地说,将内存划分为新生代和老生代,将容易死亡的对象放在新生代,且以更高的频率对新生代执行垃圾回收,将存活周期长的对象放在老生代中,这样新生代和老生代可以分别回收,并且可以采用不同的垃圾回收算法。作为代际垃圾回收器,都面临着这样的问题:在新生代回收时如何快速的标记新生代内的活跃对象?
JVM使用的是根对象引用的回收算法,即从根集合出发,标记所有存活的对象,然后遍历对象的每一个成员变量继续标记,直到所有的对象标记完毕。在分代垃圾回收中,我们知道新生代和老生代处于不同的回收阶段,如果还是按照这样的标记方法,不合理也没必要。假设我们只回收新生代,如果标记时把老生代中的活跃对象全部标记,但回收时并没有回收老生代,则浪费了时间。同理我们在回收老生代时有同样的问题。当且仅当,我们要进行Full GC才需要对内存做全部标记。所以算法设计者做了这样的设计,使用一个额外的数据结构记录不同代际之间的引用关系,这样在新生代回收的时候只需要从两种对象出发:
从根集合直接指向新生代的对象,遍历这些对象和这些对象的成员变量;
从老生代指向新生代的对象,遍历这些对象和这些对象的成员变量。
什么是引用集(RSet)?
在串行回收器、并行回收器、并发回收器,JVM设计了卡表的存储结构来记录代际引用关系,在G1中使用了RSet来记录代际引用关系。通常有两种方法记录引用关系,第一称为Point Out,第二是Point In。假设我们有这样的一个例子:把对象B(ObjB)赋值给对象A(ObjA)的成员变量,如ObjA.Field = ObjB,对于Point Out来说在对象A(ObjA)的引用关系结构中记录对象B(ObjB)的地址,对于Point In来说在对象B(ObjB)的引用关系结构中记录对象A(ObjA)的地址,这相当于一种反向引用。这两者的区别在于处理时有所不同,Point Out记录简单,但是需要对引用关系结构做全部扫描;Point In记录操作复杂,但是在标记扫描时直接可以找到有用和无用的对象,不需要额外的扫描,因为引用关系结构里面的对象可以认为就是根对象。
串行回收器、并行回收器、并发回收器使用的是Point out的方式,G1中使用的是Point In的方式。
卡表和RSet的区别是什么呢?卡表的结构非常简单,JVM使用1个Byte描述老生代中512Byte的内存是否有对象指向新生代,如果有可以认为将该Byte设置一个标记符(例如为1),如果没有可以将该Byte设置成另一个标记符(例如为0),如果是仅仅区分有引用还是没有引用只需要一个Bit即可,但实际中还需要考虑其他的一些状态(比如在处理过程中是并行标记的区别,处理过程中对象复制失败的状态区别),JVM使用一个Byte来保持状态。
而G1的RSet可以说是卡表的升级版。因为G1采用了Point In的方式,可能存在这样的情况,一个对象赋值给多个对象的成员变量,所以这个对象的RSet需要把这多个对象都进行记录。具体来说RSet使用了三种数据结构:
稀疏表,通过哈希表方式(哈希表底层是使用数组)来存储。
细粒度表,通过数组来存储,每个数组元素指向的引用者分区中512字节内存块对本分区的引用情况。
粗粒度位图,通过位图来指示,每1位表示对应的分区有引用到本分区。
G1中哪些情况需要使用RSet记录
从引用关系的设计来说,G1与串行回收器、并行回收器、并发回收器相比,除了数据结构更为复杂之外,还有一个区别,那就是每次回收的内存并不固定。
在串行回收器、并行回收器、并发回收器中内存被分成两块连续的地址,只需要简单的记录老生代到新生代的引用即可。但对于G1来说,由于内存被划分成多个分区,并且混合回收时除了回收新生代分区之外,还会回收部分老生代分区。因为只回收一部分内存,所以引用关系的记录也稍有不同,哪些情况需要记录?下面分析一下。
虽然RSet是为了记录对象在代际之间的引用,但是并不是所有代际之间的引用都需要记录。我们简单地分析一下哪些情况需要使用RSet进行记录。分区之间的引用关系可以归纳为:
分区内部有引用关系。
新生代分区到新生代分区之间有引用关系。
新生代分区到老生代分区之间有引用关系。
老生代分区到新生代分区之间有引用关系。
老生代分区到老生代分区之间有引用关系。
这里的引用关系指的是分区里面有一个对象存在一个指针指向另一个分区的对象。针对这五种情况,最简单的方式就是在RSet中记录所有的引用关系,但这并不是最优的设计方案。因为使用RSet进行回收实际上有两个重大的缺点:
需要额外内存空间;这一部分通常是G1最大的额外开销,一般有1%~20%的额外开销。
可能导致浮动垃圾;由于根据RSet回收,而RSet里面的对象可能已经死亡,这个时候被引用对象会被认为是活跃对象,实质上它是浮动垃圾。
所以有必要对RSet进行优化,根据垃圾回收的原理,我们来逐一分析哪些引用关系是需要记录在RSet中:
分区内部有引用关系,无论是新生代分区还是老生代分区内部的引用,都无须记录引用关系,因为回收的时候是针对一个分区而言,即这个分区要么被回收要么不回收,如果分区回收则会遍历整个分区,所以无须记录这种额外的引用关系。
新生代分区到新生代分区之间有引用关系,这个无须记录,原因在于G1的YGC/Mixed GC/FGC回收算法都会全量处理新生代分区,所以它们都会被遍历,所以无须记录新生代到新生代之间的引用。
新生代分区到老生代分区之间有引用关系,这个无须记录,对于G1中YGC针对的新生代分区,无须知道这个引用关系,混合回收发生的时候,G1会使用新生代分区作为根,那么遍历新生代分区的时候自然能找到新生代分区到老生代分区的引用,所以也无须这个引用关系,对于FGC来说更无须这个引用关系,所有的分区都会被处理。
老生代分区到新生代分区之间有引用关系,这个需要记录,在YGC的时候有两种根:一个就是栈空间/全局空间变量的引用,另外一个就是老生代分区到新生代分区的引用。
老生代分区到老生代分区之间有引用关系,这个需要记录,在混合回收的时候可能只有部分分区被回收,所以必须记录引用关系,快速找到哪些对象是活的。
这里给出一个表总结上面的关系,如下所示。
引用关系 | 是否需要RSet |
分区内部 | 无须 |
新生代分区到新生代分区 | 无须 |
新生代分区到老生代分区 | 无须 |
老生代分区到新生代分区 | 需要 |
老生代分区到老生代分区 | 需要 |
RSet的处理
我们知道程序在运行过程中,如果发生赋值语句就表示可能需要记录引用关系。所以JVM在对象的赋值语句真正执行之前,插入了一段额外的代码用于记录这种引用关系。
另外,在程序执行的过程中,可能有很多的赋值语句,也就是说需要记录很多引用关系。G1为了性能的考虑,设计了异步的方式来记录引用关系。简单的描述为:在赋值语句执行之前,把引用关系先放入到一个队列中,然后通过一个单独的并发线程,访问这个队列中的对象,把引用关系记录在相应对象的RSet结构中。
所以G1新引入了Refine线程,它实际上是一个线程池,分为两大功能:
用于处理新生代分区的抽样,并且在满足响应时间这个指标的情况下,更新新生代分区的数目。通常有一个单独的线程来处理。
也是最主要的功能:更新RSet。对于RSet的更新并不是同步完成的,G1会把所有的引用关系都先放入到一个queue中称为Dirty Card Queue(DCQ),然后使用Refine线程来消费这个queue完成引用关系的记录。正常来说有G1ConcRefinementThreads个线程处理。
实际上对于RSet的更新G1的还考虑了赋值均衡的情况:
G1设计了Refine线程工作区的概念,在DCQ比较少的情况,启动少的Refine线程更新RSet,在DCQ多的情况下,启动多的Refine线程更新RSet。
当所有的Refine线程都很忙,也就是说DCQ存在挤压的情况,此时JVM会要求应用程序线程暂停业务,帮助Refine线程更新RSet。
在执行垃圾回收的时候也会执行DCQ更新RSet,这是因为Refine会保留一部分工作区不处理,留给垃圾回收的工作线程处理。
G1中引用集的设计和处理是G1高效执行的原因之一。关于G1引用集进一步的介绍,例如RSet的处理、参数调优等可以参考《JVM G1源码分析和性能调优》。
自Java中引入垃圾回收器以来,垃圾回收器的发展从未停止过。JDK 11引入了一款新的垃圾回收器——ZGC。它由Oracle开发,承诺在数TB的堆上具有非常低的暂停时间。
ZGC是2017年Oracle公司贡献给OpenJDK社区的,正式成为OpenJDK的开源项目。ZGC 所针对的是这些在未来普遍存在的大容量内存:TB 级别的堆容量,具有很低的停顿时间(小于 10 毫秒),对整体应用性能的影响也很小(对吞吐量的影响低于 15%)。ZGC 所采用的机制也可以在未来进行扩展,以支持一些令人兴奋的特性,如多层堆(用于热对象的 DRAM 和用于低频访问对象的 NVMe 闪存)或压缩堆。推荐《新一代垃圾回收器ZGC设计与实现》,本书尝试对ZGC的算法实现进行分解,用大量图片展示了ZGC内部的运行原理,逐步揭开垃圾回收器算法的内幕,然后再给出调优方法。
当当、京东“423读书节”大促正在火力进行中
每满100减50
赶紧囤起来吧!
第002期赠书活动中奖名单公布
活动规则
华章图书,专注高端IT出版。感谢大家对华章图书的信任与支持。以上两本畅销好书,你最想要哪本,留言谈谈你最想要那本书的原因。留言点赞最多的前2名,小编会包邮分别送出1本正版书籍。留言截至4月24日17点。下一期赠书小编会公布中奖名单,锦鲤就是你呦!