当前位置:网站首页>JVM系列 -- G1与低延迟垃圾收集器

JVM系列 -- G1与低延迟垃圾收集器

2022-01-15 02:19:00 Danny_idea

上一篇文章中我在文中花了较多的部分介绍CMS垃圾收集器,这款收集器的主要特点就是“并发收集+低停顿”。随着JDK7版本的出现,Hotspot JDK研发团队开始渐渐将重心移向到Grarbage First(G1)收集器中,起初设计的目标是希望能够将其替换掉CMS垃圾收集器。

垃圾收集器接口的统一收拢

 JDK在开发的过程中也是有经过很多次代码优化的,在open jdk的这份文章记录中就有提及过关于垃圾收集器的接口统一设计思路:

链接地址:https://openjdk.java.net/jeps/304

图片

在JDK7版本出来的时候,当时的垃圾收集器其实已经和很多JDK中非垃圾收集器相关的代码存在耦合情况,这也是一种不符合指责分离原则的设计,于是后续的JDK维护人员也在渐渐进行这方面的实施。

 

G1收集器

在CMS垃圾收集器成功推广到市场进行广泛使用的时候,JDK研发团队开始着重研究关于G1(Garbage First)垃圾收集器的工作。

设计目的

G1的设计目的是希望能够实现一种可以有效控制垃圾收集的时间停顿范围的垃圾收集器。CMS在进行垃圾收集的时候,由于是对内存块中的整个区域进行回收,例如说整片年轻代内存,整片老年代内存,或者整个堆内存,粒度比较粗,因此对于收集停顿的时间通常需要结合内存块的整个大小进行考虑。

G1收集器在进行内存收集的时候就采用来划分region的设计思路来避免这种情况的产生。

怎么理解region设计

其实很简单,就是将大块的内存划分为来多个小模块,而且每个模块的内存大小都一致相等。

 

G1垃圾收集器和之前的几组垃圾收集器都具有相似的共同点

年轻代,老年代都是独立而且连续的内存块。年轻代依然是划分为了eden区域和survivor区域,并且采用双复制算法来进行内存管理。老年代的内存收集需要对整个老年代区域进行扫描等等。

 

在讲解G1的实现之前,我们需要先熟悉两个概念。

记忆集合

在上一篇文章中我们有提及到关于记忆集合的一些基本概念,主要是为了解决跨代应用的一些设计方面所使用的。当进行并发回收的时候,如果原先的对象引用有发生改变就需要进行相关记录,记录的存储方式是放入一个叫做remember set的记忆集合当中。

卡表

上边提到的记忆集合只不过是一种抽象的数据结构设计,而卡表可以看做是这种抽象数据结构设计的具体实现方式之一。有些类似于HashMap和Map之间的关系。

其实在真实的垃圾回收场景中,除了跨代回收的时候需要考虑使用到卡表这种情况之外,在一些跨内存区域模块进行回收的时候也会使用(例如说G1的垃圾回收当中就会有需要)

当一个对象存在跨代应用的时候,状态可能是如下模式:

例如说一个User对象中存在对Address对象的引用

public class User{
  private int id;
  private int sex;
  private Address address;
}

那么当进行垃圾收集器扫描到的User对象的时候需要进行该区域内存信息的判断:

 

图片

此时会将Address对象放入到记忆集中的Card Table(卡表)里面,在Hotspot的实现里面,Card Table本质是一个数组集合:

CARD_TABLE [this address >> 9] = 0;

在数组的每个索引位上都会存储对应的元素所在的内存区域,这个对应的内存区域我们一般称之为卡页,例如说CARD_TABLE[0] 存储了Address对象所在的内存区域。

图片

在扫描完User内存区域之后,一旦Address对象发生了引用的改变,此时就会将其对应的card_page标识为dirty状态,然后后续会对这类dirty的对象重新进行gc root对象的扫描分析,展开内存回收工作。

卡表设计带来的相关问题

