G1垃圾收集器读书总结

G1垃圾收集器早就听说了,但是一直没用过。因为在公司申请的服务器上默认垃圾收集器是CMS所以,一直没真正用过G1。用了一段时间后,发现G1的FGC次数确实比CMS少了很多(测试环境,3G堆空间)。

所以大概经历了三周的时间一直在学习JVM以及G1的相关知识。本文就是记录下G1的读书笔记。

 G1 中的region

  四种,Eden Survivor Old Humongous

  对于Humongous 这种Region被设计用来保存超过Region的50%空间的对象,它们存储在一系列连续的Region中。通常来说,超大对象只会在最终标记结束后的清理阶段(cleanup)才会被回收,或者发生FullGC时。

  Region数量最多不会超过2048个,每个Region的大小在1~32M之间,且必须是2的N次方。这就意味着,如果堆内存少于2048M(2G),那么Region数量就会少于2048个。

G1能解决的问题

  • 对于大于8G的堆,回收的很快,因为不会对整个队进行FGC

  • 自适应算法,能根据用户配置的启动参数指定的暂停时间,尽可能做到按照配置的暂停时间内完成一个MIXED GC

G1的重要概念

  • SATB

  • write barrier

  • Remembered Set

  • Card Table

SATB,Card Table和Write Barrier

在垃圾回收的过程中,标记阶段最怕的就是出现错标和漏标。漏标还好说,可以在下一次GC中回收。而且无论是CMS和G1都不可能完全解决漏标问题。

但是错标是千万不能的。为了解决错标的问题。接产生了write barrier以及对应的satb和card table。

 

write barriar分为两种:pre 和 post ,SATB以来的就是pre write baririer,而post 使用过card table和remembered set(简称rset)实现的

  • pre:SATB

  • post:card table 和rset

那什么是写屏障呢,代码示例

 

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  
}  

就是当有一个对象的引用类型的字段在进行赋值的时候,此时写屏障就发生了作用。

我们先说说post 写屏障吧

POST WRITE BARRIER

当对象的某一个字段写完成,表示该字段指向了新的引用。此时写屏障做的是就是这样的

 

  1. void post_write_barrier(oop* field, oop new_value) {

  2. uintptr_t field_uint = (uintptr_t) field;

  3. uintptr_t new_value_uint = (uintptr_t) new_value;

  4. uintptr_t comb = (field_uint ^ new_value_uint) >> HeapRegion::LogOfHRGrainBytes;

  5.  

  6. if (comb == 0) return; // field and new_value are in the same region

  7. if (new_value == null) return; // filter out null stores

  8.  

  9. // Otherwise, log it

  10. *volatile jbyte* card_ptr = card_for(field);* // get address of the card for this field

  11.  

  12. // in generational G1 mode, skip dirtying cards for young gen regions,

  13. // -- young gen regions are always collected

  14. // if (*card_ptr == g1_young_gen) return;

  15.  

  16. if (*card_ptr != dirty_card) {

  17. // dirty the card to reduce the work for multiple stores to the same card

  18. *card_ptr = dirty_card;

  19. // log the card for concurrent remembered set refinement

  20. JavaThread::current()->dirty_card_queue->enqueue(card_ptr);

  21. }

  22. }

如果有region之间的引用变更,必须是OLD-->OLD ,OLD --> YOUNG. YOUNG -->OLD无需考虑,无需记录。

从该代码可以看出来,所谓的dirty表示的是 该card中的某个对象的成员变量的引用发生了改变,指向了新的引用。那么该成员变量field所在的catd变为dirty。

注意这句,JavaThread::current()->dirty_card_queue->enqueue(card_ptr);  
把这个脏卡放到一个queue里,等待着Concurrent refinement threads去处理,处理的过程也就是更新rset的过程。

 

接下来我们说说RSET。

RSET的作用就是相当于字典的目录,是为了避免扫描整个region而设计的。每个region都有其RSET,
RSET的结构类似hashmap,我引用一段R大的原文:

G1 GC则是在points-out的card table之上再加了一层结构来构成points-into RSet:每个region会记录下到底哪些别的region有指向自己的指针,而这些指针分别在哪些card的范围内。
这个RSet其实是一个hash table,key是别的region的起始地址,value是一个集合,里面的元素是card table的index。
举例来说,如果region A的RSet里有一项的key是region B,value里有index为1234的card,它的意思就是region B的一个card里有引用指向region A。所以对region A来说,该RSet记录的是points-into的关系;而card table仍然记录了points-out的关系。

这就好理解了,本region中哪些对象被引用了只需要查询rset就够了。
把这个过程串起来一下:

1 post写屏障把脏card压入queue,queue满了会存入到全局的一个大queue里,等待着refine线程处理。
2 在remark阶段refine线程开始处理queue
3 refine线程拿到脏页后,得到对象的field,同时能得到field所引用的其他的region regionB 中的对象。现在能够拿到的是regionB,regionA的脏页,脏页里的field机器引用
4 这样就可以update regionb的rset了。

这就是rset的update过程。

​而RSET被使用的阶段是evacuation阶段,通过RSET可以直接使用copy算法,找出来应该保留的对象并拷贝到其他region。

​而RSET被使用的阶段是evacuation阶段,通过RSET可以直接使用copy算法,找出来应该保留的对象并拷贝到其他region。

PRE WRITE BARRIER

介绍完了post write barrier,再来看下pre write barrier。我们之前说,标记过程中最怕的是错标和漏标,这里pre write barrier就是解决错标的手段。还是引用下大佬R大的原话

