基础概念
定义: Garbage First,垃圾优先,主要面向服务端应用的垃圾收集器。
开启命令: -XX: UseG1GC
目标: “停顿时间模型”的收集器:能够支持指定所在一个长度为M毫秒的时间片段内,消耗在垃圾收集上的时间大概率不超过N毫秒这样的目标。
适用场景: 适用于大内存、多CPU的机器。
设计理念
跳出之前要不收集新生代,要不收集老年代的樊笼,G1面向所有的堆内存任何部分来组成回收集进行回收,衡量标准不再是它属于哪个分代,而是哪块区域中存放的垃圾数量最多,回收效益最大。
区域划分
Region区域
定义: 将Java堆划分为多个大小相等的Region,每个Region都可以是新生代、老年代。G1收集器根据角色的不同采用不同的策略去处理
大小: 2 的N次幂,1MB~32MB
配置参数: -XX:G1HeapRegionSize,不指定G1会根据堆大小自行指定
Humongous区域(Region中的一部分)
定义: 专门用来存储大对象(超过Region容量一半的对象即为大对象),超过整个Region区域的会放在多个连续的Humongous区。
回收身份: G1把Humongous当做老年代的一部分
垃圾收集
借助R大的回答,从最高层看,G1的Collector一侧就是两个大部分,并且这两个部分可以相对独立执行。
- 全局并发标记(global concurrent marking)
- 拷贝存活对象(evacuation)
全局并发标记(global concurrent marking)
基于SATB(Snapshot At The Begining)形式的并发标记。
1、初始标记(STW)
G1对根进行标记。扫描根集合,标记所有从根集合可直接到达的对象(CMS的初始标记也是类似)并将它们的字段压入扫描栈(marking stack)中等到后续扫描。G1使用外部的bitmap来记录mark信息,而不是用对象头的mark word里的mark bit。在分代式G1模式中,初始标记阶段借用 yong GC的暂停,因而没有额外的、单独的暂停阶段。
为什么初始标记阶段是借用Yong GC的暂停做的?
从逻辑上将“全局并发标记”和“拷贝存活对象”是相对独立的,但是“全局并发标记”阶段的“初始标记”阶段又和Yong GC要做的事情有重叠—遍历根集合,所以在实现上把他们安排在一起做,Yong GC期间可以顺带做,也可以不做。
2、并发标记
G1在整个堆中查找可以访问的(存活的)对象,递归扫描整个堆里的对象图。每扫描到一个对象就对其进行标记,并压入扫描栈中。重复扫描过程直到扫描栈清空。过程中还会扫描SATB 写屏障(write barrier)所记录下的引用,SATB相关下文会介绍。
3、最终/重新标记(STW)
处理在并发标记阶段剩余未处理的SATB写屏障的记录。同时此阶段也进行弱引用处理(reference proccessing),**这个暂停与CMS的remark有一个本质的区别,这个暂停只需要扫描SATB buffer(将这些旧引用作为根重新扫描一遍,避免漏标),而CMS的remark需要重新扫描mod-union table里的dirty card外加整个根集合,**而此时整个Yong 区不管对象死活都会被当做根集合的一部分,因而CMS remark有可能会非常慢。
4、清理(cleanup)(STW)
清点和重置标记状态,与mark-sweep中的sweep阶段类似,但不是在堆上sweep实际对象,而是在marking bitmap里统计每个Region被标记为活的对象有多少。这个阶段如果发现完全没有活对象的Region,就会将其整体回收到可分配Region列表中(空闲列表)。
拷贝存活对象 (Evacuiation)(STW)
也叫筛选回收/清理(STW),负责把一部分Region里的活对象拷贝到空Region里去,然后回收原本的Region的空间。
此阶段可以自由选择任意多个Region来独立收集构成收集集合(Collection Set,CSet),靠每个Region的RSet实现。这是Regional garbage collector的特点。
确定完CSet后肯定就要复制了,其实就和ParallelScavenge的Young GC算法类似,采用并行复制算法把CSet里每个Region里的活对象拷贝到新的Region里,整个过程完全暂停。
“Garbage-First Garbage Collection”论文中讲到,CSet的选定完全靠统计模型找出收益最高、开销不超过用户指定的上限的若干个Region,由于每个Region都有RSet覆盖,要单独evacuate任意一个或者多个Region都没问题。
分代式G1模式下有两种选定CSet的子模式:
- Yong GC:选定所有Yong区里的Region。通过控制Yong区的Region个数来控制GC的开销。
- Mixex GC:选定所有Yong Gen里的Region,外加根据global concurrent marking统计得出收益最高的若干old区的Region。在用户指定的开销目标范围内尽可能选择收益高的old区Region
可以看到Yong区Region总是在CSet内,因此分代式G1不维护从Yong区Region出发的引用设计的RSet更新。
工作流程总结
分代式G1(只有分代式G1,其它的目前还没有)的正常工作流程就是在YongGC与Mixed GC之间视情况进行切换,背后定期做全局并发标记。初始化标记默认搭在YongGC上执行;
当全局并发标记正在工作时,G1不会选择做Mixed GC,反之MixedGC正在进行中G1也不会启动初始化标记。
在正常的工作流程中没有Full GC的概念,Old区的收集完全靠MixedGC来完成。
问题
如何保证收集线程与用户线程互不干扰的运行?
在算法实现细节中说过了“三色标记”算法,这个算法阐明了对象在垃圾收集过程中所有的状态,白、黑、灰。垃圾收集过程中,对象的状态可能会出现黑色对象引用了白色对象或者灰色对象与白色对象之间的引用断开了(当两种条件同时满足时就会出现漏标/错标的情况),其实也就是垃圾收集过程中原有的对象结构被打破了,解决这种情况的方案有两种:增量更新、原始快照,G1 GC使用的是原始快照(SATB),CMS使用的是增量更新(incremental update)
SATB( snapshot At The Begining)
SATB是维持并发GC正确性的一个手段,抽象的说就是
- 在一次GC开始的时候活的对象就被认为是活的,此时的对象图形成一个逻辑“快照”;
- 在GC过程中新分配的对象都当做是活的,其他不可到达的对象就是死的。
G1如何知道哪些对象是GC开始后新分配的呢?
每个Region记录着两个TAMS(Top At Mark Start)指针,分别为prevTAMS和nextTAMS,G1在并发标记期间会让新分配的对象在TAMS上分配。在TAMS以上的对象就是新分配的,因而被视为隐式标记。
G1的并发标记用了两个bitmap:
- prevBitmap记录第n-1轮并发标记所得的对象存活状态,由于第n-1轮并发标记已经完成,这个bitmap的信息可以直接使用
- nextBitmap记录第n轮concurrent marking的结果,这个bitmap是当前将要或者正在进行并发标记的结果,还不能使用
对应的每个Region都有这么几个指针:
top是该Region的当前分配指针,[bottom,top)是当前Region已用的部分,[top,end)是尚未使用的可分配空间。
- [bottom,preTAMS)这里的对象存活信息可以通过prevBitmap来得知
- [prevTAMS,nextTAMS)这部分里的对象在第n-1轮并发标记中隐式存活的
- [nextTAMS,top)这部分的对象是在第n轮并发标记中隐式存活的
G1如何处理在并发标记阶段用户线程对对象引用的修改呢?
SATB write barrier,是对“对引用类型字段赋值”这个动作的环切,也就是说赋值前后都在barrier覆盖的范畴内。在赋值前的部分叫做pre-write barrier,在赋值后的叫作post-write barrier。在JVM记忆集文章中我们也讲过,在G1 GC之前其他的垃圾收集器都只是使用了post-write barrier。
SATB要维持“在GC开始时活的对象”的状态这个逻辑snapshot。除了从root出发把整个对象图mark下来之外,其实只需要用pre-write barrier把每次引用关系变化时旧的引用值记下来就好了。这样等并发标记到某个对象时,这个对象的所有引用类型字段的变化全都有记录,就不会漏掉任何在snapshot里活着的对象。当然,很有可能有对象在snapshot中是活的,但是随着并发GC的进行,它已经死了但SATB还是会让它活过这次GC,这时候就会产生floal garbage.
因此在G1 GC中,整个write barrier oop_field_store是这样的:
void oop_field_store(oop* field, oop new_value) { pre_write_barrier(field); // pre-write barrier: for maintaining SATB invariant *field = new_value; // the actual store post_write_barrier(field, new_value); // post-write barrier: for tracking cross-region reference }
pre-write barrier的过程如下:
void pre_write_barrier(oop* field) { oop old_value = *field; if (old_value != null) { if ($gc_phase == GC_CONCURRENT_MARK) { // SATB invariant only maintained during concurrent marking $current_thread->satb_mark_queue->enqueue(old_value); } } }
enqueue动作的实际代码是在G1SATBCardTableModRefBS::enqueue(oop pre_val),它判断当前是否在并发标记阶段用的是JavaThread::satb_mark_queue_set().is_active() ,SATBMarkQueueSet只有在并发标记阶段才会被置为active。
CMS的增量更新设计使得它在Remark阶段必须重新扫描所有线程栈和整个Yong区作为Root;而G1的SATB设计在Remark阶段则只需要扫描剩下的satb_mark_queue
为何在pre-write barrier中只是把旧的引用放入了SATBMarkQueue,为何没有压入标记栈中?
为了减少write barrier对用户线程性能的影响,G1将一部分原本要在barrier里做的事情挪到了别的线程中并发执行,实现这种分离的方式就是通过logging形式的 write barrier实现的,用户线程只在barrier里把要做的事情信息记录到一个队列中,由另外的线程从队列中取出信息批量完成剩余的动作。
以SATB write barrier为例,每个Java线程由一个独立的、定长的SATBMarkQueue,用户线程在barrier里只把old_value压入该队列中。一个队列满了之后,这个队列就会被加到全局的SATB队列集合SATBMarkQueueSet里等待处理,然后给对应的Java线程换一个新的、干净的队列继续执行下去。
并发标记过程中会定期检查全局SATB队列集合的大小。当SATBMarkQueueSet****中队列数量超过一定阈值后,并发标记线程就会处理集合里所有的队列,****把队列里记录的每个对象都标记上,并将其引用字段压到标记栈上等后边做进一步标记。
跨Region引用如何解决?
JVM使用记忆集(Remember Set)来避免全堆最为GC Roots扫描,关于记忆集的内容前边的文章中已经讲过,如果你已经忘记了可以返回去看看,JVM记忆集相关。
Remember Set
G1的堆与其它GC一样有一个覆盖整个堆的cart table,从逻辑上说,G1的RSet是应该每个Region都有一份,这个RSet记录的是从别的Region指向该Region的card。所以说这是一种“ponits-into”的RSet。
用card table实现的RSet通常是points-out的,也是就是说card table要记录的是从它覆盖的范围出发指向别的范围的指针,以分代式GC的card table为例,要记录old区指向yong区的跨代指针,被标记的card是old区范围内的。
G1 GC在ponits-out的card table之上再加了一层结构来构成points-into RSet:每个Region会记录下到底哪些别的Region有指向自己的指针,而这些指针分别在哪些card的范围内。这个RSet其实就是一个Hash Table,key是别的Region的起始地址,value是一个集合,里面的元素是card table的index,card table又对应一个card page。下图形象的秒数了points-into RSet的关系,原文地址为Tips for Tuning the Garbage First Garbage Collector
举例:
如果Region A的RSet中有一个key是Region B,value里有index为1234的card,那么它的意思就是RegionB的一个card里有引用指向Region A。所以对A来说,该RSet记录的是points-into的关系,而card table仍然记录了points-out的关系(不要太纠结points-into和points-out)。
缺点
G1的Region区域过多会导致G1收集器比其他收集器占有的内存多,据经验,G1收集器要耗费大约相当于Java堆内存10%到20%的额外内存来维持工作。
RSet是如何被更新的
前边也已经讲过了,通过write-barrier来实现,G1通过post-write barrier来更新RSet。
理论上post-write barrier的逻辑
- 首先会先获取指向该字段卡卡页
- 判断卡页是否已经被标记为脏页,如果已经为脏页,则不处理
- 将当前卡页变“脏”,以防多个字段同属于一个卡页重复执行此逻辑
- 判断是否为Yong 区,如果为Yong区则不处理,前边已经讲过了分代式G1下,Yong GC和Mixed GC都会的处理Yong GC,因此过滤到从Yong区出发的引用涉及的RSet的维护(既然GC都会扫描,干嘛还浪费时间区更新呢)
- 找到卡页所属的Region
- 找到堆中引用Region的Region
- 更新cart table及Region的RSet
实际上post-write barrier也利用了前边讲SATB中的logging barrier,与SATBMarkQueue类似,每个Java线程由一个DirtyCardQueue,有一个全局的DirtyCardQueueSet,更新RSet的动作交由多个ConcurrentG1RefineThread并发完成,当全局集合中的队列个数超过指定阈值后,ConcurrentG1RefineThread就会取出若干个队列,遍历每个队列记录的card并将card加到对应的Region的RSet里去。
如何建立起可靠的停顿预测模型?
可以通过-XX:MaxGCPauseMillis参数指定预期的停顿时间,G1 GC的停顿预测模型是以衰减均值(Decaying Average)为理论基础来实现的,在垃圾收集过程中,G1收集器会记录每个Region的回收耗时、每个Region记忆集里的脏卡数量等各个可测量的步骤花费的成本,并分析得出平均值、标准偏差、置信度等统计信息。“衰减平均值”是指它会比普通的平均值更容易受到新数据的影响,平均值代表整体平均状态,但衰减平均值更准确地代表“最近的”平均状态。也就是说,Region的统计状态越新越能决定其回收的价值。然后通过这些信息预测现在开始回收的话,由哪些Region组成回收集才可以在不超过期望停顿时间的约束下获得最高的收益。
优点
1、G1收集器并非纯粹地追求低延迟,官方给它设定的目标是在延迟可控的情况下获得尽可能高的吞吐量。设计理念与之前的垃圾收集器也不相同,由以前追求一次把整个Java堆全部清理干净到追求能够应付应用的内存分配速率。
2、可指定期望停顿时间
3、不会产生空间碎片,在程序为大对象分配内存时不容易出现找不到连续内存而提前触发下一次GC或者Full GC
4、处理跨代引用时使用原始快照,避免了在重标记阶段像CMS一样扫描所有的“脏”页,只需要扫描SATB buffer中的引用即可。
缺点
- 如果Mixed GC无法跟上程度分配内存的速度,导致Old区域填满无法分配内存时,就会切换到G1之外的Serial Old GC来收集整个Java堆(包括Yong、OId、Permgen),也就是Full GC,这种状态的G1就和-XX: UseSerialGC的Full GC一样(背后的核心代码是两者公用的)
G1 GC的System.gc()默认是Full GC,也就是Serial Old GC,只有加上-XX: ExplicitGCInvokesConcurrent时才会用自身的并发GC来执行System.gc(),此时System.gc()的作用是强行启动一次global concurrent marking;一般情况下暂停中只会做初始标记然后就返回了,接下来的并发标记还是照常并发执行。
注意事项
不要把 -XX:MaxGCPauseMillis设置的太低,设置的太低会导致G1垃圾收集跟不上对象的分配,可能会导致垃圾堆积,最后引发Full GC。
作者:零壹玖
链接:https://juejin.cn/post/7025212933428740110
来源:稀土掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。
版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 举报,一经查实,本站将立刻删除。