彻底搞懂G1收集器的工作原理

1,281 阅读15分钟

G1 收集器

引言

  • YoungGC和MixedGC会STW吗
  • 全局并发标记和YoungGC、MixedGC有什么关系吗
  • YoungGC会整堆扫描吗?如果不会,G1是如何做到的

带着这些疑问,我们就正式开始说G1了。

如果有不懂的名词或关系,可以参考:GC方法论:关于JVM垃圾回收机制必须掌握的基础知识

G1的特点

G1: Garbage-First

最大的特点就是:停顿时间可控,这也是G1名字的由来

G1一共有两个过程是并发的。逻辑上分代,物理上分区。

G1在正常工作时,只有Young GC和Mixed GC交替,一般不会出现Full GC的,设计理念就是尽量避免Full GC。

G1采取的垃圾收集算法

不管是新生代还是年老代,回收算法都是采用复制算法

但实际上,因为物理分区,所以区内是标记复制算法,整体上来看整个堆,是一种标记整理

G1的逻辑分代

新生代,老年代

Eden,Survivor,Old的代的概念都被保留

默认新生代对堆内存的初始占比是5%,最多60%,且比例仍然是8:1:1

Humongous:存储大对象

除了Eden,Survivor,Old区域,G1还额外多了一个Humongous区域,专门用于存放巨型对象

如果一个对象的大小超过Region容量的50%以上,就认为是大对象

如果一个H区装不下巨型对象,那么G1会寻找连续的H分区来存储,如果寻找不到连续的H区的话,就不得不启动 Full GC 了。

G1提出Humongous区域的意义:

CMS的做法是直接将大对象放入老年代 ,然而大对象未必就一定存活很久,直接放入老年代是不妥的。

G1的物理分区:Region

G1将Java堆划分为多个大小相等的独立的Region区域,默认2048个Region。每个Region都可以属于某个代。

card:堆内存的最小粒度

每个Region内部,又会被分割为若干个卡片,一个卡片的大小为512B。

Region内部结构

一个Region内部有如下五个指针:

三个好理解的:

  • bottom:Region块的起始位置
  • end:Region块的结束位置,end - top就是一个Region块的大小
  • top:bottom ~ top属于Region已经被使用的空间;top ~ end属于空闲空间 top是空闲内存与已使用内存的分界线

TAMS:top-at-mark-start

又分为previous和next,就是前一次和后一次嘛,就是前后两次发生并发标记时的位置

那这俩指针有什么用呢?考虑这样一个问题:并发标记是不是可打断的?

BitMap:维护对象的标记

三色标记法,会将对象标记成三种颜色,如何维护这个颜色呢?

一种做法是,借用对象的mark word的mark bit。

但G1的选择是在外部为每个Region维护两个BitMap:

一个是prevBitmap [bottom, prevTAMS),而另一个是 nextBitmap [bottom, nextTAMS)。这两个bitmap维护了内部两个指针之间对象的存活状况。

为什么需要prev?

因为并发标记是可以被YoungGC打断的,所以还需要个prev区域维护旧的BitMap。

TAMS指针与BitMap如何配合工作

并发标记开始时,next TAMS = top,在并发标记的过程中,所有新对象分配在next TAMS之后,这些对象不会被GC。

对于第n轮并发标记而言:

  • [bottom, prevTAMS): 这部分里的对象存活信息可以通过prevBitmap来得知
  • [prevTAMS, nextTAMS): 这部分里的对象在第n-1轮concurrent marking是隐式存活的
  • [nextTAMS, top): 这部分里的对象在第n轮concurrent marking是隐式存活的

并发标记期间新的对象根本不会被GC,全部认为存活

写前屏障:实现SATB🚩

SATB是必须理解的一个非常重要的概念,并发标记解决漏标问题的核心

不懂SATB,就不懂为什么全局并发标记的step2、3在干嘛,为什么这样实现

G1收集器会通过写前屏障,在引用被更改前先记录一下原本的引用信息。这样G1始终持有并发标记开始之前的对象图。这不就是快照snapshot at the beginning吗。这样就解决了漏标问题。

写前屏障具体行为

许多论文提到的mutator,可以简单理解为用户线程。

mutator的写前栅栏会在引用变更前,将值记录在SATB日缓冲区中。每个线程都会独占一个SATB缓冲区(SATBMarkQueue)。当SATBMarkQueue放满了,会放到全局的SATBMarkQueueSet。

