我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章,点击查看活动详情
前言
HotSpot 算法细节(一):juejin.cn/post/713379…
5、记忆集与卡表
5.1、跨代引用与区域收集
跨代引用
分代收集理论部分说明了为解决对象跨代引用所带来的的问题,垃圾收集器在新生代中建立了名为记忆集(Remembered Set)的数据结构,用以避免把整个老年代加进 GC Roots 扫描范围
区域收集
事实上并不是只是新生代与老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的垃圾收集器,典型的如 G1、ZGC 和 Shenandoah 收集器,都会面临相同的问题
5.2、记忆集
概述
记忆集是一种用于记录从非收集区指向收集区域指针的集合的抽象数据结构
实现的初步考虑(不可行)
如果不考虑效率和成本,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构
这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂
而在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了
因此可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本
实现的考虑(可行):三种可供选择的精度
-
字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的 32 位或 64 位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针
- 即每个字长都要记录一下是否包含跨代指针
-
对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针
- 即每个对象都要记录一下是否包含跨代指针
-
卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针
- 即每一片内存区域都要记录一下是否包含跨代指针
- 显然该精度比较粗犷,更节省记忆集的存储和维护成本
第三种卡精度所指的是用一种称为卡表(Card Table)的方式去实现记忆集,这也是最常用的一种实现形式之一
卡表定义了记忆集的记录精度、与堆内存的映射关系
5.3、HotSpot 默认的卡表标记逻辑
CARD_TABLE(this address >> 9) = 1;
CARD_TABLE:字节数组 CARD_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的区域,这个内存块被称作卡页(Card Page),一般来说卡页都是 2N 次幂,比如上述代码中 HotSpot 使用的卡页是 29 ,即 512 字节
卡页:一个卡页的内存中通常包含不止一个对象,只要卡页内有一个或者更多对象的字段存在着跨代指针,那就将对象卡表的元素变脏(Dirty),没有则标记为 0
标记流程
在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入 GC Roots 中一并扫描
ps
之所以用 byte 数组而不是用 bit 数组主要是速度上的考量,现代计算机硬件都是最小按字节寻址,没有直接存储一个 bit 的指令,所以要用 bit 的话不得不多消耗几条 shift+mask 指令
5.4、卡表示意图
是否只有引用才被标记尚存疑,被引用好像不会被标记,而且如何消除标记也不知
6、写屏障
6.1、概述及定义
记忆集的卡表已经解决了如何缩减 GC Roots 扫描范围的问题
维护卡表是否变脏则是通过写屏障来解决的
写屏障就是当引用被赋予变量时的这一时刻的前或后添加一个逻辑,称为写前屏障和写后屏障
6.2、卡表变脏基准
有其他分代区域中对象引用了本区域对象时,其他分代区域所对应的卡表元素就应该变脏
变脏时间点原则上应该发生在引用类型字段赋值的那一刻
6.3、如何维护卡表 — 写屏障
如何在对象赋值的那一刻去更新维护卡表呢
假如是解释执行的字节码,那就相对好处理,虚拟机负责每条字节码指令的执行,有充分的介入空间
但在编译执行的场景中呢?经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作中 —— 写屏障
写屏障(Write Barrier)
写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的 AOP 切面
在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作
也就是说赋值的前后都在写屏障的覆盖范畴内,赋值前叫写前屏障(Pre-Write Barrier),赋值后叫写后屏障(Post-Write Barrier)
即对一个对象引用进行写操作(即引用赋值)之前或之后附加执行的逻辑
ps
HotSpot虚拟机的许多收集器都有使用到写屏障,但是直到 G1 收集器出现前,其他收集器都只用到了写后屏障
6.4、更新卡表的简化逻辑
void oop_field_store(oop* field, oop new_value) {
// 引用字段赋值操作
*field = new_value;
// 写后屏障,在这里完成卡表状态更新
post_write_barrier(field, new_value);
}
应用写屏障后,虚拟机就会为所有赋值操作生成相应指令,一旦收集器在写屏障中增加了更新卡表的操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与 Minor GC 时扫描整个老年代的代价相比还是低得多
6.5、伪共享:高并发场景
伪共享(False Sharing)
现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化、同步)而导致性能降低,这就是伪共享问题
假如处理器的缓存行大小为 64 字节,由于一个卡表元素占 1 个字节,64 个卡表元素将共享同一个缓存行
这 64 个卡表元素对应的卡页总的内存为 32 KB(64 * 512 字节),也就是说如果不同线程更新的对象正好处于这 32 KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能
6.6、伪共享解决方案
一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变量,即将卡表更新的逻辑变为以下代码
if (CARD_TABLE [this address >> 9] != 1) {
CARD_TABLE [this address >> 9] = 1;
}
在 JDK 7 之后,HotSpot 虚拟机增加了一个新的参数 -XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断
开启会增加一次额外的判断开销,但能够避免伪共享问题,两者各有性能损耗,要根据应用实际运行情况进行测试权衡
未完待续……
参考文献
《深入理解 Java 虚拟机》第三版