JVM - Garbage-First(G1)

514 阅读18分钟

Garbage-First

G1是针对大内存服务器设计的一款垃圾回收器,以高概率满足垃圾回收暂停时间目标(STW预期时间)且同时实现高吞吐量。

在G1出来之前,垃圾回收器是区分老年代垃圾回收器和新生代垃圾回收器的,而G1是做到了全局的垃圾回收器,不再区分不同代的不同垃圾回收器 所以G1也被Oracle官方称为全功能的垃圾收集器(Fully-FeaturedGarbageCollector)。 image.png

上图列出了7种垃圾回收器,也是比较经典的垃圾回收器,从最早期的Serial串行垃圾回收器到后来的 CMS并发垃圾回收器以及Paraller并行垃圾回收器(并行垃圾回收器 再到后来的 G1笔者现在所描述的全功能垃圾回收器,还有后续的ZGC低延迟垃圾回收器

G1是Java9中默认的垃圾回收器,并发执行的一款针对于大内存的垃圾回收器,G1是在JDK7中引入当时为实验版,一直到后续Java8才转正,也是在Java8中才实现了并发的类型卸载(方法区中)

在Java9中做为默认的垃圾回收器,也是首个抛弃以前传统的内存分代模型采用Region管理模型

里程碑的垃圾回收器

为什么说G1是里程碑的垃圾回收器? 首先之前笔者说过CMS做为一个里程碑的垃圾回收器 是因为 CMS是首个并发回收的垃圾回收器,但是CMS仅作用于老年代的垃圾回收并且采用的标记清除的方式 会有比较多的问题.

G1做为首个区别于之前的分代模型创新了堆内存的规划 其实这一点很像我们的服务中的微服务治理,将一个比较大的服务拆分成多个小服务分别管理,G1就是采用的Region模型治理堆中的内存,将比较大的内存区域切分多个region的内存块,当然只是逻辑切分,使用RSetCSet分别记录引用以及可回收的数据,回收方式也不同于之前的垃圾回收器的分代回收,采用了最大回收收益(最多垃圾区域)回收的方式,同时也是并发回收并且支持可预估停顿时间,以及推出了混合回收 MixedGC的方式处理垃圾数据

本片文章我们主要介绍 Region,RSet,CSet的主要作用还有垃圾回收的工作流程 以及 G1中垃圾回收的阶段

Region

image.png G1在逻辑上保留了分代模型的概念 区别与其他的垃圾回收器(PS+PO、ParNew+CMS、Serial + Serial Old) G1只是逻辑上有分代的概念 不要求每个代之间的内存是连续的,G1将内存分为多个Region,每一个Region的大小也都是可以配置 配置为2^n 最大不能超过32M 最小不能低于1M

堆被分成大约 2000 个区域。最小大小为 1Mb,最大大小为 32Mb。蓝色区域保存老年代对象,绿色区域保存年轻代对象。

G1 Region 分为 E(Eden) S(Surrivor) O(Old) H(humongous)

H代表超大对象 H中对象的大小可以通过配置设置 超过region的50%就会存储在humongous

上图每一个区域快都是一个Region、Region中还有Free空闲区域 也就是图上对应的空区域

每一个Region对应的代的概念都不是固定的,Region中的代是变化的 比如最开始为E代后续可能会变为O代以及S

区域之间的数据可能会存在跨区域的概念,当一个区域存储不下的时候是可能会跨区域存储的

TAMS

大家都知道在并发标记的时候 我们程序的线程是会一起运行的,在运行的时候是肯定会产生新的对象,那么新的对象应该放在哪里,所以就设计出了TAMS来解决这个并发过程中新对象存储问题

TAMS(Top At Mark Start)其实就是为每一个Reion维护了2个指针,将Reion中的一部空间让出来给GC并发过程中新创建的对象存储的区域,新对象必须要这个2个指针之类存放

在CMS中笔者之前也说过存储Concurrent Model Fail导致FullGC,同理在G1中同样也是会如此,如果内存回收的效率没有新产生内存的效率高也是会产生FullGC导致超长的STW出现,G1中的FullGC也是采用的Serial Old串行压缩整理算法,这一点和CMS是一样的

每一个Region都会下面几个指针

|<-- (1) -->|<-- (2) -->|<-- (3) -->|<-- (4) -->|
bottom      prevTAMS    nextTAMS    top         end

其中top是该region的当前分配指针,bottom, top 是当前该region已用的部分,top, end是尚未使用的可分配空间(unused)。

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

TAMS就是 prevTAMS->nextTAMS 之间,在这之间的数据都是被隐试标记过存活的

RememberedSet

RememberedSet简称RSet或者RS,中文就叫做记忆集

RSet是一个Hash存储的表 Key值存储当前对象被引用对象的地址,Value是一个集合,存储对应CardPage的Index 因为G1将内存划分为了一个一个Region,使用RSet 避免全堆做为GCRoots扫描,可以通过扫描根节点后找到引用对应根节点的对象

RSet与CardTable

CardTable笔者之前也有介绍过 就是将内存以512bytes划分长度,记录对应引用的具体地址 用来解决跨代引用问题 详情查看CardTable

CardTable我们可以简单理解为一个模板类 不同的垃圾回收器实现的方式不同,在Hotspot源码中

...
if (UseG1GC) {
   _ct_bs = new G1SATBCardTableLoggingModRefBS(whole_heap,
                                               max_covered_regions);
} else {
 _ct_bs = new CardTableModRefBSForCTRS(whole_heap, max_covered_regions);
}
...

如果使用的是G1垃圾回收器 则具体实现类为G1SATBCardTableLoggingModRefBSG1SATBCardTableLoggingModRefBS中的具体实现类为G1SATBCardTableModRefBS

G1SATBCardTableModRefBS::G1SATBCardTableModRefBS(MemRegion whole_heap,
                                                int max_covered_regions) :
   CardTableModRefBSForCTRS(whole_heap, max_covered_regions)
{
 _kind = G1SATBCT;
}

G1SATBCardTableModRefBS又是继承于CardTableModRefBSForCTRS,主要实现了SATB的逻辑

image.png

上图所示 Region2 被 Region1和Region3 中对象所引用,那么在Region2中的RSet就会记录Region1中引用Region2的值

上面已经介绍过Region,每一个Region都会对应一个RSet 在RSet中会记录 谁引用了当前Region中的对象

RSet是如何记录对象引用的?

 void oop_field_store(oop* field, oop new_value) {
     pre_write_barrier(field); //写前屏障
     invariant *field = new_value; // the actual store 
     post_write_barrier(field, new_value); //写后屏障
 }

在修改一个的引用的时候,会触发oop_field_store,写屏障分两个屏障写前屏障写后屏障,这里可以当成是一个切面(AOP)去理解相当于加了一个环形切面(Around)

笔者之前在介绍CMS的时候有说过,并发回收漏标的问题,也就是在并发标记过程中用户线程修改了对象引用,解决方式有两种 一种增量更新(incremental update write barrier),一种记录修改前引用快照的方式(STAB),STAB就是在pre_write_barrier中实现,在改变对象前记录对象的引用形成并发标记前引用的一个快照

写前屏障

void G1SATBCardTableModRefBS::enqueue(oop pre_val) {
  ....

  //SATBMarkQueueSet只有在concurrent marking时才会被置为active
  if (!JavaThread::satb_mark_queue_set().is_active()) return;
  Thread* thr = Thread::current();
  if (thr->is_Java_thread()) {
    JavaThread* jt = (JavaThread*)thr;
    jt->satb_mark_queue().enqueue(pre_val);
  } else {
    MutexLocker x(Shared_SATB_Q_lock);
    JavaThread::satb_mark_queue_set().shared_satb_queue()->enqueue(pre_val);
  }
}

G1出来之前的垃圾回收器 基本都没有使用到写前屏障,都是在写后屏障中做的处理,直到G1出来之后才引入了写前屏障pre_write_barrier

在并发标记过程中被修改了的引用 在修改之前会将旧值oop添加到satb_mark_queue中 形成原始引用的快照队列

satb_mark_queue又是是干嘛的呢? 在并发标记过程中 程序工作线程和GC垃圾回收线程是同时进行的,那么再标记存活对象的时候 可能指针会发生改变 将对应的改变的数据之前的旧值的引用放入SATB的标记队列中

在并发标记线程中获取队列值 并及时修改 也就是程序一遍修改 GC线程一遍读取队列值做出相应的修改,但是这个过程可能没完没了 所以在重新标记(STW触发)的时候会消费完整个satb队列

写后屏障

在对象引用改变之后保存新的对象引用 在RSet中需要记录垮Region的引用 这个时候就需要通过写后屏障去判断 对应对象所在的区域是否和当前对应不在一个区域,记录对应的区域对当前区域对象引用的地址

void
G1SATBCardTableLoggingModRefBS::write_ref_field_work(void* field,
                                                    oop new_val) {
 jbyte* byte = byte_for(field);
 if (*byte != dirty_card) {
   *byte = dirty_card;
   Thread* thr = Thread::current();
   if (thr->is_Java_thread()) {
     JavaThread* jt = (JavaThread*)thr;
     jt->dirty_card_queue().enqueue(byte);
   } else {
     MutexLockerEx x(Shared_DirtyCardQ_lock,
                     Mutex::_no_safepoint_check_flag);
     _dcqs.shared_dirty_card_queue()->enqueue(byte);
   }
 }
}

dirty_card_queue主要是为了更新RSet中老年代对新生代的引用,这个操作就是在对象赋值后的添加到这个脏卡队列

在YGC的时候有更新RSet中的数据的步骤,数据来源就是根据dirty_card_queue队列中的数据,可以避免扫描整个堆,只需要扫描对应Region中RSet对象的引用就可以解决跨代引用的问题,

这里需要注意这里指的屏障跟线程模型中的内存屏障不是一回事 只是在写的前后添加了方法记录被称之为屏障

CSet

CSet全称是CollectionSet,CSet中主要记录的值就是需要回收的Region区域,首先我们通过标记是可以知道垃圾分布的情况。

CSet的选定完全靠统计模型找处收益最高、开销不超过用户指定的上限的若干region。由于每个region都有RSet覆盖,要单独清理(evacuate)任意一个或多个region都没问题。

YGC选定所有新生代里的Region,MixedGC选定所有的新生代Region和部分老年代的Region(通过统计预测方式计算尽可能满足预期停顿时间的方式) 放入CSet中

Region区域放在CSet中,这里需要注意CSet中是一定包含所有新生代的会包含部分老年代,不管是YGC还是MixedGC CSet中一定包含所有新生代,那么如何去实现 可预测停顿时间呢,通过控制新生代的Region个数来控制YGC的开销。

GC回收阶段

YGC

image.png YGC几个流程:

  1. STW暂停工作线程 整个YGC都是STW的
  2. 构建CSet 在YGC的时候 选定所有新生代区域的Region做为CSet回收表
  3. 处理所有的dirty_card_queue 更新RSet
  4. 通过RSet找到被old区引用的对象 添加到GCRoots扫描集中
  5. 扫描GCRoots,找到存活对象
  6. 复制存活对象到 Survivor或者Old
  7. 处理软弱虚引用的回收
  8. 记录Region数量和对应STW时间,调整新生代Region个数

通过RSet是可以知道当前对象是否被Old区的Region所引用,自然就解决看跨代引用的问题,避免了扫描整个堆

在YGC过程中会将所有的新生代的Region做为扫描CSet Region的列表

image.png 以上图来说就将 [E1,E2,E3,S]做为本次需要mark新生代的Region,通过Copying算法将对应存活的数据放入Survivor区或者Old区然后清空Eden区整个过程都是STW,因为只是将年轻代的Region做为回收内存区域计算,所以时间不会特别长,并且G1也会根据预期停顿时间对新生代region个数做调整

整个YGC都是STW这个需要注意 从逻辑上YGC跟其他GC是没有什么关系,但是因为YGC需要mark整个新生代,只是后续MixedGC会复用下YGC所标记的结果而已

所以YGC触发的时候可能会有两种情况 一种新生代空间不够触发YGC

GC pause (G1 Evacuation Pause) (young)

还有一种是 YGC(init mark)也就是由并发标记中的初始标记阶段触发的

GC pause (G1 Humongous Allocation) (young) (initial-mark)

YGC清理的过程比较简单,就是通过GCRoots找到区域中存活的对象将其复制到其他Region,年龄不够的复制到S 幸存者区 年龄晋升条件到达的 复制到O 老年区

记录Eden/Survivor的数量和GC时间,以及自适应调节

  • 根据暂停目标自动调整Region数量(如果达不到你设定的时间,则减少该Region的数量)
  • 暂停目标越短,那么可能会导致Eden区过于少,从而导致执行YGC过滤频繁,导致吞吐量下降
YGC日志
[GC pause (G1 Evacuation Pause) (young), 0.0129459 secs]
   // 并行回收
   [Parallel Time: 12.0 ms, GC Workers: 8]
       //开始GC 预备阶段
      [GC Worker Start (ms): ..]
      //扫描根
      [Ext Root Scanning (ms): ..]
      //修改RSet数据
      [Update RS (ms): ...]
      //扫描RSet
      [Scan RS (ms): ...]
      //根搜索
      [Code Root Scanning (ms):...]
      //复制存活对象
      [Object Copy (ms): ...]
      //结束
      [Termination (ms): Min: ...]
      [GC Worker Other (ms):..]
      [GC Worker Total (ms): ...]
      [GC Worker End (ms):...]
   [Code Root Fixup: 0.0 ms]
   [Code Root Purge: 0.0 ms]
   [Clear CT: 0.1 ms]
   [Other: 0.8 ms]
      //选择需要回收的Region 添加到CSet
      [Choose CSet: 0.0 ms]
      //引用处理
      [Ref Proc: 0.4 ms]
      [Ref Enq: 0.0 ms]
      // 赃卡表处理
      [Redirty Cards: 0.1 ms]
      // 记录大对象
      [Humongous Register: 0.1 ms]
      //清理大对象
      [Humongous Reclaim: 0.1 ms]
      //清空CSet
      [Free CSet: 0.0 ms]
   [Eden: 25.0M(25.0M)->0.0B(21.0M) Survivors: 0.0B->4096.0K Heap: 80.8M(512.0M)->56.4M(512.0M)]
 [Times: user=0.01 sys=0.05, real=0.01 secs] 

并发标记阶段

image.png

初始标记 STW

1.标记好所有的GCRoots直接可以访问的对象,这个过程是比较快的。

2.执行一次YGC

//  YGC + InitialMark
[GC pause (young) (initial-mark), 0.17767100 secs]
// Eden被清空
[Eden: 1220M(1220M)->0B(1220M)
Survivors: 144M->144M Heap: 3242M(4096M)->2093M(4096M)]
[Times: user=0.02 sys=0.04, real=0128 secs]

根区域扫描

扫描survivor区,标记直接可达对象。 这个过程和应用程序可以并行执行,但根区域扫描不能和新生代GC同时进行。如果恰好此刻新生代也在执行,那么需要等待根区域扫描结束以后才能进行,这样YGC的时间就会延长。

并发标记

在之前的CMS笔者说过,通过根搜索的方式定位整个对象图是比较耗时的部分,所以新一代的垃圾回收器基本都会在这个阶段采用并发的方式出定位

  1. 通过第一步初始标记后,是可以直接知道根引用的直接对象

  2. 通过根搜索三色标记算法,可以定位出对应有用的对象以及标记完可达对象

  3. SATB通过写前屏障 会记录在并发阶段被修改引用对象之前引用的队列satb_mark_queue,并且在并发线程的时候还会去消费satb_mark_queue队列中的数据

  4. 并发标记的时候也会记录对象存活的比例,主要是为了实现后续的筛选回收,通过用户指定的最大停顿时间(预测模型计算) 去做筛选回收

并发标记的根搜索算法是需要等待YGC结束之后才能执行的,主要是YGC会修改SurvivorOld,如果同时进行那么数据就得不到保障,CMS是单独回收老年代,所以不存在这个问题,而G1因为有MixedGC混合回收的概念,所以并发标记和YGC是互斥的,并发标记也是会被YGC给中断的

总结来说并发标记之前肯定是会执行YGC,YGC执行之后才会触发并发标记直接利用YGC产生STW的时候标记根直接可达对象

重新标记

这个阶段主要是为了处理SATB队列中残留的值 解决漏标的问题 所以这一步必然后产生STW,这里需要我们记住 在任何垃圾回收器中基本在重新标记阶段都是STW

并发和独占清理

  1. STW 记录存活对象以及记录完全空闲区域的Region(整个Region没有一个存活对象)
  2. STW 删除记录中的Region 这里并不是清理 只是在维护Region的CSet中删除Region的记录
  3. 清理完全空闲的Region,将清理完的Region添加到空闲列表中

这里需要注意 1和2同样也是会产生STW的 我看到网上很多人说这里是并发,其实是不对的 这两步同样也会产生STW

日志分析
//触发YGC 和 InitMark STW
[GC pause (G1 Humongous Allocation) (young) (initial-mark), 0.0018917 secs]
   [Parallel Time: 0.9 ms, GC Workers: 8]
     .....
 [Times: user=0.01 sys=0.01, real=0.00 secs] 
 
//并发根搜索
[GC concurrent-root-region-scan-start]
[GC concurrent-root-region-scan-end, 0.0001262 secs]

//并发标记
[GC concurrent-mark-start]
[GC concurrent-mark-end, 0.0012169 secs]

//重新标记 STW
[GC remark [Finalize Marking, 0.0004252 secs] [GC ref-proc, 0.0000869 secs] [Unloading, 0.0012126 secs], 0.0036890 secs]
 [Times: user=0.01 sys=0.00, real=0.00 secs] 
 
// 清理 
[GC cleanup 302M->247M(512M), 0.0007835 secs]
 [Times: user=0.00 sys=0.00, real=0.00 secs] 
 
// 并发清理
[GC concurrent-cleanup-start]
[GC concurrent-cleanup-end, 0.0000512 secs]

MixedGC

MixedGC是混合回收,在MixedGC之前肯定执行过多次并发标记 基本流程差不多为

YGC

YGC

YGC+initMark
Concurrent Mark

YGC

YGC+initMark
Concurrent Mark

...

MixedGC

可以看到在MixedGC之前肯定执行过一次或者多次Init MarkConcurrent Mark的,所以在前面阶段已经删除了全都是垃圾的Region(没有存活对象的Region mark中的Clean已经删除了),

  1. 新生代清理和初始标记 YGC+InitMark
  2. 并发标记 Concurrent Mark
  3. 复制清理阶段

在并发标记阶段的时候 记录了Region中对象的活跃度信息,此活跃信息确定在清理暂停期间最好回收哪些区域(垃圾最多优先)并且清理完全为空的垃圾区域

Mixed 清理阶段 老年代和新生代同时被回收,并且老年代的Region是根据用户设定的最大停顿时长去筛选的真正需要清理的Region 所以老年代区域有可能只是部分回收

[GC pause (G1 Evacuation Pause) (mixed), 0.0043200 secs]
   [Parallel Time: 3.4 ms, GC Workers: 8]
       //GC任务开始执行
      [GC Worker Start (ms): Min: 167223.5, Avg: 167223.6, Max: 167223.9, Diff: 0.3]
      //扫描根直接可达
      [Ext Root Scanning (ms): Min: 0.0, Avg: 0.2, Max: 0.3, Diff: 0.3, Sum: 1.5]
      //修改RSet
      [Update RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
         [Processed Buffers: Min: 0, Avg: 1.5, Max: 2, Diff: 2, Sum: 12]
      //扫描RSet   
      [Scan RS (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
      //根搜索
      [Code Root Scanning (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.0]
      //复制存活对象到空闲区域
      [Object Copy (ms): Min: 2.6, Avg: 2.7, Max: 2.8, Diff: 0.2, Sum: 21.8]
      //结束
      [Termination (ms): Min: 0.0, Avg: 0.1, Max: 0.3, Diff: 0.3, Sum: 1.2]
         [Termination Attempts: Min: 1, Avg: 1.5, Max: 2, Diff: 1, Sum: 12]
      [GC Worker Other (ms): Min: 0.0, Avg: 0.0, Max: 0.0, Diff: 0.0, Sum: 0.1]
      [GC Worker Total (ms): Min: 2.9, Avg: 3.1, Max: 3.2, Diff: 0.3, Sum: 24.8]
      [GC Worker End (ms): Min: 167226.7, Avg: 167226.7, Max: 167226.7, Diff: 0.0]
   [Code Root Fixup: 0.0 ms]
   [Code Root Purge: 0.0 ms]
   [Clear CT: 0.2 ms]
   [Other: 0.7 ms]
      [Choose CSet: 0.0 ms]
      [Ref Proc: 0.2 ms]
      [Ref Enq: 0.0 ms]
      [Redirty Cards: 0.2 ms]
      [Humongous Register: 0.2 ms]
      [Humongous Reclaim: 0.1 ms]
      [Free CSet: 0.0 ms]
   [Eden: 25.0M(25.0M)->0.0B(72.0M) Survivors: 0.0B->4096.0K Heap: 338.0M(512.0M)->300.6M(512.0M)]

可以看到Mixed的日志和Eden的日志几乎一模一样,因为Mixed同样也是采用的复制算法 同样也会回收新生代会依赖之前的并发标记的结果进行回收。

  1. 在并发标记之后,部分为垃圾的老年代区域已经被统计好了,在默认情况下老年代内存会分段为8次进行回收(-XX:G1MixedGCCountTarget)
  2. 优先回收垃圾最多的区域 因为上面已经将老年代分为8次进行回收 通过复制算法进行回收
  3. 混合回收也不一定要进行8次回收,可以通过-XX:G1HeapWastePercent设置可浪费的内存区域占比,默认为5%,整个堆内存中有5%是可以浪费的,如果发现内存中垃圾占比不超过5%的时候就不再进行混合回收了

FullGC

在G1中同样会产生FullGC 当内存无法分配的时候 比如在并发标记过程中产生的对象远远大于回收的对象,这个时候就会使用Serail Old串行垃圾回收器 进行全局STW 使用 标记整理的算法进行内存整理,我们在使用G1的时候应该完全避免出现Full GC

常用参数

参数描述
-XX:+UseG1GC在JDK9之前需要手动开启使用G1垃圾回收器
-XX:MaxGCPauseMillis最大暂停时间 默认为200ms
-XX:InitiatingHeapOccupancyPercen启动并发 GC 周期的(整个)堆占用百分比 默认为45%
-XX:ParallelGCThreads并行线程数 GC线程数
-XX:ConcGCThreads并发线程数
-XX:G1HeapRegionSizeRegion的大小 最小值为 1Mb,最大值为 32Mb 必须为2的幂
-XX:G1ReservePercent在并发阶段设置作为空闲空间的预留内存百分比,以降低目标空间溢出的风险。默认值是 10%