记忆集与卡表
跨代引用
![](https://img.haomeiwen.com/i28686732/aa66397f28ccef34.jpg)
跨代引用是指新生代中存在对老年代对象的引用,或者老年代中存在对新生代的引用。
新生代引用老年代
做正常的垃圾回收即可。即使Minor GC把年轻代的对象清理掉了,程序依然能正常运行,而且随着引用链的断掉,无法被标记到的老年代对象会被后续的Major GC回收。
老年代引用新生代
YGC时,为了找到年轻代中的存活对象,不得不遍历整个老年代;反之亦然。这种方案存在极大的性能浪费。因为跨代引用是极少的,为了找出那么一点点跨代引用,却得遍历整个老年代! 慢,效率低下。
垃圾回收器在新生代中建立记忆集 Remembered Set
数据结构,用来避免把整个老年代加进 GC Roots
扫描范围。
所有涉及部分区域收集 Partial GC
行为的垃圾收集器,都面临相同问题,如 G1、ZGC、Shenandoah等。
记忆集
一种抽象数据结构,用于记录从非收集区域指向收集区域的指针集合。
垃圾回收场景中,收集器只需要通过记忆集判断出某一块非手机区域是否存在有指向了收集区域的指针即可,并不需要了解跨代指针的全部细节。(节省空间占用和维护成本)
记录精度:
- 字长精度:每个记录精确到一个机器字长(处理器的寻址位数,如常见的32位或64位),该字包含跨代指针。
- 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
- 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
“卡精度”指的是用一种称为“卡表”的方式来实现记忆集,是最常见的实现形式。
卡表
卡表是记忆集的一种具体实现。
基于卡表(Card Table)的设计,通常将堆空间划分为一系列2次幂大小的卡页(Card Page)。
卡表(Card Table),用于标记卡页的状态,每个卡表项对应一个卡页。
![](https://img.haomeiwen.com/i28686732/619670e92c6bac6b.jpg)
HotSpot JVM的卡页(Card Page)大小为512字节,卡表(Card Table)被实现为一个简单的字节数组,即卡表的每个标记项为1个字节。
当对一个对象引用进行写操作时(对象引用改变),写屏障逻辑将会标记对象所在的卡页为dirty。
HotSpot
默认的卡表标记逻辑:
CARD_TABLE [this address >> 9] = 0;
首先,计算对象引用所在卡页的卡表索引号。将地址右移9位,相当于用地址除以512(2的9次方)。可以这么理解,假设卡表卡页的起始地址为0,那么卡表项0、1、2对应的卡页起始地址分别为0、512、1024(卡表项索引号乘以卡页512字节)。
其次,通过卡表索引号,设置对应卡标识为dirty。
![](https://img.haomeiwen.com/i28686732/1a8fbcb8331261ac.jpg)
一个卡页中可包含多个对象,只要有一个对象的字段存在跨代指针,那其对应的卡表元素标识就变成1,表示该元素变脏,否则为0。
GC
时,只要筛选本收集区的卡表中变脏的元素加入 GC Roots
里。
写屏障
卡表如何维护?何时变脏?谁来把它变脏?
发生引用字段赋值时,HotSpot
通过写屏障Write Barrier
技术维护卡表状态。可以看做成在虚拟机层面对“引用类型字段赋值”这个动作的 AOP
环形 Around
切面。
无条件写屏障带来的性能开销
每次对引用的更新,无论是否更新了老年代对新生代对象的引用,都会进行一次写屏障操作。显然,这会增加一些额外的开销。但是,与YGC时扫描整个老年代相比较,这个开销就低得多了。
高并发下伪共享带来的性能开销
伪共享:CPU 的缓存系统中是以缓存行(Cache Line)为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,就会彼此影响(写回、无效化或者同步)而导致性能降低。
卡表在高并发场景下面临“伪共享”(False Sharing) 问题。
解决方案
不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标识过时才将其标记为变脏,逻辑如下:
if (CARD_TABLE [this address >> 9] != DIRTY) {
CARD_TABLE [this address >> 9] = DIRTY;
}
JDK 7 之后,HotSpot 增加了一个新参数: -XX:+UseCondCardMark
,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,需要根据实际运行情况来进行测试权衡。
网友评论