文章目录
- STW
- 安全点
- 安全区域
- 记忆集与卡表
- 读写屏障
STW
收集器在根节点枚举这步都是必须要暂停用户线程的( STW ),如果不这样的话在根节点枚举的过程中由于引用关系在不断变化,分析的结果就不准确
安全点
收集器在工作的时候某些时间是需要暂停正在执行的用户线程的( STW ),这个暂停也并不是说用户线程在执行指令流的任意位置都能停顿下来开始垃圾收集, 而是需要等用户线程执行到最近的安全点后才能够暂停。
安全点如何选取呢?,安全点的选取基本是以:”是否具有让程序长时间执行的特征“为标准选定的,而最明显的特征就是指令序列的复用,主要有以下几点:
- 方法调用
- 循环跳转
- 异常跳转
对于安全点另一个问题是:垃圾收集器工作时如何让用户线程都跑到最近的安全点停顿下来?有两种方案:
- 抢先式中断:不需要用户代码主动配合,垃圾收集发生时,系统把用户线程全部中断,如果发现用户线程中断的地方不在安全点上,就恢复这个线程执行让它执行一会再重新中断。不过现在的虚拟机几乎没有采用这种方式。
- 主动式中断: 当垃圾收集器需要中断线程的时候,不直接对线程操作,仅仅设置一个标志位,各个线程执行过程中会不停的去主动轮询这个标志,一旦发现中断标志为真时就自己再最近的安全点上主动挂起。
安全区域
安全点的设计似乎完美的解决了如何停顿用户线程,它能保证用户线程在执行时,不太长时间内就会遇到可进入垃圾回收的安全点,但是如果用户线程本身就没在执行呢?比如用户线程处于 sleep 或者 blocked 状态,这个时候它就无法响应虚拟机的中断请求,没办法主动走到安全的地方中断挂起自己,对于这种情况就必须引入安全区域( Safe Regin )来解决。
安全区域是指能够确保在某一段代码片段之中, 引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。
当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,这段时间里 JVM 要发起 GC 就不必去管这些线程了。 当线程要离开安全区域时,它要检查 JVM 是否已经完成了根节点枚举(或者其他 GC 中需要暂停用户线程的阶段):
- 如果完成了,那线程就当作没事发生过,继续执行
- 如果没有完成,他就必须一直等待,直到收到可以离开安全区域的信号为止
记忆集与卡表
在分代收集理论里面提到过一个跨代引用问题,为了解决跨代引用带来的问题,垃圾收集器在新生代建立了一个叫做:记忆集( Remembered Set
)的数据结构存储老年代哪些区域存在跨代引用,以便在根节点扫描时将这些老年代区域加入 GC Roots 的扫描范围,这样避免将整个老年代都加入 GCRoots 的扫描范围
当然跨代引用的问题并非只在回收新生代才有,回收老年代也是一样的,所以需要更进一步理解记忆集的原理和实现方式。
记忆集定义:是一种用于记录从非收集区域指向收集区域的指针集合的象数据结构。
记忆集的实现:最常见的实现方式是通过卡表( Card Table
)的方式去实现,卡表最简单的形式是一个字节数组(hotspot ),如下:
CARD_TABLE[this address >> 9 ] = 0 1
1、字节数组 CARD_TABLE
的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作:卡页( Card Page
),卡页大小一般是2的N次幂, hotspot 中是2的9次幂(地址右移9位),即512字节。
2、如果卡表标识的起始地址是:0x0000,那数组的0,1,2号元素,分别对应的地址范围是:0x0000ox01ff,0x02000x03ff,0x0400~0x05ff,如下:
3、一个卡页的内存中通常包含不止一个对象,只要卡页内存中有一个或多个对象的字段存在跨代引用指针,那就将卡表对应字节数组元素的值标识位1,称之为 Ditry ,没有则标识位0,垃圾收集器工作时只要筛查 CARD_TABLE
中为1的元素,就能轻易找到哪些卡页内存块中包含跨代引用,就把这些内存块加入到 GC Roots 的扫描范围内。
读写屏障
目前已经解决了用记忆集来缩减存在跨代引用时 GC Roots 的扫描范围,但是还没解决卡表如何维护的问题,比如:何时将卡表变脏?
答案似乎明显:非收集区域存在收集区域的引用时,对应卡表元素就变脏,变脏的时间点原则上应发生在引用类型字段赋值的那一刻, 但问题是如何在引用类型字段赋值的那一刻去维护卡表呢?
如果是解释执行的字节码那相对好处理,虚拟机负责每条字节码的执行,有充分的介入空间,但如果是编译执行的场景呢?即时编译器编译后的代码已经是纯粹的机器指令了,所以必须找一个在机器码操作的层面,在赋值操作发生时来维护卡表。
hotspot 中是通过写屏障( write barrier )来维护的, 这里的读写屏障要和解决并发问题的 内存屏障
区分开来,这里的读写屏障类似于 spring 的AOP ,比如以下代码是一个卡表更新的简化逻辑
void oop_field_store( oop* field,oop new_value) {
//引用字段赋值
*field = new_value;
//写后屏障,完成卡表更新
post_write_barrier(field,new_value);
}
当然这里还需要解决一个问题:卡表在高并发场景下面临着 伪共享 问题,一般处理器的缓存行( cache line )大小是64字节,由于卡表一个元素占一个字节,64个卡表元素共享同一个缓存行,这64个卡表元素对应的卡页总大小内存为:64*512bytes=32M,也就是说如果不同线程更新的对象引用正好处在这32M内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。
为了解决伪共享的问题,简单的解决方案就是不采用无条件的写屏障,而是先检查卡标记,只有当卡表元素未被标记过时才将其标记为变脏,即更新卡表的逻辑变更如下:
if (CARD_TABLE[this address >> 9] != 0 ) {
CARD_TABLE[this address >> 9] = 0;
}
在jdk1.7之后 , hotspot 虚拟机增加了一个参数 -XX:+UseCondCardMark
,用来解决是否开启卡表更新前的条件判断,开启会增加一次额外的条件判断开销,但能够避免伪共享问题,两者各有性能损耗,是否开启需要根据实际情况来测试权衡,默认是关闭的。