在并发标记阶段,还会定期检查和处理SATBMarkQueueSet的记录,重新标记为灰色压入扫描栈。

并发标记阶段结束剩余的未处理的SATBMarkQueue,SATBMarkQueueSet,在最终标记处理

RSet:G1对记忆集的实现

Remembered Set是记忆集,是个抽象的概念。RSet是G1对Remembered Set的实现,不是一个东西。

G1与HotSpot VM的其它GC一样有一个覆盖整个heap的card table

而G1在points-out的card table之上再加了一层结构来构成points-into RSet。

每个region(分区)都有一个Rset,记录其它属于老年代的 Region 的card对当前Region 的card 的引用情况。也就是记录谁的指针指向我,或者谁引用了我的对象。这和Card Table是刚好相反的。而这些新生代的card可以作为Young GC的GC roots。

即便当前Region属于老年代,也会维护RSet,减压MixedGC

RSet的内部结构

RSet本质上就是一个哈希表结构(HashTable),Key为其他引用当前区内对象的Region起始地址,Value则是一个集合,里面的元素为其他Region中每个引用当前区内对象的地址。

实际上G1中的RSet对内存的开销也并不小,当JVM中分区较多且运行时间较长的情况下,这块的内存开销可能会占用到20%以上

引自:juejin.cn/post/708003…

写后屏障:维护Rset

在对引用做修改的时候,去修改对应的RSet。这个过程与SATB十分相似。另外,G1还有card table需要维护。

mutator把引用记录到dirty card queue,DirtyCardQueueSet,通过ConcurrentG1RefineThread并发维护RSet

比如:

1 listnode  l = null; 
2 listnode p = new listnode(); 
3 l = p;

每个线程有自己的log buffer,写屏障的记录先放进去自己的log buffer中,装满了之后,就会把log buffer放到全局long buffer global set of filled buffer中,而后再申请一个log buffer。

G1只有两件事情是并发的,一是屏障缓存区的并发处理,二是并发标记

CSet:GC区域

CSet虽然是个很简单易懂的概念,但却是很重要的概念

Collect Set用于描述:一次GC的区域,是一个由若干region构成的集合

一次GC后,CSet的所有region资源会被释放,成为闲置的Region,所有存活的对象会被放到别的Region

扫描任何region的时候如果碰到指向不在CSet里的region的引用都可以忽略,即我们只关注从非CSet到Cset的引用,而忽略所有Cset到非CSet的引用。

GC工作线程数量

-XX:ParallelGCThreads参数指定

默认值为:

  • 当CPU核数小于等于8,则默认与CPU核数相等
  • 当CPU核数大于8,经过计算得到,一般小于CPU核数

整体看G1的GC行为

从最高层看,G1的collector一侧其实就是两个大部分:

  • 全局并发标记(global concurrent marking)
  • 拷贝存活对象(evacuation)

全局并发标记和拷贝存活对象可以彼此独立运行

全局并发标记:global concurrent marking

全局并发标记:global concurrent marking,是基于SATB形式的并发标记。

全局并发标记整个过程只有并发标记可能被Young GC打断。

在JDK12前,MixedGC也是不允许被打断的。后来MixedGC能被打断。

1、初始标记:initial marking

STW。

找到GC Roots,把roots引用的对象压入扫描栈(灰色)。

这个过程是等Young GC触发时借道的。

根分区扫描 Root Region Scanning

根分区:Young GC期间,没有死亡的年轻代,晋升到的所有Survivor区域

由于初始标记是借道的,也就是此时Eden为空,年轻代存活的对象全部为存活对象,因此需要将这块区域的所有对象标记为GC Roots。

2、并发标记:concurrent marking

并发标记算法的实现就是三色标记法,所以就是递归扫描直至扫描栈为空

过程中还会扫描SATB write barrier所记录的引用,就是将黑色变成灰色的对象重新压入扫描栈

并发标记的线程数

-XX:ConcGCThreads:默认为GC线程数的1/4,即-XX:ParallelGCThreads/4,一次只扫描一个分区

-XX:ParallelGCThreads:见「GC工作线程数量」

3、最终/重新标记:remark/final mark

STW。

处理剩下的SATB write barrier记录的引用(详见『SATB的实现:写前屏障』)。也进行弱引用处理

