G1的Refine线程

三、G1的Refine线程

G1中存在一个重要的概念:记忆集(Remember Set,简称RSet)和卡表

为了记录对象在不同代际之间的引用关系,目的是在垃圾回收的时候快速地找到活跃对象,不用遍历整个堆空间。G1中引入了新的Refine线程用于处理这种引用关系

1.记忆集和卡表

因为标记阶段需要标记所有的存活对象,而在分代垃圾收集下,新生代和老年代处于不同的收集阶段,没有必要去标记所有的存活对象。因此使用了一个RSet记录从非收集部分指向收集部分的指针的集合,描述了对象的引用关系。RSet是针对于Region的

通常有两种方法记录引用关系:

  • 成为Point Out,在RSet中记录引用对象的地址,操作简单,但是标记扫描时需要扫描所有的RSet
  • 成为Point In,在RSet中记录被引用对象的地址,操作复杂,标记扫描时可以直接找到有用和无用的对象,因为RSet中的对象可以认为是根对象

Region之间的引用关系可以划分为:

  • Region内部有引用关系,垃圾回收的时候是针对分区,回收时会遍历整个分区,无需记录这种关系
  • 新生代到新生代Region,无需记录,因为三种回收算法都会全量处理新生代分区,它们会被遍历
  • 新生代到老年代Region,对于YGC来说,会针对新生代分区所以无需记录,对于混合GC来说,G1会使用新生代分区作为根,遍历新生代分区的时候自然会找到老年代分区,因此无需记录,对于Full GC来说,所有的分区都会被处理,更无需记录
  • 老年代到新生代Region,需要被记录,YGC的时候需要知道哪些对象是被老年代分区所引用的,YGC的时候有两种根,一个就是栈空间/全局空间变量的引用,另一个就是老年代分区到新生代分区的引用
  • 老年代到老年代Region,需要被记录,在混合GC的时候可能只有部分分区被回收,所以必须记录引用关系,快速找到哪些对象是活跃的

位图

当我们使用RSet直接记录对象的地址,带来的问题就是RSet的大小会急速膨胀,一个字可以表示512字节区域到被引用区的关系,因此RSet使用分区的起始地址和位图表示一个分区的所有引用信息

卡表有全局表,每个Region中也有一个卡表,作用并不是记录引用关系,而是记录该区域中对象垃圾回收过程中的状态信息,且描述对象所处的内存区域块的使用情况。全局只有一个可以理解为是一个bitmap,并且其中每个元素即是卡页(card)与堆中的512字节内存相互映射,当这512个字节中的引用发生修改时,写屏障就会把这个卡页标记为脏卡(dirty_card)。Region中的卡表就是将Region进一步划分,将Region划分为若干个物理连续的512Byte的card page - 卡页,这样每个Region就有一个卡表来映射Regin中的卡页,整堆有个global card table - 全局卡表 存储所有Region的卡表情况

堆内存申请:

// total_reserved是最大堆内存
// 申请内存,这里会传入地址addr从特定地址开始申请,默认从0开始申请最大堆内存
ReservedHeapSpace total_rs(total_reserved, alignment, use_large_pages, addr);

然而卡表相关类的初始化列表是在堆内存申请完成之后的:

  // 申请一段内存空间,大小为_byte_map_size
  // 且没有传入映射内存映射的基础地址 addr ,即从随机地址映射
  // 底层会调内核mmap()
  ReservedSpace heap_rs(_byte_map_size, rs_align, false);


由于card_table在heap之后才会申请创建,也就是说是堆内存确认之后才会开始进行卡表的内存申请,且是随机映射,而heap是根据对应地址去映射,所以card_table并不是使用的heap空间。

G1中使用了Point In的方法,算法可以简化为找到需要收集的分区Region集合,所以YGC只需要扫描Root Set和RSet即可,如果对象的引用发生了变化(通常就是赋值操作),必须要通知RSet,更改其中的记录。对于一个分区来说,里面的对象可能被很多分区引用,这就要求这个分区记录所有引用者的信息,所以G1垃圾收集器使用了一种新的数据结构:PRT (Per Region Table)来记录这种变化,每个Region都包含了一个PRT,是通过分区中的一个结构:HeapRegionRemSet获得,HeapRegionRemSet包含了一个OtherRegionsTable,也就是我们所说的PRT

hotspot/src/share/vm/gc_implementation/g1/heapRegionRemSet.hpp
class OtherRegionsTable VALUE_OBJ_CLASS_SPEC {
  BitMap            _coarse_map;
  PerRegionTable** _fine_grain_regions;
  SparsePRT         _sparse_table;
};

OtherRegionsTable使用了三种不同的粒度来描述引用,因为G1采用了Point In,它的缺点是一个对象可能被引用多次,次数不固定,为了提高效率,使用了动态化数据结构存储,三种粒度对应RSet中的三种数据类型:

  • 稀疏PRT:通过哈希表方式来存储。默认长度为4。
  • 细粒度PRT:通过PRT指针的指针,所以可以简单地理解为PRT指针的数组。其数组长度可以指定也可以自动计算得到。
  • 粗粒度:通过位图来指示,每一位表示对应的分区有引用到该分区数据结构。