额外的写屏障开销

为了维护卡表内部的引用标识状态,必须要求在每次进行对象赋值操作的时候都加入一层屏障进行拦截,这也就无形之中加大了对计算机性能的消耗。但是这类消耗相比于整个年轻代或者老年代进行回收的stw而言性能消耗要降低了很多。

 

 

伪共享问题

在操作系统底层,计算机对内存数据更新一般是以缓存行作为单位,而缓存行的大小一般和计算机cpu有关,有32位或者64位的大小区别。但是卡页的大小一般为512byte大小,所以有可能会出现极端情况,所有的卡页都存放在同一个缓存行当中,从而减低了计算机的性能。为了解决这种问题,hotspot中提供了这么一个参数:-XX:+UseCondCardMark  该参数用于控制是否需要更新卡表内部的引用。如果卡表已经被标识为了dirty状态,那么后续就不需要再进行更新标记。

 

 

在了解了基本的region概念之后我们再来看看G1的回收细节。

 

G1的具体回收过程

初始化标记

每个Region中对象进行创建的时候,都会将其放入到tams指针所指定的内存区域中,这个过程是和minor gc并行执行的,所以其实开销成本非常小。

并发标记

从根对象开始进行遍历分析,一层层的扫描。找出需要回收的对象之后,还会对之前在stab中有记录的对象进行重新分析。

最终标记

由于g1收集器在针对跨内存区域回收的时候是采用了原始标记的思路进行设计的,所以在这个阶段中需要对stab中记录的对象进行重新分析,整个最终标记的过程需要对工作线程有一个短暂的暂停。

筛选回收

会根据每个region回收的耗时,回收之后的剩余空间进行计算得出回收的效率,最后将其进行汇总排序,按照回收效率从高到低的顺序进行垃圾回收。

这里面的回收细节点:

会单独抽取几个空闲的Region区域,然后将需要回收的Region区中存活的对象拷贝过去,再对旧的内存区域进行清除。这个过程中涉及到较多的对象移动,内存拷贝情况,所以需要暂停用户线程。

 整体的收集流程如下图所示:

图片

 

从这四个阶段可以发现,G1其实在每个阶段都需要对工作线程进行暂停操作,这一步设计其实Jdk的开发者们之前也有想过将其设计为并发执行的模式,但是官方的设计初衷是为了追求高吞吐而非低延时,加上延时时长可以交给开发者来进行设置,所以采用了“暂停”的做法。

 

在g1的四个回收阶段中的并发标记环节里面,stab内部是如何处理对象跨区域引用的呢?

这里其实是继续沿用之前的remember set 记忆集合的设计思路,这里面的设计优点类似于hashmap。

 

key是region的内存地址起始值,value是一个card table的索引号集合,每个卡表都会记录下被引用的对象和含有该引用的对象。(也就是记录下 “谁引用了我,我引用了谁”)

 

g1中的这种记忆集合设计要比传统的cms垃圾收集器中的记忆集设计更为复杂,因此在内存这块的开销也更高。

 

宏观角度分析G1收集器的设计

 

  1. 结合对于底层设计的原理来对G1收集器进行分析,你会发现这款收集器有个很大的特点,就是能够根据开发者的意愿来设置每次垃圾回收的期望停顿时长(注意只是期望,一般这个数值默认是200ms),并且在回收的时候会按照回收的效益来进行排序回收。

  2. 如果说我们将回收的停顿时间压制过短,可能会导致空间不足以容纳新对象产生,所以这个空间的释放和新对象分配之间需要靠开发者自己去把握好一个平衡点,否则程序最终又会频繁出现full gc的问题。

  3. 相比于cms垃圾收集器而言,g1更多地是采用了标记-整理算法,减少内存碎片的产生。

 

 

低延迟收集器Shenandoah

