HotSpot关于GC的部分算法实现

记忆集参考博客

三色标记算法参考博客

获取GC Roots

1.枚举根节点(保守式GC)

通过遍历方法区和栈找到GC Roots,这个方法有两个弊端:

  • 耗时,现在很多应用仅仅方法区就有上百MB,如果逐个检查的话,效率就会变得不可接受
  • 需要STW(Stop The World),遍历时必须停止用户线程,不然用户线程一直运行,可能会导致GC Roots不断增加,永远也遍历不完

2.OopMap(准确式GC)

HotSpot使用OopMap数据结构来记录程序中对象的引用位置,每次需要GC时只需要扫描OopMap就可以让虚拟机快速定位到GC Roots。

3.安全点(Safepoint)

HotSpot什么时候创建OopMap?在执行过程中,导致引用关系变化的语句非常多,引用关系的改变也会使OopMap内容发生变化,如果每次变化都生成一次OopMap,那么会花费大量的内存记录OopMap,这样GC的空间成本将会变得很高,所以就有了安全点(Safepoint)。

程序运行到安全点的时候会暂停下来,这时引用不会发生变化,JVM此时创建OopMap (也就是说GC只能在安全点开始,因为GC需要OopMap来确定GC Roots)

Safepoint的选定既不能太少以致于让GC等待时间太长,也不能过于频繁以致于过分增大运行时的负荷。所以,Safepoint的选定基本上是以程序**“是否具有让程序长时间执行的特征”**为标准进行选定的。产生Safepoint的地方有3个:

  • 方法临返回前/调用方法的call指令后
  • 循环的末尾
  • 可能抛出异常的位置

既然用户线程到达Safepoint才能进行GC,那么问题就来了,如果让用户线程在GC时都跑到Safepoint上?

有两种方案:

  • 抢断式中断

    当GC发生的时,中断所有线程,如果被中断的线程不在Safepoint,恢复线程让其运行至Safepoint。几乎没有虚拟机采用这种方式

  • 主动式中断

    给线程设置一个中断标志位,各个线程执行时会主动去轮询这个标志位,当发现中断标志位是true时,就将自己挂起(轮询标志的地方和Safepoint是重合的)

4.安全域(SafeRegion)

Safepoint可以解决正在运行的线程,使其到达Safepoin。但是如果线程处于sleep或者blocked状态,这时线程无法响应中断(无法轮询中断标志位),所以Safepoint对这类线程无效,这时就引入了安全域(SageRegion)。

SafeRegion是指在一片代码段中,引用关系不会发生变化。在这个区域中的任意地方开始GC都是安全的(比如线程阻塞)。

也就是说:

  • 线程进入SafeRegion时(sleep或者blocked时),会标记自己进入了SafeRegion,这样JVM就不用担心GC时这些线程发生引用变化,可以正常GC
  • 当线程被唤醒后,先检查GC是否结束,如果结束,则可以离开SafeRegion,没结束则等待GC结束再离开

记忆集与卡表

堆空间通常被划分为新生代和老年代,由于新生代对象存活率低的特点,所以Young GC执行频率是比较高的。假设现在堆空间已经经历了多次GC,那么其结构可能如下图,对象分散在Gen 0新生代和Gen 1老年代中。
image-20200805195639859
如果此时进行Young GC,那么对新生代的对象进行扫描,可达对象就只有两个(标了对号的),其他三个都算是不可达,将被回收。

1.问题1——跨代引用

但是这就产生了一个问题,因为会存在Gen 1对象到Gen 0对象的引用,即跨代引用(见图中红色箭头)。如果这些对象真的被GC掉,红色箭头所代表的指针就成了一个悬空指针(dangling pointer),为了保证安全,必须把存在跨代引用的Gen 1对象也设为GC根或者类似的东西,确保GC可以正确回收,如下图所示。
image-20200806081313830

2.问题2——如何找到跨代引用

如何才能知道哪些对象存在跨代引用?由于对象引用是单向的,所以一把思路就是在Young GC时将Old区也扫描一次,但是Young GC执行频繁,每次都需要将整个堆扫描,这样的效率是很低的。所以需要有一个东西来记录这些跨代引用,这就是记忆集合(Remembered Set/RemSet)。

3.记忆集合

记忆集合简要图示如下:
image-20200806082227787
RemSet中的每个元素分别对应内存中的一块连续区域是否有跨代引用对象,如果有,该区域会被标记为dirty(脏的),否则就是clean(干净的)。这样在GC时,只需要扫描RemSet就可以快速的确定跨代引用的位置,是个典型性的空间换时间的思路。

4.卡表

卡表是个字节数组(每个元素1字节),每个字节对应堆空间老生代中的512个字节**(这512个字节叫做卡页)**是否有跨代引用。

基于卡表的设计,通常会将堆空间划分为一系列2次幂大小的卡页,在HotSpot中,卡页大小为512字节。卡表用于记录卡页的状态,每一个卡表项对应一个卡页。
image-20200806085603554
卡页中只要有一个对象被其他区域对象所引用,对应卡表元素的值就变成1,也就是所谓的元素变脏。在GC时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页对应的内存包含跨代指针,把他们加入GC Roots中一并扫描。

三色标记算法

1.基本算法

