文章资讯
十、如何解决跨代引用问题
2022-07-22 10:15  浏览:1
假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的 GC Roots 之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担,记忆集(Remembered Set):一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构,在对象层面来说就是非收集区域对象对收集区域对象的引用的记录,记忆集根据记录的精度分三类:,卡表(Card Table):是以卡精度的方式实现的记忆集,也是目前最常用的方式。记忆集是抽象的概念,而卡表就是记忆集的一种具体实现,卡表最简单的形式可以是一个字节数组,HotSpot 就是这样实现的,把地址的值右移 9 位相当于除于 512 就是卡表索引,每字节 512 为一组对应卡表同一个元素,一组就是一个卡页,如果这个卡页中只要有一个对象被其他区域对象所引用,对应卡表元素的值就变成1,也就是所谓的元素变脏;在垃圾回收时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页对应的内存包含跨代指针,把他们加入 GC Roots 中一并扫描,卡表解决了如何使用记忆集来缩减GC Roots扫描范围的问题,但还没有解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏等,在 HotSpot 虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。写屏障可以看作在虚拟机层面对 引用类型字段赋值 这个动作的 AOP 切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的
前后都在写屏障的覆盖范畴内,应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与 Minor GC 时扫描整个老年代的代价相比还是低得多的,除了写屏障的开销外,卡表在高并发场景下还面临着伪共享(False Sharing)问题。,假设处理器的缓存行大小为 64字 节,由于一个卡表元素占 1 个字节,64 个卡表元素将共享同一个缓存行。这 64 个卡表元素对应的卡页总的内存为 32 KB(64×512字节),也就是说如果不同线程更新的对象正好处于这 32KB 的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏,即将卡表更新的逻辑变为以下代码所示:,在 JDK 7 之后,HotSpot虚拟机增加了一个新的参数 -XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡,

如何解决跨代引用问题
  • 一、题目
  • 二、什么是跨代引用
  • 三、记忆集
  • 四、卡表
  • 五、写屏障
  • 六、伪共享

一、题目

如何解决跨代引用问题

二、什么是跨代引用

假如要现在进行一次只局限于新生代区域内的收集(Minor GC),但新生代中的对象是完全有可能被老年代所引用的,为了找出该区域中的存活对象,不得不在固定的 GC Roots 之外,再额外遍历整个老年代中所有对象来确保可达性分析结果的正确性,反过来也是一样遍历整个老年代所有对象的方案虽然理论上可行,但无疑会为内存回收带来很大的性能负担

  • 跨代引用相对于同代引用来说仅占极少数,不应再为了少量的跨代引用去扫描整个老年代,也不必浪费空间专门记录每一个对象是否存在及存在哪些跨代引用,只需在新生代上建立一个全局的数据结构(该结构被称为记忆集,Remembered Set),这个结构把老年代划分成若干小块,标识出老年代的哪一块内存会存在跨代引用
  • 此后当发生 Minor GC 时,只有包含了跨代引用的小块内存里的对象才会被加入到 GC Roots 进行扫描。虽然这种方法需要在对象改变引用关系(如将自己或者某个属性赋值)时维护记录数据的正确性,会增加一些运行时的开销,但比起收集时扫描整个老年代来说仍然是划算的
三、记忆集

记忆集(Remembered Set):一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构,在对象层面来说就是非收集区域对象对收集区域对象的引用的记录

它存放在收集区域,比如在新生代里面存放着老年代对新生代对象的每一个引用。这样在收集新生代的时候,我们就可以根据记忆集知道哪些对象被老年代对象所引用,不能回收,这就解决了跨代引用的问题

记忆集根据记录的精度分三类:

  • 字长精度:记录的是老年代指向新生代地址
  • 对象精度:记录的是老年代引用的新生代对象
  • 卡精度:记录的是新生代一段地址是否存在被老年代引用的记录
四、卡表

卡表(Card Table):是以卡精度的方式实现的记忆集,也是目前最常用的方式。记忆集是抽象的概念,而卡表就是记忆集的一种具体实现

卡表最简单的形式可以是一个字节数组,HotSpot 就是这样实现的

CARD_TABLE [this address >> 9] = 0;

把地址的值右移 9 位相当于除于 512 就是卡表索引,每字节 512 为一组对应卡表同一个元素,一组就是一个卡页,如果这个卡页中只要有一个对象被其他区域对象所引用,对应卡表元素的值就变成1,也就是所谓的元素变脏;在垃圾回收时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页对应的内存包含跨代指针,把他们加入 GC Roots 中一并扫描

五、写屏障

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

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

在 HotSpot 虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。写屏障可以看作在虚拟机层面对 引用类型字段赋值 这个动作的 AOP 切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的
前后都在写屏障的覆盖范畴内

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

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

六、伪共享

除了写屏障的开销外,卡表在高并发场景下还面临着伪共享(False Sharing)问题。

伪共享是处理并发底层细节时一种经常需要考虑的问题,现代中央处理器的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题

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

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

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


转载请标明出处,原文地址:https://blog.csdn.net/weixin_41835916 如果觉得本文对您有帮助,请点击支持一下,您的支持是我写作最大的动力,谢谢。

,