稀疏PRT:稀疏哈希表

默认长度是4

 key  - region address  引用对象所在Region的地址,我得知道哪个Region引用了我啊
 value - card page index array - 引用对象所在Region中卡表索引值数组,我得知道引用我得对象在Region中具体那个位置啊

SparsePRT.cpp -> bool RSHashTable::add_card(RegionIdx_t region_ind, CardIdx_t card_index)
向稀疏哈希表中添加引用

细粒度PRT

当被引用的个数大于稀疏表大小时(默认四个),稀疏哈希表就需要使用更小的单位来映射卡表:位图来映射卡表,
位图很好理解,位-Bit,最小单位,只有0和1,中心思想就是用更小的空间来代表card中对象应用情况,节省空间

结构:

HeapRegion*     _hr;//Heap Region指针
  BitMap        _bm; //位图,每一位映射Region中一个card
  jint            _occupied;//引用数量
  PerRegionTable* _next; // 双向链表,指向下一个节点
  PerRegionTable* _prev; // 双向链表,指向上一个节点
  PerRegionTable * _collision_list_next;
  static PerRegionTable* volatile _free_list;

整体是一个双向链表,每个Node中包含了对应被引用Region中的卡页的位图,(key:卡页id,value:0/1),以及引用数量

向细粒度PRT中添加引用:add_reference_work

粗粒度PRT

对于被引用很多很多的对象来说,细粒度PRT的记录数同样也会不断飙升,所以在达到了G1设定的阈值之后,会转为使用粗粒度PRT:位图的0和1不在代表卡页了,而是代表Region【1代表这个Region中有对象引用了我】

一个Region的粗粒度表就是一个位图

2.Refine线程的功能及原理

Refine线程是G1新引入的并发线程池,线程默认个数为G1ConcRefinementThreads + 1 , 主要功能为:

  • 处理新生代分区的抽样,并且在满足响应时间的这个指标下,更新YHR的个数,通常有一个线程来处理
  • 管理RSet,主要功能,RSet的更新并不是同步完成的,G1会把所有的引用关系都先放入到一个队列中,称为dirty card queue(DCQ),然后使用线程来消费这个队列以完成更新。正常来说有G1ConcRefinementThreads个线程处理;实际上除了Refine线程更新RSet之外,GC线程或者Mutator也可能会更新RSet;DCQ通过Dirty Card Queue Set(DCQS)来管理;为了能够并发地处理,每个Refine线程只负责DCQS中的某几个DCQ。

这里对于处理DirtyCard的Refine线程有两个关注点:

  • Mutator如何把引用对象放入到DCQS供Refine线程处理
  • 当Refine线程太忙时,Mutator如何帮助线程

Mutator - 用户线程

Refinement - 优化线程

2.1 抽样线程

Refine线程池中的最后一个线程就是抽样线程,主要作用是设置新生代分区的个数,使得G1满足垃圾回收的预测停顿时间

收集集合(CSet)代表每次GC暂停时回收的一系列目标分区。在任意一次收集暂停中,CSet所有分区都会被释放,内部存活的对象都会被转移到分配的空闲分区中。因此无论是年轻代收集,还是混合收集,工作的机制都是一致的。年轻代收集CSet只容纳年轻代分区,而混合收集会通过启发式算法,在老年代候选回收分区中,筛选出回收收益最高的分区添加到CSet中。

2.2 管理RSet

G1中使用Refine线程异步维护和管理RSet的引用关系,因为是异步所以必须要有一个数据结构来维护这些需要引用的对象。JVM声明了一个全局的静态变量DirtyCardQueueSet(DCQS),每个DCQS中存放的是DCQ,为了性能,所有处理引用关系的线程共享DCQS,每个用户线程Mutator线程在初始化的时候都会关联DCQS。每个用户线程都有一个私有的队列,每个队列的最大长度由G1UpdateBufferSize(默认256)确定,即最多存放256个引用关系对象。如果产生新的对象引用关系则把引用者放入DCQ中,当满256个时,就把这个队列加入到DCQS中,因为DCQS被所有线程共享,所以放入时需要加锁,当然也可以手动提交当前线程的队列,但是当队列还没有满的时候,提交时需要指明有多少个引用关系,DCQ的处理则是通过Refine线程

// DCQS初始化代码:
// 初始化动作只应由JVM完成
    dirty_card_queue_set().initialize(DirtyCardQ_CBL_mon, // 全局的Monitor
                                      DirtyCardQ_FL_lock,
                                      -1, // never trigger processing,不做处理
                                      -1, // no limit on length,不设置GCQS的长度
                                      Shared_DirtyCardQ_lock,
                                      &JavaThread::dirty_card_queue_set());