CMS 的remark非常慢。CMS的remark需要重新扫描mod-union table里的dirty card外加整个根集合,而此时整个young gen(不管对象死活)都会被当作根集合的一部分

4、清理:clean

STW。

清点和重置标记状态。这个阶段不拷贝任何对象

拷贝存活对象:Evacuation

Evacuation阶段是全暂停的。

1、确定CSet

Evacuation阶段可以「自由选择任意多个region」来独立收集构成收集集合,而这依赖「RSet」实现

被选择的这些Region就叫做CSet

RSet还记得么,是实现Partial GC的核心机制

2、可能的标记行为

前文说过evacuation与global concurrent marking是可以彼此独立工作的。

因此:Evacuation也需要能够扫描标记存活对象

实际上,标记过程不一定会有。

  • 如果有global concurrent marking的结果,那就直接拿来用
  • 如果没有,也无所谓,Evacuation自己也能扫描出来
3、拷贝存活对象

evacuation把一部分region里的活对象拷贝到空region里去,然后回收原本的region的空间。

具体的拷贝行为:

跟ParallelScavenge的young GC的算法类似,采用并行copying(或者叫scavenging)算法把CSet里每个region里的活对象拷贝到新的region里,整个过程完全暂停。是多线程并行拷贝。

能不能并发拷贝(concurrent compaction拷贝行为与用户线程同时进行)

不能,已知的实现concurrent compaction的GC算法都需要read barrier,而G1则坚持只用write barrier不用read barrie。

xxxGC与Evacuation和marking的关系

这里指Young GC和Mixed GC。

R大原话是:

分代式G1模式下有两种选定CSet的子模式,分别对应young GC与mixed GC

更确切的说,Young GC和Mixed GC都是Evacuation的一种,它们的区别仅仅是选定的CSet不同

它们的具体选定CSet的规则是:

  • Young GC:选定所有young gen里的region。通过控制young gen的region个数来控制young GC的开销。
  • Mixed GC:选定所有young gen里的region,外加根据global concurrent marking统计得出收集收益高的若干old gen region。在用户指定的开销目标范围内尽可能选择收益高的old gen region。

你想想,选定CSet属于哪个部分?属于Evacuation行为的第一步。这也就是为什么很多讲G1的作者常说:

G1的Mixed GC与Young GC的行为几乎完全一样

看到这里,你应该知道为什么这么说了吧。还不知道就再看一遍。

Young GC和Mixed GC 都是STW的,对吗

对的。它们是Evacuation的一种,而Evacuation是STW的。

global concurrent marking对mixed gc中的old gen gc有帮助,与young gc没多大关系

全局并发标记与Mixed GC是互斥的(不会同时进行)

Initial marking默认搭在young GC上执行;当全局并发标记正在工作时,G1不会选择做mixed GC,反之如果有mixed GC正在进行中G1也不会启动initial marking。

JDK12推出了可中断的Mixed GC

G1工作周期

这应该能帮助你更好的理解G1的行为模式

一个假想的混合的STW时间线:

1  启动程序 
2  -> young GC 
3  -> young GC 
4  -> young GC 
5  -> young GC + initial marking 初始标记
6  (... concurrent marking ...) 并发标记
7  -> young GC (... concurrent marking ...) 
8  (... concurrent marking ...) 
9  -> young GC (... concurrent marking ...) 
10 -> final marking  最终标记
11 -> cleanup 清理
12 -> mixed GC 
13 -> mixed GC 
14 -> mixed GC 
15 ... 
16 -> mixed GC 
17 -> young GC + initial marking 
18 (... concurrent marking ...) 
19 ...

G1的三种GC

G1的GC主要是YoungGC和MixedGC,G1追求尽量避免Full GC

G1的收集都是根据CSet进行操作的,YoungGC与MixedGC没有明显的不同,最大的区别在于两种收集的触发条件

1、Young/MinorGC

触发时机

YoungGC并不是说现有的Eden区放满了就会马上触发,G1会计算下现在Eden区回收大概要多久时间,如果回收时间远远小于参数 -XX:MaxGCPauseMills 设定的值(默认200ms),那么增加年轻代的region,继续给新对象存放,不会马上做Young GC,直到下一次Eden区放满,G1计算回收时间接近参数 -XX:MaxGCPauseMills 设定的值,那么就会触发Young GC。

即:当Eden区放满时,预计一次Young GC时间接近200ms,才触发Young GC,否则分配一个新Region给Eden

