JVM GC如何处理跨代引用?(JVM记忆集)

此篇内容来自《深入理解Java虚拟机》

跨代引用

对象不是孤立的,对象之间会存在跨代引用,假如现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的东西是完全有可能被来年代所引用的,为了找出该区域中的存活对象,不得不在固定的GC Roots之外再额外遍历整个老年代中所有的东西来确保可达性分析结果的正确性。
JVM将堆内存进行了分代,对象间可能存在跨代引用,那么每次进行GC的时候都需要进行全堆扫描判断是否有引用吗?答案并不是,JVM通过卡表的的技术来解决这个问题。

跨代引用假说

跨代引用相对于同代引用来说占极少数。
存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举例,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生对象在收集时同样得以存活,进而年龄增长后晋升到老年代中,这时跨代引用也随机被消除了。
因此依据这条假说,我们就不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为“记忆集”)。

记忆集(Remember Set)

记忆集是一种抽象概念,用于在实现部分垃圾收集(Partial GC)时用于记录从非收集区域指向收集区域的指针集合,卡表是记忆集的一种实现方式。例如在分代式GC中,通常能单独收集的只有Yong gen,那记忆集记录的就是Old gen指向Young gen的跨代指针。

卡表(Card Table)

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

CARD_TABLE [this address >> 9] = 0;

之所以使用byte数组而不是bit数组主要是速度上的考量,现代计算机硬件都是最小按字节寻址的,没有直接存储一个bit指令,所以要用bit的话就不得不多消耗几条shift+mask指令。

字节数组CARD_TABLE的每一个元素都对应着其标识的内存区域中的一块特定大小的内存块,这个内存块被称作“卡页”(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数,通过上面的代码卡页看出HotSpot中使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。那如果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了地址范围为0.00000x01FF、0x02000x03FF、0x04FF~0x05FF的卡页内存块。备注:十六进制数200、400分别为十进制的512、1024,这3个内存块为从0开始、512字节容量的相邻区域。
在这里插入图片描述

在这里插入图片描述

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

写屏障

上边已经解决了GC Roots扫描范围的问题,但是还没有解决卡表元素如何维护的问题,例如他们何时变脏、谁把他们变脏等。


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


在HotSpot虚拟机里是通过写屏障(Write Barrier)技术去维护卡表状态的。这与解决并发乱序执行问题的“内存屏障”有所不同。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动手做的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值前后都在写屏障的覆盖范畴之内。在赋值前的部分写屏障叫做写前屏障(Pre-Write-Barrier),在赋值后的则叫作写后屏障(Post-Write-Barrier)。HotSpot虚拟机的许多收集器中都有使用到写屏障,但直到G1收集器出现,其他收集器都只用到了写后屏障。

更新卡表的简单逻辑

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

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

伪共享

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

维基百科对于伪共享的解释:CPU的缓存是以缓存行(cache line)为单位进行缓存的,当多个线程修改不同变量,而这些变量又处于同一个缓存行时就会影响彼此的性能。例如:线程1和线程2共享一个缓存行,线程1只读取缓存行中的变量1,线程2修改缓存行中的变量2,虽然线程1和线程2操作的是不同的变量,由于变量1和变量2同处于一个缓存行中,当变量2被修改后,缓存行失效,线程1要重新从主存中读取,因此导致缓存失效,从而产生性能问题。
总体来说就是线程同时访问缓存行导致了,数据在缓存行里的缓存数据失效。


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

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

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


记录精度

一般假设:

  • RSet:对象粒度(RSet中存有Old gen的对象指针)
  • Cart Table:块粒度(上边已经讲到,通常是2的幂次方,HotSpot使用了512个字节),可能会包含多个对象

精度选择:

  • 字粒度:每个记录精确到一个机器字(word)。该字包含有跨代指针
  • 对象粒度:每个记录精确到一个对象。该对象里有跨代指针
  • card粒度:每个精度精确到一大块内存区域。该区域内可能含有大量的跨代指针
  • ====…

精度的不同,准确性就不同,垃圾收集过程中就可能会产生floating garbage,虽然不影响GC的正确性,但会带来一定的内存开销(已经死了对象没有被回收)
































  • 2
    点赞
  • 13
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

壹氿

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值