Shenandoah是一款只有OpenJDK才会包含的收集器,最开始由RedHat公司独立发展后来贡献给了OpenJDK,相比G1主要改进点在于:

  1. 支持并发的整理算法,Shenandoah的回收阶段可以和用户线程并发执行;

  2. Shenandoah 目前不使用分代收集,也就是没有年轻代老年代的概念在里面了;

  3. Shenandoah 摒弃了在G1中耗费大量内存和计算资源去维护的记忆集,改用名为“连接矩阵”(Connection Matrix)的全局数据结构来记录跨Region的引用关系,降低了处理跨代指针时的记忆集维护消耗,也降低了伪共享问题的发生概率。

 

Shenandoah收集器的工作过程

Shenandoah收集器的工作过程一共有九个阶段,下图只画了最核心的三个阶段并发标记、并发回收、并发引用更新。

图片

  • 初始标记(Initial Marking):与G1一样,只标记与GC Roots直接关联的对象,这个阶段仍是“Stop The World”的,但停顿时间与堆大小无关,只与GC Roots的数量相关。

  • 并发标记(Concurrent Marking) :与G1一样,从GC Root开始对堆中对象进行可达性分析,找出存活的对象,可与用户线程并发执行,不会造成停顿,时间的长度取决于堆中存活对象的数量和对象图的结构复杂度。

  • 最终标记(Final Marking):与G1一样,处理剩余的SATB扫描,并在这个阶段统计出回收价值最高的Region,将这些Region构成一组回收集(Collection Set),会有一小段短暂的停顿

  • 并发清理(Concurrent Cleanup):这个阶段用于清理那些整个区域内连一个存活对象都没有找到的Region(这类Region被称为Immediate Garbage Region)。

  • 并发回收(Concurrent Evacuation) :首先把回收集里面的存活对象先复制一份到其他未被使用的Region之中,然后通过读屏障Brooks Pointers转发指针技术来解决在垃圾回收期间用户线程继续读写被移动对象的问题,并发回收阶段运行的时间长短取决于回收集的大小。

  • 初始引用更新(Initial Update Reference):并发回收阶段复制对象结束后,还需要把堆中所有指向旧对象的引用修正到复制后的新地址,这个操作称为引用更新。引用更新的初始化阶段实际上并未做什么具体的处理,设立这个阶段只是为了建立一个线程集合点,确保所有并发回收阶段中进行的收集器线程都已完成分配给它们的对象移动任务而已。初始引用更新时间很短,会产生一个非常短暂的停顿

  • 并发引用更新(Concurrent Update Reference) :真正开始进行引用更新操作,这个阶段是与用户线程一起并发的,时间长短取决于内存中涉及的引用数量的多少。并发引用更新与并发标记不同,它不再需要沿着对象图来搜索,只需要按照内存物理地址的顺序,线性地搜索出引用类型,把旧值改为新值即可。

  • 最终引用更新(Final Update Reference):解决了堆中的引用更新后,还要修正存在于GCRoots中的引用。会产生一个非常短暂的停顿,停顿时间只与GC Roots的数量相关。

  • 并发清理(Concurrent Cleanup):经过并发回收和引用更新之后,整个回收集中所有的Region已再无存活对象,所以最后再调用一次并发清理过程来回收这些Region的内存空间,供以后新对象分配使用。

     

连接矩阵

连接矩阵可以简单理解为一张二维表格,如果Region N有对象指向RegionM,就在表格的N行M列中打上一个标记,如图所示,如果Region 5中的对象Baz引用了Region 3的Foo,Foo又引用了Region 1的Bar,那连接矩阵中的5行3列、3行1列就应该被打上标记。在回收时通过这张表格就可以得出哪些Region之间产生了跨代引用。

图片

Shenandoah收集器的连接矩阵示意图

 

Brooks Pointer 转发指针技术

