安全点、安全区域、记忆集、卡表与写屏障

一、安全点(safe point)

1.1、定义

用户程序执行时并非在代码指令流的任意位置都能够停顿下来开始垃圾收集,而是强制要求必须执行到达特殊位置后才能够暂停,这些位置被称为安全点 ( Safepoint)。

1.2、安全点的选择

安全点的选定既不能太少以至于让收集器等待时间过长,也不能太过频繁以至于过分增大运行时的内存负荷。安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的,因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,“长时间执行”的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都于指令序列复用,所以只有具有这些功能的指令才会产生安全点。

对于安全点,另外一个需要考虑的问题是,如何在垃圾收集发生时让所有线程 (这里实不包括执行JNI调用的线程)都跑到最近的安全点,然后停顿下来。这里有两种方案可供选择:抢先式中断(Preemptive Suspension)和主动式中断(Voluntary Suspension)。

1.3、抢先式中断(Preemptive Suspension)

抢先式中断不需要线程的执行代码主动去配合,在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会儿再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC 事件。

1.4、主动式中断(Voluntary Suspension)

主动式中断的思想是当垃圾收集需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志位,各个线程执行过程时会不停地主动去轮询这个标志,一旦发现中断标志为真时就自己在最近的安全点上主动中断挂起。轮询标志的地方和安全点是重合的另外还要加上所有创建对象和其他需要在 Java 堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。

使用安全点的设计似乎已经完美解决如何停顿用户线程,让虚拟机进入垃圾回收状态的问题了,但实际情况却并不一定。安全点机制保证了程序执行时,在不太长的时间内就会遇到可进入垃圾收集过程的安全点。但是,程序“不执行”的时候呢?所谓的程序不执行就是没有分配处理器时间,典型的场景便是用户线程处于 Sleep 状态或者 Blocked 状态这时候线程无法响应虚拟机的中断请求,不能再走到安全的地方去中断挂起自己,虚拟机也显然不可能持续等待线程重新被激活分配处理器时间。对于这种情况,就必须引入安全区域(Safe Region)来解决。

二、安全区域( safe region)

2.1、定义

安全区域是指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。

当用户线程执行到安全区域里面的代码时,首先会标识自己已经进人了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了。那线程就当作没事发生过,继续执行否则它就必须一直等待,直到收到可以离开安全区域的信号为止。

三、 记忆集(Remembered Set)

3.1、定义

记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构

3.2、记忆集的作用

为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集 (Remembered Set) 的数据结构,用以避免把整个老年代加进GC Roots 扫描范围。事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集 (Partial GC)行为的垃圾收集器,典型的如 G1、ZGC 和 Shenandoah 收集器都会面临相同的问题。

3.3、可供选择的记录精度

这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂,而在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。那设计者在实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择 (当然也可以选择这个范围以外的) 的记录精度:

  • 字长精度: 每个记录精确到一个机器字长 (就是处理器的寻址位数,如常见的 32位或 64 位, 这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。

  • 对象精度: 每个记录精确到一个对象,该对象里有字段含有跨代指针。

  • 卡精度 : 每个记录精确到一块内存区域,该区域内有对象含有跨代指针。

四、卡表(Card Table)

4.1、定义

“卡精度”所指的是用一种称为“卡表”(Card Table) 的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式前面定义中提到记忆集其实是一种“抽象”的数据结构,抽象的意思是只定义了记忆集的行为意图,并没有定义其行为的具体实现。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。

4.2、卡表解析

卡表最简单的形式可以只是一个字节数组,而 HotSpot 虚拟机确实也是这样做的。以下这行代码是 HotSpot 默认的卡表标记逻辑:

CARD_TABLE [this address >> 9] = 0;

字节数组CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数,通过上面代码可以看出 HotSpot 中使用的卡页是2的9次幂,即512 字节(地址右移9位,相当于用地址除以 512)。那如果卡表标识内存区域的起始地址是 0x0000 的话,数组CARD TABLE的第 0、1、2 号元素,分别对应了地址范围为 0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块,如图所示:

一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多) 对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为 1,称为这个元素变脏(Dirty),没有则标识为 0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入 GC Roots 中一并扫描。

我们已经解决了如何使用记忆集来缩减GC Roots 扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等。

卡表元素何时变脏的答案是很明确的一一有其他分代区域中对象引用了本区域对象时其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢?假如是解释执行的字节码,那相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间;但在编译执行的场景中呢?经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。

五、写屏障 ( Write Barrier)

5.1、定义

在 HotSpot 虚拟机里是通过写屏障 ( Write Barrier) 技术维护卡表状态的。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的 AOP 切面,在引用对象赋值时会产生一个环形 (Around通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值后的则叫作写后屏障 (Post-WriteBarrier)。HotSpot 虚拟机的许多收集器中都有使用到写屏障,但直至 G1收集器出现之前,其他收集器都只用到了写后屏障。下面这段代码清单 3-6 是一段更新卡表状态的简化逻辑:

                            代码清单 3-6 写后屏障更新卡表

void oop_field_store(oop* field, oop new_value) {
    // 引用字段赋值操作
    *field = new_value;// 写后屏障,在这里完成卡表状态更新
    post_write_barrier(field,new_value);
}

应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与 Minor GC 时扫描整个老年代的代价相比还是低得多的。

5.2、伪共享(False Sharing)问题

除了写屏障的开销外,卡表在高并发场景下还面临着“伪共享”(False Sharing)问题.伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行 ( Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。

假设处理器的缓存行大小为 64 字节,由于一个卡表元素占 1个字节,64 个卡表元素将共享同一个缓存行。这 64 个卡表元素对应的卡页总的内存为 32KB (64X512 字节),也就是说如果不同线程更新的对象正好处于这 32KB 的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏。即将卡表更新的逻辑变为以下代码所示:

if (CARD_TABLE [this address >> 9] != 0)
    CARD_TABLE [this address >> 9] = 0;

在JDK 7之后,HotSpot 虚拟机增加了一个新的参数 -XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。

借道友法力一用:

========================== stay hungry stay foolish =============================

  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值