要找出存活对象,根据可达性分析,从GC Roots开始进行遍历访问,可达的则为存活对象。把遍历过程中遇到的对象,按“是否访问过”这个条件标记成以下三种颜色:

  • 白色:尚未访问过
  • 黑色:本对象已访问过,而且本对象引用到的其他对象 也全部访问过了
  • 灰色:本对象已访问过,但是本对象引用到的其他对象 尚未全部访问完。全部访问后,会转换为黑色
    img
    假设现在有白、灰、黑三个集合(表示当前对象的颜色),其遍历访问过程为:
  1. 初始时,所有对象都在白色集合中,都未被访问
  2. 将GC Roots 直接引用到的对象 挪到灰色集合中
  3. 从灰色集合中获取对象:
    1. 将本对象引用到的其他对象全部挪到灰色集合中
    2. 将本对象挪到黑色集合里面
  4. 重复步骤3,直至灰色集合为空时结束
  5. 结束后,仍在白色集合的对象即为GC Roots不可达,可以进行回收,因为白色集合中的对象是访问不到的对象,即不可达对象

2.多标和漏标问题

当STW(Stop The World)时,对象间的引用是不会发生变化的。但是**需要支持并发标记时(GC线程与用户线程并发执行),**对象间的引用可能发生变化,这时就会发生2个问题:

  • 多标
  • 漏标
2.1 多标——浮动垃圾

假设GC线程已经遍历到E(已被放入灰色集合中),此时用户线程执行了:

// D断开了对E的引用
objD.fieldE = null;

image-20200805090618876
这时E、F、G都应该被回收掉,但是因为E已经变成灰色,那么它仍会被当做存活对象继续遍历,遍历结束后,F、G也都会标记为存活。这样这些对象就只能等待下次GC来回收,这部分本应该回收,但是却没有被回收的对象就是浮动垃圾

针对并发标记开始后的新对象,通常会直接全部标记为黑色,但是这些对象可能只存活极短的时间就被断开引用,所以这些新对象也可能成为浮动垃圾,浮动垃圾不会影响程序的正确性。

2.2 漏标——读写屏障

假设已经遍历到E(已被放入灰色集合中),此时用户线程执行了:

Object G = objE.fieldG;
// E断开对G的引用
objE.fieldG = null;
// D建立对G的引用
objD.fieldG = G;

image-20200805091915233
这时如果在切换会GC线程,会继续E的遍历,然而E已经没有了G的引用,所以无法标记到G;而D已经标记结束,不会再次遍历D,所以G最终会在白色集合中,被当做垃圾处理。

如果G被回收,那么就影响了程序的正确性,因为之后在使用D对象的时候可能发生错误(比如空指针异常),这是不被允许的。

3.解决方案

只有漏标的情况下,才会对程序的正确性造成影响,所以现在对漏标问题进行分析,寻找解决方案。

可以看出,漏标情况发生需要同时满足两个条件

  • 灰色对象断开了对白色对象的引用
  • 黑色对象重新引用了这个白色对象

从代码方面来看,其执行流程如下:

// 1.读操作:读取白色对象的引用值
Object G = objE.fieldG;
// 2.写操作:灰色对象将字段中白色对象的引用值写为null值
objE.fieldG = null;
// 3.写操作:黑色对象将字段写入白色对象的引用值
objD.fieldG = G;

所以只要在这3步中进行一些操作,将G记录起来,然后作为灰色对象再进行遍历即可。比如放到一个特定的集合,等初始的GC Roots遍历完(并发标记),再对这个特殊集合进行遍历(重新标记)

注意:重新标记是需要STW的,如果不STW,那么用户线程会一直执行,可能会导致特殊集合中一直添加新对象,永远遍历不完。(也可以在并发标记期间就遍历部分这个特殊集合,这是一种优化方法)。

通常对于漏标的解决方案有两种(都是通过写屏障来实现的):

  • 原始快照(SATB,Snapshot At The Beginning)
  • 增量更新(Incremental Update)

读屏障:

某个对象的成员变量赋值时,底层代码大概如下:

/**
* @param field 某对象的成员变量,如 D.fieldG
* @param new_value 新值,如 null
*/
void oop_field_store(oop* field, oop new_value) { 
    *field = new_value; // 赋值操作
} 

写屏障就是指在复制操作前后,加入一些处理

void oop_field_store(oop* field, oop new_value) {  
    pre_write_barrier(field); // 写屏障-写前操作
    *field = new_value; 
    post_write_barrier(field, value);  // 写屏障-写后操作
}
3.1 SATB

针对灰色对象,增加前置操作(即针对E对象),当对象E的成员变量的引用发生变化时(objE.fieldG = null;),将E原来成员变量的引用对象G记录下来:

void pre_write_barrier(oop* field) {
 // 处于GC并发标记阶段 且 该对象没有被标记(访问)过
 if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) { 
     oop old_value = *field; // 获取旧值
     remark_set.add(old_value); // 记录  原来的引用对象
 }
}

这种做法的思路是:尝试保留开始时的对象图,即原始快照(Snapshot At The Beginning,SATB)。当某个时刻 的GC Roots确定后,当时的对象图就已经确定了。比如当时D是引用着G的,那后续的标记也应该是按照这个时刻的对象图走(D引用着G)。如果期间发生变化,则可以记录起来,保证标记依然按照原本的视图来

3.2 增量更新

针对黑色对象,增加后置操作(即针对D对象),当对象D的成员变量的引用发生变化时(objD.fieldG = G),将D新的成员变量引用对象G记录下来,等到并发标记结束后,再以新建立引用的节点为根(D对象)进行扫描,可以理解为:一旦黑色对象重新引用了白色对象,那么就变成了灰色,重新扫描

void post_write_barrier(oop* field, oop new_value) {  
  if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
      remark_set.add(new_value); // 记录新引用的对象
  }
}

4.三色标记算法的引用

  • CMS中使用,漏标采用增量更新
  • G1中使用,漏标采用SATB(更注重减少STW的时间)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值