复制对象这件事情如果将用户线程冻结起来再做那是相当简单的,但如果两者必须要同时并发进行的话,就变得复杂起来了。其困难点是在移动对象的同时,用户线程仍然可能不停对被移动的对象进行读写访问,移动对象是一次性的行为,但移动之后整个内存中所有指向该对象的引用都还是旧对象的地址,这是很难一瞬间全部改变过来的。Brooks Pointer 转发指针技术是来实现对象移动与用户程序并发的一种解决方案。

Brooks 在原有对象布局结构的最前面统一增加一个新的引用字段,在正常不处于并发移动的情况下,该引用指向对象自己(类似句柄,一个是放在句柄池中,一个是放在对象头前面),如图:

图片

 

在对象移动的时候我们只需要将Brooks Pointer 指向新对象,在对象访问过程中,只通一条mov指令就可以完成对新对象的访问了,如图:

图片

 

当写操作发生时,Shenandoah收集器是通过CAS(Compare And Swap)操作,来保证收集器线程或者用户线程只有其中之一可以进行修改操作,以此来保证并发时对象访问的正确性。

优缺点

  • 优点:延迟低

  • 缺点:高运行负担使得吞吐量下降;使用大量的读写屏障,尤其是读屏障,增大了系统的性能开销;

 

 

 

低延迟收集器ZGC

 

ZGC(Z Garbage Collector)是一款由Oracle公司研发的,以低延迟为首要目标的一款垃圾收集器。它是基于动态Region内存布局,(暂时)不设年龄分代,使用了读屏障、染色指针和内存多重映射等技术来实现可并发的标记-整理算法的收集器。在JDK 11新加入,还在实验阶段,主要特点是:回收TB级内存(最大4T),停顿时间不超过10ms。

动态Region

ZGC的Region可以具有如图所示的大、中、小三类容量:图片

 

  • 小型Region(Small Region):容量固定为2MB,用于放置小于256KB的小对象。

  • 中型Region(Medium Region):容量固定为32MB,用于放置大于等于256KB但小于4MB的对象。

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

 

HotSpot虚拟机的标记实现方案有如下几种:

  1. 把标记直接记录在对象头上(如Serial收集器);

  2. 把标记记录在与对象相互独立的数据结构上(如G1、Shenandoah使用了一种相当于堆内存的1/64大小的,称为BitMap的结构来记录标记信息);

  3. 直接把标记信息记在引用对象的指针上(如ZGC)

     

染色指针是一种直接将少量额外的信息存储在指针上的技术。目前在Linux下64位的操作系统中高18位是不能用来寻址的,但是剩余的46位却可以支持64T的空间,到目前为止我们几乎还用不到这么多内存。于是ZGC将46位中的高4位取出,用来存储4个标志位,剩余的42位可以支持4T的内存,如图所示:

图片

  • Linux下64位指针的高18位不能用来寻址,所以不能使用;

  • Finalizable:表示是否只能通过finalize()方法才能被访问到,其他途径不行;

  • Remapped:表示是否进入了重分配集(即被移动过);

  • Marked1、Marked0:表示对象的三色标记状态;

  • 最后42用来存对象地址,最大支持4T;

染色指针的三大优势

  1. 一旦某个Region的存活对象被移走之后,这个Region立即就能够被释放和重用掉,而不必等待整个堆中所有指向该Region的引用都被修正后才能清理,这使得理论上只要还有一个空闲Region,ZGC就能完成收集。而Shenandoah需要等到更新阶段结束才能释放回收集中的Region,如果Region里面对象都存活的时候,需要1:1的空间才能完成收集。

  2. 染色指针可以大幅减少在垃圾收集过程中内存屏障的使用数量,ZGC只使用了读屏障。

  3. 染色指针具备强大的扩展性,它可以作为一种可扩展的存储结构用来记录更多与对象标记、重定位过程相关的数据,以便日后进一步提高性能。

 

内存多重映射

ZGC使用了内存多重映射(Multi-Mapping)将多个不同的虚拟内存地址映射到同一个物理内存地址上,这是一种多对一映射,意味着ZGC在虚拟内存中看到的地址空间要比实际的堆内存容量来得更大。把染色指针中的标志位看作是地址的分段符,那只要将这些不同的地址段都映射到同一个物理内存空间,经过多重映射转换后,就可以使用染色指针正常进行寻址了,效果如图:

 