这里全局的Monitor的目的为:任意的用户线程都可以通过JavaThread中的静态方法找到DCQS这个静态成员变量,当DCQ满了之后就会把DCQ加入到DCQS中,当DCQ加入成功并且满足一定的条件(DCQS中DCQ的个数大于一个阈值,这个阈值和Green Zone相关),就会获取静态变量Monitor,然后通过这个Monitor发送Notify通知0号Refine线程启动,因为0号Refine线程可能会被任意一个用户线程通知,所以这里的Monitor是一个全局变量

把DCQ加入到DCQS的方法是enqueue_complete_buffer,它定义在PtrQueueSet中,PtrQueueSet是DirtyCardQueueSet的父类。enqueue_complete_buffer是通过process_or_enqueue_complete_buffer完成添加的。在process_or_enqueue_complete_buffer中如果Mutator发现DCQS已经满了,那么就不继续往DCQS中添加了,这个时候说明引用变更太多了,Refine线程负载太重,这个Mutator就会暂停其他代码执行,替代Refine线程来更新RSet

Refine线程忙不过来时,G1会让用户线程帮忙处理引用变更。Refine线程的个数可以由用户设置,但是仍然可能存在因为对象引用修改太多,导致Refine线程太忙,处理不过来。所以让用户线程来处理引用变更,这样不仅可以暂停业务处理,还可以帮助处理引用关系。如果发生这种情况,要么就是修改关系太多,要么就是Refine线程数目设置太少。我们可以通过参数G1SummarizeRSetStats打开RSet处理过程中的日志,可以在其中查看处理线程的信息

2.3 Mutator处理DCQ

DCQS的最大长度依赖于Refine线程个数,最大为Red Zone的个数(之后会介绍),当DCQS里的DCQ数量个数超过Red Zone的个数时,提交队列的用户线程就不能把这个DCQ放入到DCQS中,此时,用户线程就会直接处理这个DCQ的引用

处理方法和Refine线程完全一样,关键是调用了DirtyCardQueue::apply_closure_to_buffer,会在之后进行介绍,完成之后会增加用户线程Mutator的统计信息

2.4 Refine线程的工作原理

JVM为了防止没有足够多的引用变更关系从而导致Refine线程空转的现象,通过wait和notify来控制Refine的冻结和激活,设计思想为:前一个线程发现自己太忙,就会激活后一个线程,后一个线程发现自己太闲,就会主动冻结自己,第0个线程则由java【用户】线程来进行激活。如果用户线程尝试把修改的引用放入到队列的时候,如果0号线程还没有被激活,那么就发送notify信号来激活0号线程,因为0号线程可能被任意的用户线程激活,所以0号线程等待的是一个全局的变量的monitor,剩余之后的线程中的monitor则全部都为局部变量

Refine通过DCQS来维护RSet的整体更新流程为:对于通过DCQS选出的需要处理分区的512字节,通过遍历的方式寻找到第一个对象,当作引用者来处理,通过引用者寻找到被引用者,在被引用者所在的分区中更新其RSet中记录的引用关系

在Refine线程的执行过程中,被引用者的地址不会发生变化,因为Refine线程过程中并不会发生GC,因此对象地址都是固定的

3.Refinement Zone

我们知道Refine线程的工作情况会根据其负载情况而改变,如果线程负载大则会唤醒后一个Refine线程一起工作,如果DCQS太满,所有的Refine线程负载都很大则会使用用户线程来帮助Refine线程进行处理引用关系。而这里的工作负载就是通过Refinement Zone来进行控制的,G1提供三个值,分别为Green、Yellow、Red,将整个DCQS计划分为四个区:

  • 白区:[0,Green) ,此区域内,Refine线程不处理,交给GC线程来处理DCQ
  • 绿区:[Green,Yellow),该区中,Refine线程开始启动,根据DCQS的大小启动不同数量的Refine线程来处理DCQ
  • 黄区:[Yellow,Red),该区中,所有的Refine线程(除了抽样线程)都会参与DCQ处理
  • 红区:[Red,无穷),在黄区处理的基础上还要加上用户线程也会参与处理DCQ
4.RSet涉及的写屏障

写屏障:在改变特定的内存的值时(实际上也就是写入内存)额外执行一些动作

G1的RSet就是通过写屏障来完成的,写变更的时候通过插入一条额外的代码把应用关系放入到DCQ中,之后Refine线程更新RSet,在写屏障中还添加了过滤操作来加快赋值器的速度也能减轻回收器的负担,G1采用了三重过滤:

  • 不记录新生代到新生代或者新生代到老年代的引用,因为GC时,新生代的堆分区会被采集
  • 过滤掉同一个分区内部的引用,在RSet处理时就被过滤
  • 过滤掉空引用,在RSet处理时过滤

使得RSet的大小大幅度减小,如何触发写屏障更新DCQ会在混合回收的时候提到

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值