前面提到SATB要维持“在GC开始时活的对象”的状态这个逻辑snapshot。除了从root出发把整个对象图mark下来之外,其实只需要用pre-write barrier把每次引用关系变化时旧的引用值记下来就好了。这样,等concurrent marker到达某个对象时,这个对象的所有引用类型字段的变化全都有记录在案,就不会漏掉任何在snapshot里活的对象。当然,很可能有对象在snapshot中是活的,但随着并发GC的进行它可能本来已经死了,但SATB还是会让它活过这次GC。

那么jvm是怎么做的是,看下源码

 

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);  
  }  
}  
}  

说明SATB队列是存在于线程中的,也是进入一个queue中以便后续处理。

 

现在我抛出来一个问题,既然都有了rset为啥还要satb呢?我当初也有这个疑问,但是重新看了下R大发出来的帖子,又有了新的认识。

因为,G1的标记是并发标记,而且post写屏障也是放到queue里异步处理,难以保证标记的顺序。而且G1采用的是三色标记法,而且不同于CMS,在比较过程中不会变更已标记的颜色。也就是说黑的节点,就是不会再次扫描的。这也是G1比CMS快的原因之一。因为CMS会把黑节点变成灰节点,这就导致remark节点要扫描整个堆区,而G1不会,扫过了就绝不会再次扫描,节省时间,只不过浮动垃圾会比CMS还多。

 

总结SATB和RSET,CARD TABLE

1 SATB使用阶段是并发标记周期,防止有对象被错标

2 RSET使用阶段是evacuation,清理阶段

 

Region内的结构

对应的,每个region都有这么几个指针:

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

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

(1): [bottom, prevTAMS): 这部分里的对象 <u>存活信息可以通过prevBitmap</u> 来得知

(2): [prevTAMS, nextTAMS): 这部分里的对象在第n-1轮concurrent marking是隐式存活的

(3): [nextTAMS, top): 这部分里的对象在第n轮concurrent marking是隐式存活的

这里提到了bitmap的概念,在标记阶段,每一个region的标记是需要rset和bitmap一起使用的.liveness bitmap其实就是个外置的mark bit啦,没啥神奇的。

带有marking阶段的GC,要记录某个对象是否已经被mark,可以在对象头上借用一个bit,也可以在一个外部的bitmap里记录。G1的global concurrent marking用的是外部的bitmap方式.就是表示对象是否被标识。

还有个小细节:在有liveness bitmap指明对象生死情况的地方,G1可以借助liveness bitmap来减少card table引致的floating garbage。具体是: 假如region A的RSet记录着有来自region B的card 123有incoming reference,而这个card 123正好在第n-1轮global concurrent marking已经记录下了对象生死在prev liveness bitmap里,那么在扫描card 123的时候只有prev liveness bitmap说还活着的对象的字段会被扫描,bitmap说已经死掉的对象则不会被扫描。

G1的回收周期

以下是R大的原文

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

启动程序 -> young GC -> young GC -> young GC -> young GC + initial marking (... concurrent marking ...) -> young GC (... concurrent marking ...) (... concurrent marking ...) -> young GC (... concurrent marking ...) -> final marking -> cleanup -> mixed GC -> mixed GC -> mixed GC ... -> mixed GC -> young GC + initial marking (... concurrent marking ...) ...

其中可以看到young GC可以单独运行,也可以搭上initial marking在同一个STW中运行。 “young GC (... concurrent marking ...)”的行里面young GC还是单独运行的,跟后台正在进行的concurrent marking不冲突。

 

可以看得出来,mixed gc会进行多次

 

该表来自 https://blog.csdn.net/renfufei/article/details/41897113

 

  下表是老年代的GC步骤,但是这里面也包含了young gc。

阶段说明
(1) 初始标记(Initial Mark) (Stop the World Event,所有应用线程暂停) 此时会有一次 stop the world(STW)暂停事件. 在G1中, 这附加在(piggybacked on)一次正常的年轻代GC. 标记可能有引用指向老年代对象的survivor区(根regions).
(2) 扫描根区域(Root Region Scanning) 扫描 survivor 区中引用到老年代的引用. 这个阶段应用程序的线程会继续运行. 在年轻代GC可能发生之前此阶段必须完成.
(3) 并发标记(Concurrent Marking) 在整个堆中查找活着的对象. 此阶段应用程序的线程正在运行. 此阶段可以被年轻代GC打断(interrupted).
(4) 再次标记(Remark) (Stop the World Event,所有应用线程暂停) 完成堆内存中存活对象的标记. 使用一个叫做 snapshot-at-the-beginning(SATB, 起始快照)的算法, 该算法比CMS所使用的算法要快速的多.
(5) 清理(Cleanup) (Stop the World Event,所有应用线程暂停,并发执行)
在存活对象和完全空闲的区域上执行统计(accounting). (Stop the world)
擦写 Remembered Sets. (Stop the world)
重置空heap区并将他们返还给空闲列表(free list). (Concurrent, 并发)
(*) 拷贝(Copying) (Stop the World Event,所有应用线程暂停) 产生STW事件来转移或拷贝存活的对象到新的未使用的heap区(new unused regions). 只在年轻代发生时日志会记录为 `[GC pause (young)]`. 如果在年轻代和老年代一起执行则会被日志记录为 `[GC Pause (mixed)]`.

posted on 2020-08-13 16:46  MaXianZhe  阅读(424)  评论(0)    收藏  举报

导航