浅谈JVM垃圾收集——记忆集与卡表

发布时间:2023-12-08 16:30

背景

上一篇文章(浅谈JVM垃圾收集)提到了,当JVM进行垃圾收集时,它是怎么判断对象是否跨代引用的呢?

记忆集与卡表

为解决扫描GC ROOT时遇到对象跨代引用所带来的问题,收集器在新生代上建立一个全局的称为记忆集(Remembered Set)的数据结构

这个结构把老年代划分为若干个小块,标识出老年代哪一块内存会存在跨代引用。当发生 Minor GC 时,只有包含了跨代引用的小块内存中的老年代对象才会加入到 GC Roots 扫描中,避免整个老年代加入到 GC Roots 中。

事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的 垃圾收集器,典型的如G1、ZGC和Shenandoah收集器,都会面临相同的问题。

记忆集是一种用于记录从非收集区域指向收集区域指针集合的抽象数据结构。在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。那设计者在实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择(当然也可以选择这个范 围以外的)的记录精度:

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

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

记忆集其实是一种“抽象”的数据结构,抽象的意思是只定义了记忆集的行为意图,并没有定义其行为的 具体实现。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。 关于卡表与记忆集的关系,读者不妨按照Java语言中HashMap与Map。

HotSpot虚拟机定义的卡表只是一个字节数组。以下这行代码是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、0x0200~0x03FF、0x0400~0x05FF的卡页内存块,如下图所示:
浅谈JVM垃圾收集——记忆集与卡表_第1张图片
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。那虚拟机是何时让卡表元素变脏呢?它是如何维护卡表元素的呢?

写屏障

卡表元素何时变脏的答案是很明确的——有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻,把维护卡表的动作放到每一 个赋值操作之中。

在HotSpot虚拟机里是通过写屏障(Write Barrier)技术维护卡表状态的。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切 面,在引用对象赋值时会产生一个环形(Around)通知,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值 后的则叫作写后屏障(Post-Write Barrier)。下面是简化的代码逻辑:

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个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。

简单的说就是卡表内处于同一缓存行中的元素,若对应的不同卡页的内存中的对象的引用关系发生了变化,部分对象发生了跨代引用,那么对应的卡表数组元素就要从0变为1。但是它们由于处于同一缓存行,导致了CPU并行执行变为串行执行,降低了效率。
伪共享问题是卡表元素更改时处于同一缓存行导致的,诱发的因素是不同卡页内的对象发生了跨代引用。

为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏,即将卡表更新的逻辑变为以下代码所示:

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

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

总结

记忆集与卡表

HotSpot虚拟机是用记忆集来记录某块内存区域是否包含跨代引用的对象。记忆集是抽象概念,而卡表是记忆集的实现

卡表是用字节数组实现的,卡表数组的每个元素都是代表某块具体内存区域,这个内存区域叫卡页

卡页的大小是512字节,代表一块特定大小的内存块,若在这块内存块中有一个或多个的跨代指针,则将对应的卡表元素标为1,代表“变脏”,否则为0。当虚拟机扫描卡表元素为1时,便将对应的卡页内存区域加入到GC ROOT中一并扫描。

写屏障

使用写屏障来实现卡表元素变脏。写屏障分为写前屏障和写后屏障,大多数垃圾收集器都是使用写后屏障(G1使用写前屏障)。写后屏障具体表现在对引用对象赋值时,如果是跨代引用,则通过写后屏障将对应的卡表元素变脏

伪共享问题(美团面试)

由于CPU集成的多级缓存中是以缓存行来读取数据的,通过MESI协议保证多个CPU之间的缓存一致性。
伪共享问题是卡表元素更改时处于同一缓存行导致的,诱发的因素是不同卡页内的对象发生了跨代引用,从而使CPU并行执行变为串行执行,降低了并发性能。

举例: 若a、b位于同一缓存行,当CPU1修改a后,若CPU2想修改b,必须先提交CPU1的缓存,然后CPU2再去主存中读取数据。

伪共享问题解决方案:JAVA中的解决方案有填充法Contended 注解

  • 填充法:就是 扩大对象的大小,这样,就可以一个缓冲行中,只存在一个对象!这样,就不会导致结果是串行执行了!
  • Contended 注解法:Java1.8 中提供了Contended注解,使用这个注解,VM必须设置 -XX:-RestrictContended。
    ConcurrentHashMap的内部类CounterCell有用到这个注解
    浅谈JVM垃圾收集——记忆集与卡表_第2张图片

记忆集与OopMap的联系与区别
共同点:他们都是用于获取GC ROOT
不同点:OopMap记录的是准确的GC ROOT。而记忆集记录的是 包含跨代引用的GC ROOT 的一块内存,还要再扫描这块内存以得到GC ROOT。

问题讨论
前面说记忆集的时候有说到,老年代中的对象也存在跨代引用。目前只针对老年代进行回收的只有CMS收集器,但是在CMS中,对于老年代存在的跨代引用的对象,CMS并没有在老年代维护一个记忆集,而是把整个新生代加入到GC ROOT扫描?

问题回答
《深入理解Java虚拟机》的第3.5.7节中讲G1垃圾收集器的时候有提到CMS中的卡表,下面是书中的一段话:

相比起来CMS的卡表就相当简单, 只有唯一一份,而且只需要处理老年代到新生代的引用,反过来则不需要,由于新生代的对象具有朝 生夕灭的不稳定性,引用变化频繁,能省下这个区域的维护开销是很划算的。
代价就是当CMS发生Old GC时(所有收集器中只有CMS有针对老年代的Old GC),要把整个新生代作为GC Roots来进行扫描。([6]注释中的话)

可知CMS中只会在新生代建立记忆集,老年代中是没有建立记忆集的。
记忆集:用于记录从非收集区域指向收集区域的指针集合。
所以回收新生代时是有老年代(非收集区域)指向新生代(收集区域)的记忆集,而老年代则需要把整个新生代加入到GC ROOT扫描

ItVuer - 免责声明 - 关于我们 - 联系我们

本网站信息来源于互联网,如有侵权请联系:561261067@qq.com

桂ICP备16001015号