ZGC运作过程

ZGC的运作过程大致可划分为以下四个大的阶段:

并发标记(Concurrent Mark)

与G1、Shenandoah一样,并发标记是遍历对象图做可达性分析的阶段,它的初始标记和最终标记也会出现短暂的停顿,整个标记阶段只会更新染色指针中的Marked 0、Marked 1标志位。

 

并发预备重分配(Concurrent Prepare for Relocate)

这个阶段需要根据特定的查询条件统计得出本次收集过程要清理哪些Region,将这些Region组成重分配集(Relocation Set)。ZGC每次回收都会扫描所有的Region,用范围更大的扫描成本换取省去G1中记忆集的维护成本。

 

并发重分配(Concurrent Relocate)

重分配是ZGC执行过程中的核心阶段,这个过程要把重分配集中的存活对象复制到新的Region上,并为重分配集中的每个Region维护一个转发表(Forward Table),记录从旧对象到新对象的转向关系。ZGC收集器能仅从引用上就明确得知一个对象是否处于重分配集之中,如果用户线程此时并发访问了位于重分配集中的对象,这次访问将会被预置的内存屏障所截获,然后立即根据Region上的转发表记录将访问转发到新复制的对象上,并同时修正更新该引用的值,使其直接指向新对象,ZGC将这种行为称为指针的“自愈”(Self-Healing)能力。

 

ZGC的染色指针因为“自愈”(Self-Healing)能力,所以只有第一次访问旧对象会变慢,而Shenandoah的Brooks转发指针是每次都会变慢。一旦重分配集中某个Region的存活对象都复制完毕后,这个Region就可以立即释放用于新对象的分配,但是转发表还得留着不能释放掉,因为可能还有访问在使用这个转发表。

 

并发重映射(Concurrent Remap)

重映射所做的就是修正整个堆中指向重分配集中旧对象的所有引用,但是ZGC中对象引用存在“自愈”功能,所以这个重映射操作并不是很迫切。ZGC很巧妙地把并发重映射阶段要做的工作,合并到了下一次垃圾收集循环中的并发标记阶段里去完成,反正它们都是要遍历所有对象的,这样合并就节省了一次遍历对象图的开销。

 

ZGC存在的问题

ZGC最大的问题是浮动垃圾。

浮动垃圾

ZGC的停顿时间是在10ms以下,但是ZGC的执行时间还是远远大于这个时间的。假如ZGC全过程需要执行10分钟,在这个期间由于对象分配速率很高,将创建大量的新对象,这些对象很难进入当次GC,所以只能在下次GC的时候进行回收,这些只能等到下次GC才能回收的对象就是浮动垃圾。

ZGC没有分代概念,每次都需要进行全堆扫描,导致一些“朝生夕死”的对象没能及时的被回收。

解决方案

目前唯一的办法是增大堆的容量,使得程序得到更多的喘息时间,但是这个也是一个治标不治本的方案。如果需要从根本上解决这个问题,还是需要引入分代收集,让新生对象都在一个专门的区域中创建,然后专门针对这个区域进行更频繁、更快的收集。

官方测试数据

停顿时间

在ZGC的停顿时间测试上,和其他收集器相比完全不在一个数量级,如图:

图片

吞吐量

ZGC的“弱项”吞吐量方面,以低延迟为首要目标的ZGC已经达到了以高吞吐量为目标Parallel Scavenge的99%,直接超越了G1,如图:

图片

优缺点

优点:低停顿,高吞吐量,ZGC收集过程中额外耗费的内存小

缺点:浮动垃圾

版权声明
本文为[Danny_idea]所创,转载请带上原文链接,感谢
https://blog.csdn.net/Danny_idea/article/details/115268080

随机推荐