jvm中跨代引用相关问题

文章探讨了Java堆内存中的跨代引用问题,以及垃圾回收时如何通过记忆集和卡表来解决。重点介绍了写屏障在维护卡表中的作用,以及伪共享问题及其解决方案。
摘要由CSDN通过智能技术生成

跨代引用

跨代引用是指在java堆内存的不同代之间存在引用关系 , 比如新生代到老年代的引用 ,老年代到新生代的引用

跨代引用产生的问题

在这里插入图片描述
比如我们jvm堆上是上面这个情况 , 那么进行一次YoungGC的时候 , 会从GCRoots出发 , 然后进行可达性分析,这个时候就会发现只有A , B是可达的 , 就会在垃圾回收时把C给回收掉 , 但是其实C是有引用的 ,只不过不在新生代 , 在老年代中 , 这就发生了跨代引用

解决这个问题有两个办法 :

1. 在做Young GC的时候 ,除了固定的GCRoot以外 , 把老年代的所有对象也额外遍历了,来确保可达性分析结果的正确性,
1. 反之 (这里只是理论上允许 , 实际上除了CMS收集器 , 其他都不存在只针对老年代的收集)

上面这两种做法对垃圾回收过程中会带来很大的性能影响

其实有一个比较不错的办法就是在新生代中定义一个全局的数据结构 : 记忆集

记忆集和卡表

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

​ 记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。之后如果再发生新生代GC,垃圾回收器不需要扫描整个老年代来确定哪些对象存活 , 只需要扫描Remembered Set中的条目 , 从而就能减少扫描的开销

但其实这种记录了全部包含跨代引用对象的实现方案 , 不论是空间占用还是维护成本都挺高的 , 而在垃圾收集的场景中 , 其实我们只需要知道某一块非收集区域中 , 是否存在有着指向收集区域的指针就可以了 , 不用了解你跨代指针的全部细节 , 这点不考虑 ,

以下三种就是记忆集的记录精度分类 :

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

第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式。

千万不要把卡表就单纯的理解为了就是记忆集,记忆集是一种抽象的数据结构 , 这其实有点像java中的接口 (记忆集)和具体实现类(CardTable)的关系

卡表最简单的形式可以只是一个字节数组,它记录了记忆集的记录的精度 , 与堆内存的映射关系等等 , 而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 (十进制的 0 - 511) 、 0x0200 ~ 0x03FF (十进制的512 - 1023)、 0x0400 ~ 0x05FF (十进制的1024 - 1535) 的卡页内存块

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

写屏障

​ 卡表记忆集来缩减GCRoots扫描范围的问题了 , 但是有一个问题还没有解决 , 就是卡表中的元素如何维护 ?

  • 何时变脏 :有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。
  • 如何变脏,谁来维护卡表

​ 这里有两种情况 :

  • 如果是解释执行的字节码 , 虚拟机负责每条字节码指令的执行 , 那样就会有充分的介入空间了
  • 如果是编译执行 , 经过编译之后的代码已经是纯粹的机器指令流了,这就必须在机器码层面来把维护卡表的动作放到每一个赋值操作之中

但是java其实算是编译 + 解释…

​ HotSpot通过写屏障(Writer Barrier)技术维护卡表。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值前后都在写屏障的覆盖范围内在赋值前的部分的写屏障叫做写前屏障(Pre-Write Barrier)赋值后则叫写后屏障(Post-Write Barrier)。G1垃圾收集器出现之前,其他收集器只用了写后屏障 , 如下代码 :

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

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

​ 除了写屏障开销以外 , 卡表在高并发场景还面临着 “伪共享” 问题 , CPU的缓存系统是以缓存行 (Cache_Line)为单位存储,一个缓存行一般是32 / 64字节,多线程修改独立变量,这些变量如果恰好共享同一个缓存行,就会彼此影响(写回,无效化或同步)而导致性能降低。

​ 其实我们可以发现更新卡表的时候没有任何前置条件就可以执行的 , 只要引用字段赋完值我就执行,不管你之前有数组元素中有没有脏过 , 解决伪共享的方法就是 : 不采用无条件的写屏障,先检查卡表标记,只有卡表元素未被标记过时才将其标记变脏,即卡表更新逻辑变为:

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

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

  • 19
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值