G1 垃圾回收器学习
写在前面
在近期的项目中,遇到过oom的问题。就去服务器查看原因,发现linux环境下的大多数jDK都是openJDK11的版本,而其默认的垃圾回收器是G1,和之前熟悉的CMS等早期版本垃圾回收器还是有很多不同,所以才有了这篇博客。
常见术语
-
并行(parallelism) ,指两个或者多个事件在同一时刻发生,也可以理解为必须是拥有多核处理器的计算机上才会发生并行。
-
并发(concurrency), 指两个或者多个事件在同一时间间隔内发生,也可以理解为一个处理器的计算机分配时间片给两个或者多个事件的过程。
注意上面说的并行和并发是从处理器的角度来理解的,但是在JVM中我们也会看到并行和并发。比如 经典的ParNew 并行年轻代收集器,CMS一般称为并发标记清除(concurrent Mark Sweep) 。 这两个概念和上面说的并发和并行看起来不是那么一致,是因为JVM重新定义了并发和并行。
-
JVM中的并行, 指多个垃圾回收线程在操作系统之上并发运行,这里强调只有垃圾回收线程工作,而java应用线程都暂停执行,所以ParNew工作的时候一定发生了STW。
-
JVM中的并发,指垃圾回收相关的线程并发运行(如果启动了多个线程),同时这些线程还会和java应用线程并发运行。
-
Stop-the-world(STW), 对应java 应用来说,主线程和其它java应用线程就是整个世界,在垃圾回收时stw就是意味着停止这些线程,所以整个世界就停止了。
-
安全点(Safepoint), 指JVM在执行一些操作的时需要STW,但并不是任何线程在任何地方都能进入STW,例如我们正在执行一段代码时,线程如何能够停止?设计安全点的目的是,当线程进入到安全点时,线程就会主动停止。(这里说一个小tips, 需要stw的时候,jvm会设置将一个公共页的字段标记为需要stw, 线程每次到达安全点,都会检查该字段,如果字段 = true 那么它就暂停)
-
Mutator 它指我们的java 应用线程。
-
Rs/记忆集(Remember set),。 主要是记录跨代的引用,比如老年代对象引用的年轻代对象或者年轻代引用老年代的对象。为了减少老年代或者年轻代全区域遍历。
-
Evacuation, 转移、撤退和回收。 G1中表示在垃圾回收的Region中发现存活的对象,将他复制到新地址的过程。
-
回收(Reclaim), 通常指的是分区对象已经死亡或者已经完成Evac,分区可以被JVM再次使用。
-
GC Root , 垃圾回收的根。由于jvm采用可达性分析的遍历方式,需要从GC Root出发来标记活跃对象。
-
根集合(Root Set). 在JVM的垃圾回收过程中,需要从不同的GC Root出发,这些GC Root有线程栈、monitor列表(用于同步的监控对象列表)、JNI(本地方法)栈中或者全局对象、通过System Class Loader或者Boot Class Loader加载的class对象,通过自定义类加载器加载的class不一定是GC Root,JVM自身持有的对象,比如系统类加载器等,而这些GC Root就构成了Root Set。
-
Full GC, 整个堆的垃圾回收动作。通常Full GC是串行的,G1的Full GC不仅有串行实现,在JDK10中还有并行实现。
-
再标记(Remark) , 指再并发标记算法后,需要更新并发标记中Mutator变更的引用,这一步需要STW(若果不stw,那么就会可能一直有新的引用产生)。
回收算法概述
垃圾回收(Garbage Collection,GC)指的是程序不用关心对象在内存中的生存周期,创建后只需要使用对象,不用关心何时释放以及如何释放对象,由JVM自动管理内存并释放这些对象所占用的空间。GC的历史非常悠久,从1960年Lisp语言开始就支持GC。垃圾回收针对的是堆空间,目前垃圾回收算法的标记主要有两类:
1、引用计数法:在堆内存中分配对象时,会为对象分配一段额外的空间,这个空间用于维护一个计数器,如果对象增加了一个新的引用,则将增加计数器。如果一个引用关系失效则减少计数器。当一个对象的计数器变为0,则说明该对象已经被废弃,处于不活跃状态,可以被回收。引用计数法需要解决循环依赖的问题,在我们众所周知的Python语言里,垃圾回收就使用了引用计数法。
2、可达性分析法(根引用分析法),基本思路就是将根集合作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,当一个对象没有被任何引用链访问到时,则证明此对象是不活跃的,可以被回收。
这两种算法各有优缺点,具体可以参考其他文献。JVM的垃圾回收采用了可达性分析法。垃圾回收算法也一直不断地演化,主要有以下分类:
□垃圾回收算法对存活对象的处理实现主要分为复制(Copy)、标记清除(Mark-Sweep)和标记压缩(Mark-Compact)。
□在回收方法上又可以分为串行回收、并行回收、并发回收。
□在内存管理上可以分为代管理和非代管理。
我们首先看一下基本的收集算法。
分代管理算法
分代管理就是把内存划分成不同的区域进行管理,其思想来源是:有些对象存活的时间短,有些对象存活的时间长,把存活时间短的对象放在一个区域管理,把存活时间长的对象放在另一个区域管理。那么可以为两个不同的区域选择不同的算法,加快垃圾回收的效率。我们假定内存被划分成2个代:新生代和老生代。把容易死亡的对象放在新生代,通常采用复制算法回收;把预期存活时间较长的对象放在老生代,通常采用标记清除算法。
这里说一下为什么年轻代用复制,上文说是因为存活时间的问题。具体解释一下,如果老年代用标记清除/标记整理,前者会产生大量的空间碎片,而后者需要整理的时间很长,因为会有很多空位。如果老年代用复制算法会发生什么? 首先老年代将会被分成两块,这样就减少了老年代的可用空间,还有就是由于老年代的对象大多数对象不会短期收集,当触发老年代gc时候,就会存在大量的老年代复制到另一块老年代,这样也是影响效率的。
复制算法
复制算法的实现也有很多种,可以使用两个分区,也可以使用多个分区。使用两个分区时内存的利用率只有50%;使用多个分区(如3个分区),则可以提高内存的使用率。我们这里演示把堆空间分为1个新生代(分为3个分区:Eden、Survivor0、Survivor1)、1个老生代的收集过程。
普通对象创建的时候都是放在Eden区,S0和S1分别是两个存活区。第一次垃圾收集前S0和S1都为空,在垃圾收集后,Eden和S0里面的活跃对象(即可以通过根集合到达的对象)都放入了S1区,如图1-1所示。
回收后Mutator继续运行并产生垃圾,在第二次运行前Eden和S1都有活跃对象,在垃圾收集后,Eden和S1里面的活跃对象(即可以通过根节点到达的对象)都被放入到S0区,一直这样循环收集,如图1-2所示。
标记清除
从根集合出发,遍历对象,把活跃对象入栈,并依次处理。处理方式可以是广度优先搜索也可以是深度优先搜索(通常使用深度优先搜索,节约内存)。标记出活跃对象之后,就可以把不活跃对象清除。下面演示一个简单的例子,从根集合出发查找堆空间的活跃对象,如图1-3所示。
这里仅仅演示了如何找到对象,没有进一步介绍找到对象后如何处理。对于标记清除算法其实还需要额外的数据结构(比如一个链表)来记录可用空间,在对象分配的时候从这个链表中寻找能够容纳对象的空间。当然这里还有很多细节都未涉及,比如在分配时如何找到最合适的内存空间,有First Fit、Best Fit和Worst Fit等方法,这里不再赘述。标记清除算法最大的缺点就是使内存碎片化。
标记压缩
标记压缩算法是为了解决标记清除算法中使内存碎片化的问题,除了上述的标记动作之外,还会把活跃对象重新整理从头开始排列,减少内存碎片。
算法总结
垃圾回收的基础算法自提出以来并没有大的变化。表1-1对几种算法的优缺点进行了比较。
JVM垃圾回收器概述
JVM实现的垃圾回收器类型:串行回收,并行回收、并发标记回收(CMS)和垃圾优先回收(Garbage-First G1)
串行回收
串行回收采用单线程进行垃圾回收,再回收过程中Mutator需要stw. 新生代通常采用复制算法,老年代通常采用标记压缩算法。串行回收经典的线程交互图如下图所示:
serial 单线程年轻代收集器,serial old 单线程老年代收集器。
并行回收
并行回收器采用多线程进行垃圾回收,再回收时Mutator需要暂停,新生代通常采用复制算法,老年代采用标记压缩算法。线程交互图如下图所示:
ParNew年轻代并行回收器,它就是serial的多线程版本。
Parallel Scavenge收集器多线程并行年轻代收集器,它与ParNew最大的区别就是它支持GC自适应调节策略,不需要我们手动修改-Xmn、-XX:SurivivorRation 和 -XX:PretenureSizeThreshold ,jvm会根据系统自身情况动态修改,从而来提高系统的吞吐量和降低系统的停顿时间.
XX:MaxGCPauseMillis 控制最大的垃圾收集停顿时间
XX:GCRatio 直接设置吞吐量量的大小
直观上,只要最大的垃圾收集停顿时间越小,吞吐量是越高的,但是GC停顿时间的缩短是以牺牲吞吐量和新生代空间作为代价的。⽐比如原来10秒收集一次,每次停顿100毫秒,现在变成5秒收集一次,每次停顿70毫秒。停顿时间下降的同时,吞吐量也下降了。
Parallel Old并行的老年代收集器。
并发标记回收
并发标记回收这里单指CMS回收器, 它的整个周期大致上可以分为,初始标记、并发标价、重新标记和并发清除四个阶段。其中初始标记阶段和重新标记阶段都需要Mutator stw, 再并发标记和并发清除是mutator和垃圾回收线程是一起工作的。注意的是CMS是老年代垃圾回收器。线程交互图如下图所示:
垃圾优先回收 G1
垃圾优先回收器(Garbage-First,也称为G1)从JDK7 Update 4开始正式提供。G1
致力于在多CPU和大内存服务器上对垃圾回收提供软实时目标和高吞吐量。G1垃圾回收器的设计和前面提到的3种回收器都不一样,前面三种针对堆空间的管理方式上都是连续的,如图1-7
这样的设计存在一定问题,连续的内存将导致垃圾回收线程必须将整个空间都遍历完,导致收集时间过长,停顿时间不可控。还导致了这样在一个时间段内,大部分的垃圾收集操作只针对一部分分区,而不是整个堆或整个(老生)代。相对于CMS来说,它会产生更少的碎片空间,因为它的算法是标记整理。
G1打破了这一设计,将整个堆划分为若干个小分区(2048,1~32m, 根据堆的大小 -Xms),每个小分区有可能是新生代、老年代或者大对象。所有的新生代小分区,组成了整个新生代。老年代同理。 这样的好处是,如果再垃圾回收时发现已经回收掉的老年代空间能够满足我们所需空间就可以不继续回收了,避免了前面提到的问题。 值得注意的是一个分区不一定是一直是一个代,有可能老年代回收掉之后,被当成了年轻代。内存结构图如下图所示:
其中E代表的是Eden,S代表的是Survivor,H代表的是Humongous,剩余的深蓝色代表的是Old(或者Tenured),灰色的代表的是空闲的region。
每一个分配的Region,都可以分成两个部分,已分配的和未被分配的。它们之间的界限被称为top。总体上来说,把一个对象分配到Region内,只需要简单增加top的值。这个做法实际上就是bump-the-pointer。过程如下:
Region可以说是G1回收器一次回收的最小单元。即每一次回收都是回收N个Region。这个N是多少,主要受到G1回收的效率和用户设置的软实时目标有关。每一次的回收,G1会选择可能回收最多垃圾的Region进行回收。与此同时,G1回收器会维护一个空间Region的链表。每次回收之后的Region都会被加入到这个链表中。
每一次都只有一个Region处于被分配的状态中,被称为current region。在多线程的情况下,这会带来并发的问题。G1回收器采用和CMS一样的TLABs的手段。即为每一个线程分配一个Buffer,线程分配内存就在这个Buffer内分配。但是当线程耗尽了自己的Buffer之后,需要申请新的Buffer。这个时候依然会带来并发的问题。G1回收器采用的是CAS(Compate And Swap)操作。
为线程分配Buffer过程如下:
1、获取当前Region的top,并记录
2、准备分配
3、分配前比较期待的top是否和自己记录的相等,相等分配,不想等跳到1 循环
G1新生代的收集方式是并行收集,采用复制算法。与其他JVM垃圾回收器一样,一旦发生一次新生代回收,整个新生代都会被回收,这也就是我们常说的新生代回收(Young GC)。但是G1和其他垃圾回收器不同的地方在于:
□G1会根据预测时间动态改变新生代的大小。
注意:其他垃圾回收新生代的大小也可以动态变化,但这个变化主要是根据内存的使用情况进行的。G1中则是以预测时间为导向,根据内存的使用情况调整新生代分区的数目。
□G1老生代的垃圾回收方式与其他JVM垃圾回收器对老生代处理有着极大的不同。G1老生代的收集不会为了释放老生代的空间对整个老生代做回收。相反,在任意时刻只有一部分老生代分区会被回收,并且,这部分老生代分区将在下一次增量回收时与所有的新生代分区一起被收集。这就是我们所说的混合回收(Mixed GC)。在选择老生代分区的时候,优先考虑垃圾多的分区,这也正是垃圾优先这个名字的由来。后续我们将逐一介绍这些内容。
在G1中还有一个概念就是大对象,指的是待分配的对象大小超过一定的阈值之后,为了减少这种对象在垃圾回收过程的复制时间,直接把对象分配到老生代分区中而不是新生代分区中。
G1中的基本概念
分区
虽然再上面已经说过了一些分区的概念,这里再详细描述一下。
分区(Heap Region,HR)或称堆分区,是G1堆和操作系统交互的最小管理单位。G1的分区类型(HeapRegionType)大致可以分为四类:
□*分区(Free Heap Region,FHR)
□新生代分区(Young Heap Region,YHR)
□大对象分区(Humongous Heap Region,HHR)
□老生代分区(Old Heap Region,OHR)
其中新生代分区又可以分为Eden和Survivor;大对象分区又可以分为:大对象头分区和大对象连续分区。
每一个分区都对应一个分区类型,在代码中常见的is_young、is_old、is_houmongous
等判断分区类型的函数都是基于上述的分区类型实现,关于分区类型代码如下所示:
利用4个bit来表示不同的HR type.
H-Object在global concurrent marking阶段的clean up 和full gc阶段被回收
在分配H-obj之前检查是否超过initiating heap occupancy和the marking threshold,如果超过的话,就启动global concurrent marking, 为的是提早回收,防止evacuation failures 和 full gc.
分区大小设置
在G1中每个分区的大小都是相同的。该如何设置HR的大小?设置HR的大小有哪些考虑?
HR的大小直接影响分配和垃圾回收效率。如果过大,一个HR可以存放多个对象,分配效率高,但是回收的时候花费时间过长;如果太小则导致分配效率低下。为了达到分配效率和清理效率的平衡,HR有一个上限值和下限值,目前上限是32MB,下限是1MB(为了适应更小的内存分配,下限可能会被修改,在目前的版本中HR的大小只能为1MB、2MB、4MB、8MB、16MB和32MB),默认情况下,整个堆空间分为2048个HR(该值可以自动根据最小的堆分区大小计算得出)。HR大小可由以下方式确定:
□可以通过参数G1HeapRegionSize来指定大小,这个参数的默认值为0。
□启发式推断,即在不指定HR大小的时候,由G1启发式地推断HR大小。
HR启发式推断根据堆空间的最大值和最小值以及HR个数进行推断,设置Initial
HeapSize(默认为0)等价于设置Xms,设置MaxHeapSize(默认为96MB)等价于设置
Xmx。堆分区默认大小的计算方式在HeapRegion.cpp中的setup_heap_region_size(),代
码如下所示:
根据上述函数可以得知,首先确定的是region的个数,也就是HeapRegionBounds::target_number()的值,再根据这个值来得到region_size, 再改变这个region_size为2的n次幂,和必须再【1~32】区间
按照默认值计算,G1可以管理的最大内存为2048×32MB = 64GB。假设设置
xms = 32G,xmx = 128G,则每个堆分区的大小为32M,分区个数动态变化范围从1024
到4096个。
G1中大对象不使用新生代空间,直接进入老生代,那么多大的对象能称为大对象?简单来说是region_size的一半。
分区中的一些标记数据结构
G1的concurrent marking用了两个bitmap:
一个prevBitmap记录第n-1轮concurrent marking所得的对象存活状态。由于第n-1轮concurrent marking已经完成,这个bitmap的信息可以直接使用。
一个nextBitmap记录第n轮concurrent marking的结果。这个bitmap是当前将要或正在进行的concurrent marking的结果,尚未完成,所以还不能使用。
对应的,每个region都有这么几个指针:
|<-- (1) -->|<-- (2) -->|<-- (3) -->|<-- (4) -->|
bottom prevTAMS nextTAMS top end
其中top是该region的当前分配指针,[bottom, top)是当前该region已用(used)的部分,[top, end)是尚未使用的可分配空间(unused)。
(1): [bottom, prevTAMS): 这部分里的对象存活信息可以通过prevBitmap来得知
(2): [prevTAMS, nextTAMS): 这部分里的对象在第n-1轮concurrent marking是隐式存活的
(3): [nextTAMS, top): 这部分里的对象在第n轮concurrent marking是隐式存活的
新生代大小
新生代大小指的是新生代内存空间的大小,前面提到G1中新生代大小按分区组织,即首先计算整个新生代的大小,然后根据上一节中的计算方法计算得到分区大小,两者相除得到需要多少个分区。G1中与新生代大小相关的参数设置和其他GC算法类似,G1中还增加了两个参数G1MaxNewSizePercent和G1NewSizePercent用于控制新生代的大小,整体逻辑如下:
- 如果设置新生代最大值(MaxNewSize)和最小值(NewSize),可以根据这些值计算新生代包含的最大的分区和最小的分区;注意Xmn等价于设置了MaxNewSize和NewSize,且NewSize = MaxNewSize。
- 如果既设置了最大值或者最小值,又设置了NewRatio,则忽略NewRatio。
- 如果没有设置新生代最大值和最小值,但是设置了NewRatio,则新生代的最大值和最小值是相同的,都是整个堆空间/(NewRatio + 1)。
- 如果没有设置新生代最大值和最小值,或者只设置了最大值和最小值中的一个,那么G1将根据参数G1MaxNewSizePercent(默认值为60)和G1NewSizePercent(默认值为5)占整个堆空间的比例来计算最大值和最小值。
- 值得注意的是,如果G1推断出最大值和最小值相等,则说明新生代不会动态变化。不会动态变化意味着G1在后续对新生代垃圾回收的时候可能不能满足期望停顿的时间。
这里就说明了,G1能达到用户期望的停顿时间,动态调整新生代大小是一个重要手段。
G1启发式推断新生代大小时,如果发现需要改变新生代大小,它是如何实现的呢?
不同的类型分区(年、老)都会有一个分区列表来维护地址,如果需要扩容新生代大小,那么就把空闲分区的地址加入到维护年轻代的分区列表中即可。 G1有一个线程专门抽样处理预测新生代列表的长度应该多大,并动态调整。
那么G1拓展分区大小的触发条件是什么?它一次拓展的分区是多少呢?
G1通过GC线程消耗的时间与应用的消耗时间比作为触发条件,具体参数为-XX:GCTimeRatio。默认值是9,那么触发阈值_gc_overhead_perc = 100*(1/(1+GCTimeRatio)), 即G1 GC时间与应用时间占比不超过10%时不需要动态扩展,当GC时间超过这个阈值的10%,可以动态扩展。扩展时有一个参数G1ExpandByPercentOfAvailable(默认值是20)来控制一次扩展的比例,即每次都至少从未提交的内存中申请20%,有下限要求(一次申请的内存不能少于1M,最多是当前已分配的一倍),源码如下:
G1停顿预测模型
G1是一个响应时间优先的GC算法,用户可以设定整个GC过程的期望停顿时间,由参数MaxGCPauseMillis控制,默认值200ms。不过它不是硬性条件,只是期望值,G1会努力在这个目标停顿时间内完成垃圾回收的工作,但是它不能保证,即也可能完不成(比如我们设置了太小的停顿时间,新生代太大等)。
那么G1怎么满足用户的期望呢?就需要停顿预测模型了。G1根据这个模型统计计算出来的历史数据来预测本次收集需要选择的堆分区数量(即选择收集哪些内存空间),从而尽量满足用户设定的目标停顿时间。如使用过去10次垃圾回收的时间和回收空间的关系,根据目前垃圾回收的目标停顿时间来预测可以收集多少的内存空间。比如最简单的办法是使用算术平均值建立一个线性关系来预测。如过去10次一共收集了10GB的内存,花费了1s,那么在200ms的停顿时间要求下,最多可以收集2GB的内存空间。G1的预测逻辑是基于衰减平均值和衰减标准差。
这里不做详细解释,因为涉及一堆数学公式,有兴趣的同学可以自己去研究一下。
卡表和位图
卡表(CardTable)在CMS中是最常见的概念之一,G1中不仅保留了这个概念,还引入了RSet。卡表到底是一个什么东西?
GC最早引入卡表的目的是为了对内存的引用关系做标记,从而根据引用关系快速遍历活跃对象。它就是一个数组,数组中每个位置存的是一个byte。举个简单的例子,有两个分区,假设分区大小都为1MB,分别为A和B。如果A中有一个对象objA,B中有一个对象objB,且objA.field = objB,那么这两个分区就有引用关系了,但是如果我们想找到分区A,要如何引用分区B?做法有两种:
□遍历整个分区A,一个字一个字的移动(为什么以字为单位?原因是JVM中对象会对齐,所以不需要按字节移动),然后查看内存里面的值到底是不是指向B,这种方法效率太低,可以优化为一个对象一个对象地移动(这里涉及JVM如何识别对象,以及如何区分指针和立即数),但效率还是太低。
□借助额外的数据结构描述这种引用关系,例如使用类似位图(bitmap)的方法,记录A和B的内存块之间的引用关系,用一个位来描述一个字,假设在32位机器上(一个字为32位),需要32KB(32KB×32 = 1M)的空间来描述一个分区。那么我们就可以在这个对象ObjA所在分区A里面添加一个额外的指针,这个指针指向另外一个分区B的位图,如果我们可以把对象ObjA和指针关系进行映射,那么当访问ObjA的时候,顺便访问这个额外的指针,从这个指针指向的位图就能找到被ObjA引用的分区B对应的内存块。
这里讲一下cms中的卡表机制
再cms垃圾回收器中,将老年代的空间分成大小为512bytes的块,而card table中的每个元素对应着一个块。 那么这种一一对应的关系有什么用呢?它主要体现在并发标记阶段,如果该阶段中应用线程可以改变了老年代对象的引用,这时候为了防止漏标对象,cms采取的是update increase, 它就把这个老年对象区域一一对应的card table中的值改为1,也就是所谓的dirtyCard,这样猜pre_cleaning阶段,垃圾回收线程会遍历这些dirty card的老年代对象,记录可达对象为存活对象,并清除dirty标记.
card table 还有其它作用吗?
有,就是再minor GC时候,如果年轻代对象被老年代引用了。如果不用特殊的数据结构去记录该引用信息,那我们我们只能遍历整个老年代,来查找他们引用的年轻代,而据统计,再所有引用中,老年代引用新生代对象的场景不足1%,所以遍历整个老年代肯定不是一个明智的选择,card table就可以记录老年代是否引用了年轻代,而我们只需要遍历那些标记了遍历了年轻代的老年代就可以了。
那么card table的值是怎么更新的呢?
是通过写屏障来保证的,当字节码解析器或者JIT编译器更新了引用,就会触发写屏障来操作card table.
up曾经看过R大的解释,对象的赋值是不会触发写屏障,也就是 Animal a = null ; a = new Animal();
再G1中,每个Region被分为大小固定的若干张Card。标识堆内存最小可用粒度所有分区的卡片将会记录在全局卡片表(Global Card Table)中,分配的对象会占用物理上连续的若干个卡片,当查找对分区内对象的引用时便可通过记录卡片来查找该引用对象(见RSet)。每次对内存的回收,都是对指定分区的卡片进行处理。可以看下图效果:
在G1除了512字节粒度的卡表之外,还有bitMap,例如使用bitMap可以描述一个分区对另外一个分区的引用情况。在JVM中bitMap使用非常多,例如还可以描述内存的分配情况。
G1在混合收集算法中用到了并发标记。在并发标记的时候使用了bitMap来描述对象的分配情况。例如1MB的分区可以用16KB(16KB×ObjectAlignmentInBytes×8 =
1MB)来描述,即16KB额外的空间。其中ObjectAlignmentInBytes是8字节,指的是对象对齐,第二个8是指一个字节有8位。即每一个位可以描述64位。例如一个对象长度对齐之后为24字节,理论上它占用3个位来描述这个24字节已被使用了,实际上并不需要,在标记的时候只需要标记这3个位中的第一个位,再结合堆分区对象的大小信息就能准确找出。其最主要的目的是为了效率,标记一个位和标记3个位相比能节约不少时间,如果对象很大,则更划算。
记忆集合(Remeber set)
RS(Remember Set)是一种抽象概念,用于记录从非收集部分指向收集部分的指针的集合(points-into)。(就是谁引用了我)。
在G1回收器里面,RS被用来记录从其他Region指向一个Region的指针情况。因此,一个Region就会有一个RS。这种记录可以带来一个极大的好处:在回收一个Region的时候不需要执行全堆扫描,只需要检查它的RS就可以找到外部引用,而这些引用就是initial mark的根之一。也就是说我需要判断这个对象是否是可以收集的对象,我只需要遍历Rs,如果Rs中有对象且该对象是黑色对象,那么我就不应该被回收,因为我被此时的跟对象引用,也就是根可达对象。
Rset只是一种抽的概念,它是通过卡表来实现的。具体结构见下图:
在RS的修改上也会遇到并发的问题。因为一个Region可能有多个线程在并发修改,因此它们也会并发修改RS。为了避免这样一种冲突,G1垃圾回收器进一步把RS划分成了多个哈希表。每一个线程都在各自的哈希表里面修改。最终,从逻辑上来说,RS就是这些哈希表的集合。哈希表是实现RS的一种通常的方式之一。它有一个极大的好处就是能够去除重复。这意味着,RS的大小将和修改的指针数量相当。而在不去重的情况下,RS的数量和写操作的数量相当。
G1的RSet是在Card Table的基础上实现的:每个Region会记录下别的Region有指向自己的指针,并标记这些指针分别在哪些Card的范围内。 这个RSet其实是一个Hash Table,Key是别的Region的起始地址,Value是一个集合,里面的元素是Card Table的Index。
上图中有三个Region,每个Region被分成了多个Card,在不同Region中的Card会相互引用,Region1中的Card中的对象引用了Region2中的Card中的对象,蓝色实线表示的就是points-out的关系,而在Region2的RSet中,记录了Region1的Card,即红色虚线表示的关系,这就是points-into。 而维系RSet中的引用关系靠post-write barrier和Concurrent refinement threads来维护。
Rs存在一个并发修改的问题,G1在mututor工作时利用了write barrire来实现:
Rs利用logging write barrire来实现,那么有人肯定疑惑为什么不在写屏障直接修改Rs为什么要记录到日志里面?
这么做是为了让应用线程尽量多做一些业务事情,而更新Rs的操作统一记录,利用别的线程来统一处理。
eg: 并发阶段,一个对象A的属性a引用了对象B, 那么在操作A.a=B操作之后,write barrier触发,插入一条Rs update 日志, A.res.add(B.region); up只是猜想里面的逻辑,但是目的肯定是这样的。
跟SATB marking queue类似,每个Java线程有一个dirty card queue,也就是论文里说的每个线程的remembered set log;然后有一个全局的DirtyCardQueueSet,也就是论文里说的全局的filled RS buffers。
实际更新RSet的动作就交由多个ConcurrentG1RefineThread并发完成。每当全局队列集合超过一定阈值后,ConcurrentG1RefineThread就会取出若干个队列,遍历每个队列记录的card并将card加到对应的region的RSet里去。
收集集合 (Collect Set)
Collect Set(CSet)是指,在Evacuation阶段,由G1垃圾回收器选择的待回收的Region集合。G1垃圾回收器的软实时的特性就是通过CSet的选择来实现的。对应于算法的两种模式fully-young generational mode和partially-young mode,CSet的选择可以分成两种:
- 在fully-young generational mode下:顾名思义,该模式下CSet将只包含young的Region。G1将调整young的Region的数量来匹配软实时的目标;
- 在partially-young mode下:该模式会选择所有的young region,并且选择一部分的old region。old region的选择将依据在Marking cycle phase中对存活对象的计数。G1选择存活对象最少的Region进行回收。
SATB(snapshot-at-the-beginning)
SATB是G1解决并发操作时防止对象漏标的算法。发生漏标的两个必要充分条件是:
- 黑色对象添加了一个引用,这个引用对象是白色的。
- 灰色对象删除了1中白色对象的引用。
由于黑色对象已经mark,mark线程不会再次mark它。而灰色对象中原本要mark的对象被删除了。所以该对象会一直是白色。也就是需要被回收的对象。但是其实它被黑色对象引用,这样就出现了错误。
那么怎么解决这个问题?
只要打破一个条件就可以,CMS中是打破了第一个条件,即使是黑色对象的属性赋值,它就会把这个引用关系记下来,这个机制叫 incremental update;
而G1就是利用SATB,也就是在修改对象属性之前,我要保存我现在的对象的引用关系。具体实现是通过SATB的write barrier. 这个写屏障是通过pre 方式。逻辑源码如下:
void pre_write_barrier(oop* field) {
if ($gc_phase == GC_CONCURRENT_MARK) { // SATB invariant only maintained during concurrent marking
oop old_value = *field;
if (old_value != null && !is_marked(old_value)) {
mark_object(old_value);
$mark_stack->push(old_value); // scan all of old_value's fields later
}
}
}
这里说一下cms中用的Incremental update 和SATB的区别
两者都是为了防止并发阶段的漏标;
前者打破了上面第一条充分必要条件,它再黑色对象属性赋值之后,通过写屏障把属性关联的白色对象变成灰色(具体实现可能是把该对象标记并压到marking stack上,或者是把该对象记录再类似mod-union table里面)。
后者是打破了上面第二条处分必要条件,把marking开始时的逻辑快照里所有的活对象都看作时活的。具体做法是在write barrier里把所有旧的引用所指向的对象都变成非白的(已经黑灰就不用管,还是白的就变成灰的)。
这样做的实际效果是:如果一个灰对象的字段原本指向一个白对象,但在concurrent marker能扫描到这个字段之前,这个字段被赋上了别的值(例如说null),那么这个字段跟白对象之间的关联就被切断了。SATB write barrier保证在这种切断发生之前就把字段原本引用的对象变灰,从而杜绝了上述第二种情况的发生。
很明显,incremental update write barrier和SATB write barrier都“过于强力”,不丹足以保证所有应该活的对象都被扫描到,还可能把一些可以死掉的对象也给扫描上了。这就是它们的精确度问题,结果就是floating garbage。Yuasa式的SATB write barrier的精度应该是比CMS用的incremental update write barrier低——前者比后者导致的floating garbage更多。
如果把mutator看作一个抽象的对象(里面包含root set),那么mutator也可以用三色抽象来描述:有使用黑色mutator的算法,也有使用灰色mutator的算法。关键在于是否允许mutator在concurrent marking的过程中持有白对象的引用(也就是root set再并发过程中是否会新增白色对象),允许则为灰色mutator,不允许则为黑色mutator。
SATB write barrier是一种黑色mutator做法,而incremental update write barrier是一种灰色mutator做法。
灰色mutator做法要完成marking就需要重新扫描根集合。这就是为什么使用incremental update的CMS在remark的时候要重新扫描整个根集合。
算法详解
算法模式分为两种:
- fully-young generational mode : 看名字就是年轻代的收集,该模式只会收集所有年轻代(也就是 young region),算法通过自动调整young region的数量来达到软实时目标的;触发时机:young region被占满。
- partially-young mode : 也被成为Mixed GC, 该模式会回收所有的young region 和若干个选定的old region, 算法通过调整选多少个、选哪几个old region达到软实时目标;它的触发机制是什么? 当触发了一次Global concurrent marking之后,jvm就知道此时old region里面有多少空间是可以回收的,再每次YGC之后和再次发生Mixed GC之前,会检查old region里面的垃圾占比和参数 G1HeapWastePercent 比较,如果垃圾占比大于等于该值,那么下一次就会触发Mixed GC;
- 如果Mixed GC收集老年代的速度赶不上应用分配老年代对象的速度,那么这时候就会触发单线程的seafull gc。
根据算法流程也可以大致分为两部分:
- Global concurrent marking: 标记阶段,该阶段是不断循环进行的;
- Evacuation phase: 该阶段是负责把一部分region的活对象拷贝到空Region里面去,然后回收原本的Region空间,该阶段是STW的。
这两部分可以独立运行,也就是不一定是同步执行,可以是异步执行的。
Global concurrent marking
名字可以看出是全局并发标记流程,它的执行过程类似CMS,但是不同的是,在G1 GC中,它主要是为Mixed GC提供标记服务的,并不是一次GC过程的一个必须环节。它主要分为下面四个步骤:
- 初始标记(initial mark, STW)。它标记GC root直接可达的对象。这个阶段和young gc 一起进行的,因为它们都需要该操作,也就说明了Global concurrent marking 和young gc 是同时发生的。
- 并发标记(Concurrent Marking). 这个阶段从GC root 开始标记heap中的对象(深度遍历对象),并且此时应用线程也在并发操作,也就是可以修改对象的引用,那么这里就会产生浮动垃圾和漏标的情况,G1采用STAB的写屏障方式来防止漏标。并且该过程在标记的同时收集各个Region的存货对象信息。
- 最终标记(final marking, 在实现中也叫remarking): STW 。在完成并发标记后,每个Java线程还会有一些剩下的SATB write barrier记录的引用尚未处理。这个阶段就负责把剩下的引用处理完。同时这个阶段也进行弱引用处理(reference processing)。
注意这个暂停与CMS的remark有一个本质上的区别,那就是这个暂停只需要扫描SATB buffer,而CMS的remark需要重新扫描mod-union table里的dirty card外加整个根集合,而此时整个young gen(不管对象死活)都会被当作根集合的一部分,因而CMS remark有可能会非常慢。 - 清理(cleanup): 清点和重置标记状态。这个阶段有点像mark-sweep中的sweep阶段,不过不是在堆上sweep实际对象,而是在marking bitmap里统计每个region被标记为活的对象有多少。这个阶段如果发现完全没有活对象的region就会将其整体回收到可分配region列表中。在该阶段还会重置Remember Set。该阶段在计算Region中存活对象的时候,是STW(Stop-the-world)的,而在重置Remember Set的时候,却是可以并行的;
初始标记
初始标记(Initial Mark)负责标记所有能被直接可达的根对象(原生栈对象、全局对象、JNI对象),根是对象图的起点,因此初始标记需要将Mutator线程(Java应用线程)暂停掉,也就是需要一个STW的时间段。事实上,当达到IHOP阈值时,G1并不会立即发起并发标记周期,而是等待下一次年轻代收集,利用年轻代收集的STW时间段,完成初始标记,这种方式称为借道(Piggybacking)。在初始标记暂停中,分区的NTAMS都被设置到分区顶部Top,初始标记是并发执行,直到所有的分区处理完。
注意。 再初始标记结束后,其实还存在一个Root region scanning phase 阶段,在初始标记暂停结束后,年轻代收集也完成的对象复制到Survivor的工作,应用线程开始活跃起来。此时为了保证标记算法的正确性,所有新复制到Survivor分区的对象,都需要被扫描并标记成根,这个过程称为根分区扫描(Root Region Scanning),同时扫描的Suvivor分区也被称为根分区(Root Region)。根分区扫描必须在下一次年轻代垃圾收集启动前完成(并发标记的过程中,可能会被若干次年轻代垃圾收集打断),因为每次GC会产生新的存活对象集合。
这里保证了什么正确性呢? 是因为当我们再并发标记老年代的时候,有可能存在一种情况,就是这个老年代只有被Survivor区的对象引用,而Rs并不会记录年轻代的引用,所以为了防止漏标这个老年代,需要把Survivor区的对象扫描标记成根集合。
并发标记(concurrent marking)
并发:收集线程和应用线程一起运行
在标记阶段,会使用到一个marking stack的东西。G1不断从marking stack中取出引用,递归扫描整个堆里的对象图,并且在bitmap上进行标记。这个递归过程采用的是深度遍历,会不断把对象的域入栈。
在并发标记阶段,因为应用还在运行,所以可能会有引用变更,包括现有引用指向别的对象,或者删除了一个引用,或者创建了一个新的对象等。为了发生漏标的情况,G1采用的是使用SATB的并发标记算法来规避。
SATB是一个逻辑上存在概念,在实际中并没有任何真的实际的数据结构与之对应。叫这个名字,是因为,一旦进入了concurrent marking阶段,那么在该阶段的运行过程中,即便应用修改了引用,SATB的写屏障记录下来了原始的值,在遍历整个堆查找存活对象的时候,使用的依然是原来的值。这就是在逻辑上保持了一个snapshot at the beginning of concurrent marking phase。
相关参数:
-XX:ConcGCThreads(默认GC线程数的1/4,即-XX:ParallelGCThreads/4)控制启动数量,每个线程每次只扫描一个分区,从而标记出存活对象图。同时,并发标记线程还会定期检查和处理STAB全局缓冲区列表的记录,更新对象引用信息。参数-XX:+ClassUnloadingWithConcurrentMark会开启一个优化,如果一个类不可达(不是对象不可达),则在重新标记阶段,这个类就会被直接卸载。
重新标记/最终标记(Remark mark/final mark)
重新标记阶段是最后一个阶段,它是全程stw的。它主要是为了处理并发标记阶段产生的遗漏元素,如每个java应用线程通过SATB写屏障机制产生的SATB日志缓冲区,通过扫描日志找出所有再前面阶段未被访问的存活对象,并安全的完成存活数的计算。 该阶段也是并行执行的,可以通过参数*-XX:ParalleGCThread*设置GC过程中可以引用的GC数量。 注意这个阶段也是处理引用(软引用、弱引用、虚引用和最终引用)的阶段(G1中只有full GC才能处理软引用,也就是普通的minor gc和mixed gc不会处理软引用,所以最好不用)。
标记阶段结束是需要三个条件的:
1、concurrent marking 已经追踪了所有存活的对象;
2、marking stack是空的;
3、所有的log被处理了;
前两个阶段,可以在不stw的情况完成,但是第三个条件必须stw否则回产生一直生成log的情况。
清理(Clean up)
注意该阶段是不我们理解的清理所有不存活的对象,而是对我们的一些标记信息进行清理,方便下一次标记阶段进行。在极端情况下,该阶段结束之后,空闲Region列表将毫无变化,JVM的内存使用情况也没有变化(如果存在空闲分区,也就是这个分区完全没有存活对象,那么在清理阶段直接就释放内存了)。
该阶段是STW的。 Previous/Next mark bit map 标记位图、以及PTAMS/NTAMS,都会在清除阶段交换角色。清除阶段主要执行下面流程:
- 根据Rset和bit mark map 统计分区的存活对象,这里还会根据存活率进行排序,为选择Cset做铺垫。(STW)
- 重置和更新Rset, 因为有些对象已经确定无用了。(STW)
- 识别所有空闲分区,直接将空分区回收掉,无需等到Evacuation阶段。(并发)
经过global concurrent marking,collector就知道哪些Region有存活的对象。并将那些完全可回收的Region(没有存活对象)收集起来加入到可分配Region队列,实现对该部分内存的回收。对于有存活对象的Region,G1会根据统计模型找处收益最高、开销不超过用户指定的上限的若干Region进行对象回收。这些选中被回收的Region组成的集合就叫做collection set 简称Cset!
在MIXGC中的Cset是选定所有young gen里的region,外加根据global concurrent marking统计得出收集收益高的若干old gen region。
在YGC中的Cset是选定所有young gen里的region。通过控制young gen的region个数来控制young GC的开销。
YGC与MIXGC都是采用多线程复制清除,整个过程会STW。 G1的低延迟原理在于其回收的区域变得精确并且范围变小了。
Evacuation phase
该阶段是STW(因为这个阶段主要的任务是移动对象,如果不stw应用线程无法获取正确的对象地址)。主要流程如下:
- Clean up阶段已经为把Region根据一定条件进行排序,所有该阶段会根据统计模型找到收益最高和开销不超过用户指定的时间上线的若干region作为Cset。
- 选定Cset后,采用并行copying算法把Cset里面每个region里面的活对象拷贝到空region里去,然后回收原本的region空间。
选择Cset时存在两种模式,分别对应两种收集模式young GC和mixed GC。 young GC只会在年轻代里面的region,通过控制young gen的region个数来控制 young的开销。 在mixed GC模式中,选定所有young gen里面的region和根据global concuttent marking统计得出收集收益最高的若干old gen region. 在用户指定的开销目标范围内尽可能选择收益高的old gen region。也就是说Cset的选择模式决定了这次gc的模式。
可以看到young gen region总是在CSet内。因此分代式G1不维护从young gen region出发的引用涉及的RSet更新。
时机
evacuation的触发时机在不同的模式下会有一些不同。在不同的模式下都相同的是,只要堆的使用率达到了某个阈值,就必然会触发Evacuation。这是为了确保在Evacuation的时候有足够的空闲Region来容纳存活对象。
在young GC的情况下,G1会选择N个region作为CSet,该CSet首先需要满足软实时的要求,而一旦已经有N个region已经被分配了,那么就会执行一次Evacuation。
G1会尽可能的执行mixed GC。唯一的限制就是mix GC也需要满足软实时的要求。
G1触发Evacuation的原则大概是:
- 如果被分配的young region数量满足young GC的要求,那么就会触发young GC;
- 如果被分配的young region数量不满足young GC,就会进一步考察加上old region的数量,能否满足old GC的要求;
为了理解这一点,可以举例来说,假如回收一个old region的时间是回收一个young region的两倍,也就是young region花费时间T,old region花费2T,在满足软实时目标的情况下,GC只能回收8T的region,那么:
- 假如应用现在只分配k(k<8)块young region,没有分配任何old region。这个时候又分配了一个old region,那么这个时候会立刻触发一次mixed GC,此次GC会选择k块young region和一块old region;
- 因此,在这种假设下,只要有可以回收的old region的时候,总是会先回收old region;
- 在没有任何old region的情况下,才有可能触发young region。
算法工作模式解析
G1的正常工作流程就是在young GC和mix GC之间视情况切换,背后定期做全局并发标记。Initial marking 默认在young GC上执行; 当全局并发标记时,G1不会选择做mixed GC,反之如果在进行mixed GC时候,也不会进行Initial marking。
这里的young GC 和 mixGC之间视情况切换可以这么理解,如果g1进行了一次全局并发标记那么在清理阶段肯定知道哪些Region的存活对象最少,g1肯定会通过回收效益,比如回收一个old region 和 一个young region哪个能满足用户需求,和哪个能够最有利jvm的堆空间利用率来决定。
young GC触发机制
年轻代满了触发
mixed GC 触发机制
- G1HeapWastePercent:在global concurrent marking结束之后,我们可以知道old gen regions中有多少空间要被回收,在每次YGC之后和再次发生Mixed GC之前,会检查垃圾占比是否达到此参数,只有达到了,下次才会发生Mixed GC。 * G1MixedGCLiveThresholdPercent:old generation region中的存活对象的占比,只有在此参数之下,才会被选入CSet。 * G1MixedGCCountTarget:一次global concurrent marking之后,最多执行Mixed GC的次数。 * G1OldCSetRegionThresholdPercent:一次Mixed GC中能被选入CSet的最多old generation region数量。
G1 GC的一些重要参数
参数 | 含义 |
---|---|
-XX:G1HeapRegionSize=n | 设置Region大小,并非最终值 |
-XX:MaxGCPauseMillis | 设置G1收集过程目标时间,默认值200ms,不是硬性条件 |
-XX:G1NewSizePercent | 新生代最小值,默认值5% |
-XX:G1MaxNewSizePercent | 新生代最大值,默认值60% |
-XX:ParallelGCThreads | STW期间,并行GC线程数 |
-XX:ConcGCThreads=n | 并发标记阶段,并行执行的线程数 |
-XX:InitiatingHeapOccupancyPercent | 设置触发标记周期的 Java 堆占用率阈值。默认值是45%。这里的java堆占比指的是non_young_capacity_bytes,包括old+humongous |
serial old GC (full GC)触发机制
- 如果mixed GC的速度跟不上程序分配内存的速度,就会导致old gen填满无法继续进行mixed GC, 就会触发serial old GC 来对整个堆(young old perm)进行回收。这也是真正的full gc。 mixed GC只选择部分old region进行回收,称不上full GC。
- G1 GC的System.gc()默认还是full GC,也就是serial old GC。只有加上 -XX:+ExplicitGCInvokesConcurrent 时G1才会用自身的并发GC来执行System.gc()——此时System.gc()的作用是强行启动一次global concurrent marking;一般情况下暂停中只会做initial marking然后就返回了,接下来的concurrent marking还是照常并发执行。
G1的所有concurrent动作都在global concurrent marking里。Young GC和mixed GC都是完全暂停的。
算法整体流程
mutator | collector | remembered set thread | |
---|---|---|---|
初始标记 | STW | 标记所有root直接引用的对象,同时将这些对象中的引用字段入栈 | null |
并发标记 | 开启write barrier,使每个写操作包括如下步骤write(field, val): 1。old = *field 2。old如果为null则结束3。将old加入当前线程的satb_mark_queue4。*field=value5。检查field与val是否处于同一个region,是则结束6。检查field指向的位置所处的card是否为dirty,是则结束7。将field指向的位置所处的card置dirty,同时将该card加入当前线程的dirty_card_queue8。write结束。全局会维护一个dirty_card集合,当集合到达一定值的时候,会触dirty_card_queue扫描。 | 1。从栈中弹出一个ref,设其引用的对象为obj2。检查obj是否已经marked,是则返回第1步3。mark obj,遍历其所有引用,设为r:3.1。将r压入当前线程的mark_stack3.2。检查r,看其指向对象与obj是否在同一个region中,是则continue3.3。设r指向的对象所在的region为R,为R的remembered set(简称rs)添加一笔表示obj所在region及card的记录4。重复上述过程直到栈为空 | 1。从全局dirty_card_queue中以某种方式取出一个队列中的card。遍历该card中所有obj的所有ref:2.1。如果ref指向对象与obj在同一个region,则continue2.2。设ref指向的对象所处region为R,则为R的rs添加一笔表示obj所在region及card的记录 |
最终标记 | stw | 处理SATB通过写屏障写到日志里面的记录,将这些对象压栈到marking stack,遍历。 | 如果触发了全局Rslog处理和上面一致。 |
清理 | stw | j计算分区的存活率,Previous/Next mark bit map 标记位图、以及PTAMS/NTAMS,都会在清除阶段交换角色。 | 清除rs信息 |
这个是借鉴了LeaflnWind大佬总结的一个表格,表格还不完善,但是对新手能够更好的理解,我把它copy过来。并加入自己的一些理解。
G1 日志分析
Young GC 日志
借鉴美团某线上项目日志
{Heap before GC invocations=12 (full 1):
garbage-first heap total 3145728K, used 336645K [0x0000000700000000, 0x00000007c0000000, 0x00000007c0000000)
//分区大小1024k, 172个年轻分区,13个survivors分区 那么就是159个enden
region size 1024K, 172 young (176128K), 13 survivors (13312K)
//需要注意的是,之所以有committed和reserved,是因为没有设置MetaspaceSize=MaxMetaspaceSize。
Metaspace used 29944K, capacity 30196K, committed 30464K, reserved 1077248K
class space used 3391K, capacity 3480K, committed 3584K, reserved 1048576K
2014-11-14T17:57:23.654+0800: 27.884: [GC pause (G1 Evacuation Pause) (young)
Desired survivor size 11534336 bytes, new threshold 15 (max 15)
- age 1: 5011600 bytes, 5011600 total
//根据目标停顿时间动态选择部分垃圾对并多的Region回收,这一步就是选择Region
//_pending_cards: 表示涉及的卡片数量
27.884: [G1Ergonomics (CSet Construction) start choosing CSet, _pending_cards: 1461, predicted base time: 35.25 ms, remaining time: 64.75 ms, target pause time: 100.00 ms]
//将待收集的region(所有年轻代的region)放到收集集合中,其中eden 159个,survivors13个
27.884: [G1Ergonomics (CSet Construction) add young regions to CSet, eden: 159 regions, survivors: 13 regions, predicted young region time: 44.09 ms]
// 这一步是对上面两步的总结。预计总收集时间79.34ms。
27.884: [G1Ergonomics (CSet Construction) finish choosing CSet, eden: 159 regions, survivors: 13 regions, old: 0 regions, predicted pause time: 79.34 ms, target pause time: 100.00 ms]
, 0.0158389 secs]
// 本次gc 并行的线程数量
[Parallel Time: 8.1 ms, GC Workers: 4]
//线程的启动计时
[GC Worker Start (ms): Min: 27884.5, Avg: 27884.5, Max: 27884.5, Diff: 0.1]
//扫描root耗时
[Ext Root Scanning (ms): Min: 0.4, Avg: 0.8, Max: 1.2, Diff: 0.8, Sum: 3.1]
//接下来就是下面这段GC日志,即更新RSet消耗的时间统计信息,RSet即Remember Sets,它是用来记录引用指向一个Region的跟踪信息的数据结构。我们看到后面还有一段Processed Buffers的日志,Mutator线程会记录对象图的变化,JVM将这些变化的track信息记录在被称为Update Buffers中。这个Update RS的子任务Processed Buffers就是负责处理这个Update Buffers的,从而将所有Region的RSets更新到一致的状态。
[Update RS (ms): Min: 0.0, Avg: 0.3, Max: 0.6, Diff: 0.6, Sum: 1.4]
[Processed Buffers: Min: 0, Avg: 2.8, Max: 5, Diff: 5, Sum: 11]
//接下来就是Scan RS阶段,这个阶段会扫描遍历Remember Sets。一个Region的RSet包含了指向这个Region的引用的Cards,这个阶段就是扫描RSet中这些Cards,从而找出任何有指向CSet中所有Region的引用。通过这一步就能知道,Eden区哪些对象被老年代引用,从而不会在GC时回收掉
[Scan RS (ms): Min: 0.0, Avg: 0.1, Max: 0.1, Diff: 0.1, Sum: 0.3]
// code root指的是经过JIT编译后的代码里,引用了heap中的对象。引用关系保存在RSet中
[Code Root Scanning (ms): Min: 0.0, Avg: 0.1, Max: 0.2, Diff: 0.2, Sum: 0.6]
//再然后就是对象拷贝阶段。这个阶段会将前面扫描到的存活的对象拷贝到目标Region中,可能是Survivor类型Region,也可能是Old类型Region(如果达到晋升条件的话),并记录拷贝过程消耗的时间统计信息
[Object Copy (ms): Min: 4.9, Avg: 5.1, Max: 5.2, Diff: 0.3, Sum: 20.4]
// 线程结束,在结束前,它会检查其他线程是否还有未扫描完的引用,如果有,则”偷”过来,完成后再申请结束,这个时间是线程之前互相同步所花费的时间。
[Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[GC Worker Other (ms): Min: 0.0, Avg: 0.4, Max: 1.3, Diff: 1.3, Sum: 1.4]
[GC Worker Total (ms): Min: 6.4, Avg: 6.8, Max: 7.8, Diff: 1.4, Sum: 27.2]
[GC Worker End (ms): Min: 27891.0, Avg: 27891.3, Max: 27892.3, Diff: 1.3]
//用来将code root修正到正确的evacuate之后的对象位置所花费的时间。因为Object copy可能移动了位置
[Code Root Fixup: 0.5 ms]
//更新code root 引用的耗时,code root中的引用因为对象的evacuation而需要更新
[Code Root Migration: 1.3 ms]
//清除code root的耗时,code root中的引用已经失效,不再指向Region中的对象,所以需要被清除
[Code Root Purge: 0.0 ms]
// 清除card table的耗时,因为有些对象改变位置了,card table需要处理
[Clear CT: 0.2 ms]
[Other: 5.8 ms]
//评估需要收集的区域。YongGC 并不是全部收集,而是根据期望收集,虽然活对象已经拷贝完成,但是也不是未拷贝的非活对象一定被回收,我们需要满足用户设定的回收时间。
[Choose CSet: 0.0 ms]
//处理引用
[Ref Proc: 5.0 ms]
//遍历所有的引用,将不能回收的放入pending列表
[Ref Enq: 0.1 ms]
//在回收过程中被修改的card将会被重置为dirty
[Redirty Cards: 0.0 ms]
//将要释放的分区还回到free列表。
[Free CSet: 0.2 ms]
//新生代清空了,下次扩容到301MB。
[Eden: 159.0M(159.0M)->0.0B(301.0M) Survivors: 13.0M->11.0M Heap: 328.8M(3072.0M)->167.3M(3072.0M)]
Heap after GC invocations=13 (full 1):
garbage-first heap total 3145728K, used 171269K [0x0000000700000000, 0x00000007c0000000, 0x00000007c0000000)
region size 1024K, 11 young (11264K), 11 survivors (11264K)
Metaspace used 29944K, capacity 30196K, committed 30464K, reserved 1077248K
class space used 3391K, capacity 3480K, committed 3584K, reserved 1048576K
}
[Times: user=0.05 sys=0.01, real=0.02 secs]
Global Concurrent Marking 日志
日志来源是本地程序产出的,可能不能够模拟线上环境,但是整体流程不会受影响。
可以看出,整个全局并发标记是从young gc开始的,因为它借助了young gc来触发initial-mark阶段,这个从上面可以得到,日志也验证了这点。
2021-01-13T16:42:41.662+0800: 14.139: [GC pause (G1 Evacuation Pause) (young) (initial-mark), 0.0113350 secs]
[Parallel Time: 9.4 ms, GC Workers: 6]
[GC Worker Start (ms): Min: 14139.3, Avg: 14139.4, Max: 14139.5, Diff: 0.2]
[Ext Root Scanning (ms): Min: 1.1, Avg: 1.3, Max: 1.6, Diff: 0.4, Sum: 8.1]
[Update RS (ms): Min: 3.6, Avg: 4.1, Max: 4.4, Diff: 0.8, Sum: 24.5]
[Processed Buffers: Min: 5, Avg: 8.2, Max: 10, Diff: 5, Sum: 49]
[Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Code Root Scanning (ms): Min: 0.0, Avg: 0.2, Max: 1.4, Diff: 1.4, Sum: 1.5]
[Object Copy (ms): Min: 2.7, Avg: 3.5, Max: 3.9, Diff: 1.2, Sum: 21.1]
[Termination (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
[Termination Attempts: Min: 1, Avg: 50.2, Max: 67, Diff: 66, Sum: 301]
[GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
[GC Worker Total (ms): Min: 9.1, Avg: 9.2, Max: 9.3, Diff: 0.2, Sum: 55.2]
[GC Worker End (ms): Min: 14148.6, Avg: 14148.6, Max: 14148.6, Diff: 0.0]
[Code Root Fixup: 0.1 ms]
[Code Root Purge: 0.0 ms]
[Clear CT: 0.3 ms]
[Other: 1.6 ms]
[Choose CSet: 0.0 ms]
[Ref Proc: 1.2 ms]
[Ref Enq: 0.0 ms]
[Redirty Cards: 0.3 ms]
[Humongous Register: 0.0 ms]
[Humongous Reclaim: 0.0 ms]
[Free CSet: 0.0 ms]
[Eden: 43.0M(43.0M)->0.0B(41.0M) Survivors: 7168.0K->7168.0K Heap: 107.6M(128.0M)->65.6M(128.0M)]
[Times: user=0.00 sys=0.00, real=0.01 secs]
//并发扫描initial-mark 阶段存活者直接可达的对象
2021-01-13T16:42:41.674+0800: 14.151: [GC concurrent-root-region-scan-start]
//并发扫描initial-mark 阶段存活者直接可达的对象结束
2021-01-13T16:42:41.681+0800: 14.158: [GC concurrent-root-region-scan-end, 0.0071622 secs]
//并发标记开始
2021-01-13T16:42:41.681+0800: 14.158: [GC concurrent-mark-start]
//并发标记结束
2021-01-13T16:42:41.733+0800: 14.210: [GC concurrent-mark-end, 0.0523691 secs]
//重新扫描,stw,处理SATB
2021-01-13T16:42:41.733+0800: 14.211: [GC remark 2021-01-13T16:42:41.734+0800: 14.211: [Finalize Marking, 0.0003133 secs] 2021-01-13T16:42:41.734+0800: 14.211: [GC ref-proc, 0.0057313 secs] 2021-01-13T16:42:41.740+0800: 14.217: [Unloading, 0.0099949 secs], 0.0165977 secs]
[Times: user=0.02 sys=0.00, real=0.02 secs]
2021-01-13T16:42:41.751+0800: 14.228: [GC cleanup 73M->73M(128M), 0.0015282 secs]
G1中什么时候Region会被回收
- GC末尾将回收CSet中的所有区域(出现在CSET事件中的区域)。 带有EVAC-FAILURE事件的区域除外。
- 所有触发了clean-up事件 的区域,也就是再clean up阶段中region中不存在任何存活的对象了。
- 完整GC之后,将回收一些区域(将对象从中移出的区域)。 但这并未明确显示,而是使用POST-COMPACTION事件将堆中剩余的非空区域打印出来。