但是,如果YoungGC发现当前正在进行XXX,并不会触发。

GC区域

只对新生代的区域做GC

YoungGC的行为

见Evacuation。

通过控制young gen的region个数来控制young GC的开销。

Young GC和全局并发标记有没有关系

没有什么关系。

从Evacuation的角度看,全局并发标记的结果是可有可无的。

从GC的角度看,Young GC不依赖全局并发标记,而Mixed GC通常需要全局并发标记的结果。

2、MixedGC

触发时机

『老年代/整个堆的百分比』达到 IHOP (-XX:InitiatingHeapOccupancyPercent)阈值,并且发生Young GC

当达到 IHOP 阈值时,G1并不会立即发起并发标记周期,而是等待下一次年轻代收集,利用年轻代收集的STW时间段,完成初始标记,这种方式称为借道。

注意:达到 IHOP阈值触发的是『全局并发标记』,全局并发标记结束后才会Mixed GC

『全局并发标记』后,仍然不会立刻开始Mixed GC。

随后G1并不会马上开始一次混合收集,而是让应用线程先运行一段时间,等待触发一次年轻代收集。在这次STW中,G1将保准整理混合收集周期。接着再次让应用线程运行,当接下来的几次年轻代收集时,将会有老年代分区加入到CSet中,即触发混合收集,这些连续多次的混合收集称为混合收集周期(Mixed Collection Cycle)。

引用自:pdai.tech/md/java/jvm…

所以Mixed GC仍然依托于Young GC,然后修改它的CSet。

因此Mixed GC的真正触发时机是:

  • 经历过『全局并发标记』,有老年代的区域需要GC
  • 第一次触发Young GC,做choose CSet的操作
  • 并且仍有老年代的区域没来得及GC
  • 并且触发后续的Young GC时,通过修改它的CSet使Young GC变成Mixed GC。

GC区域

整个新生代和部分老年代,取决于你期望G1暂停的时间,和GC一个Region的价值

MixedGC的行为

见Evacuation。

选定所有young gen里的region,外加根据global concurrent marking统计得出收集收益高的若干old gen region。

因为每次GC的暂停时间由-XX:MaxGCPauseMills 控制,因此一次MixedGC未必能回收一次global concurrent marking所标记出的CSet。因此G1可能会产生连续多次的混合收集与应用线程交替执行。

这就解释了『G1工作周期』的12~16行出现的多次Mixed GC

G1的担保机制

在GC时先将要回收的Region区中存活的对象拷贝至别的Region区内,拷贝过程中,如果发现没有足够多的空闲Region区承载拷贝对象,此时就会触发一次Full GC

3、Full GC

无法在堆空间中申请新的分区时,G1便会触发担保机制,执行一次STW式的、单线程的 Full GC。Full GC的收集代价非常昂贵,应该避免Full GC的发生。

G1的设计理念是尽量避免Full GC的发生

触发时机

触发full GC的场景:

  • 从年轻代分区拷贝存活对象时,无法找到可用的空闲分区
  • 从老年代分区转移存活对象时,无法找到可用的空闲分区
  • 分配巨型对象时在老年代无法找到足够的连续分区

GC区域

当然是全部,包括元空间,大对象区域等。

FullGC的行为

JDK10之前,G1中的FullGC是采用serial old FullGC

JDK10及之后,采用了并发式的。

G1重要配置参数

-XX:MaxGCPauseMillis:一般的期望的最大STW时间,默认值:200ms

-XX:GCPauseTimeInterval:严格的必须的最大STW时间,默认值:没有,即允许极端情况下G1 STW很久

-XX:ParallelGCThreads:并行工作最大线程数,默认值比较复杂,见『GC线程数量』

-XX:ConcGCThreads:并发工作最大线程数,默认值-XX:ParallelGCThreads除以4

Parallel并行线程数:STW时并行工作的线程数量

Conc并发线程数:并发标记(全局并发标记的第二步)时的工作线程数量

-XX:G1HeapRegionSize:每个Region的大小

-XX:G1(Max)NewSizePercent:初始(最大)新生代占堆内存的占比

-XX:G1HeapWastePercent:回收Region的「值得回收部分的」阈值,CSet的某个Region的可回收的内存占整堆超过该值才可能被GC,默认值为5%

参考文献

pdai.tech.GC - Java 垃圾回收器之G1详解

JVM成神路 - 竹子爱熊猫

R大关于G1的答疑帖