三色标记法
GC 垃圾回收器其主要的目的是为了实现内存的回收,在这个过程中主要的两个步骤就是:内存标记,内存回收。
三色标记法简介
三色标记法,主要是为了高效的标记可被回收的内存块。
三色标记(Tri-color Marking)作为工具来辅助推导,把遍历对象图过程中遇到的对象,按照“是否访问过”这个条件标记成以下三种颜色:
- 白色:表示对象尚未被垃圾收集器访问过。显然在可达性分析刚刚开始的阶段,所有的对象都是白色的,若在分析结束的阶段,仍然是白色的对象,即代表不可达。
- 黑色:表示对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色的对象代 表已经扫描过,它是安全存活的,如果有其他对象引用指向了黑色对象,无须重新扫描一遍。黑色对 象不可能直接(不经过灰色对象)指向某个白色对象。
-
灰色:表示对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
三色标记过程
image.png
标记过程:
- 在 GC 并发开始的时候,所有的对象均为白色;
- 在将所有的 GC Roots 直接应用的对象标记为灰色集合;
- 如果判断灰色集合中的对象不存在子引用,则将其放入黑色集合,若存在子引用对象,则将其所有的子引用对象存放到灰色集合,当前对象放入灰色集合
- 按照此步骤 3 ,依此类推,直至灰色集合中所有的对象变黑后,本轮标记完成,并且在白色集合内的对象称为不可达对象,即垃圾对象。
- 标记结束后,为白色的对象为 GC Roots 不可达,可以进行垃圾回收。
误标
什么是误标?当下面两个条件同时满足,会产生误标:
- 赋值器插入了一条或者多条黑色对象到白色对象的引用
- 赋值器删除了全部从灰色对象到白色对象的直接引用或者间接引用
误标的解决方案
要解决误标的问题,只需要破坏这两个条件中的任意一种即可,分别有两种解决方案:增量更新(Incremental Update) 和原始快照(Snapshot At The Beginning, STAB)
增量更新
增量更新要破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束之后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。这可以简化理解为,黑色对象一旦新插入了指向白色对象的引用之后,它就变回灰色对象 了。
原始快照 (STAB)
原始快照要破坏的是第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删 除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描 一次。这也可以简化理解为,无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
漏标和多标
对于错标其实细分出来会有两种情况,分别是:漏标和多标
多标-浮动垃圾
如果标记执行到 E 此刻执行了 object.E = null
在这个时候, E/F/G 理论上是可以被回收的。但是由于 E 已经变为了灰色了,那么它就会继续执行下去。最终的结果就是不会将他们标记为垃圾对象,在本轮标记中存活。
在本轮应该被回收的垃圾没有被回收,这部分被称为“浮动垃圾”。浮动垃圾并不会影响程序的正确性,这些“垃圾”只有在下次垃圾回收触发的时候被清理。
还有在,标记过程中产生的新对象,默认被标记为黑色,但是可能在标记过程中变为“垃圾”。这也算是浮动垃圾的一部分。
漏标-读写屏障
写屏障(Store Barrier)
给某个对象的成员变量赋值时,其底层代码大概长这样:
/**
* @param field 某个对象的成员属性
* @param new_value 新值,如:null
*/
void oop_field_store(oop* field, oop new_value) {
*fieild = new_value // 赋值操作
}
所谓写屏障,其实就是在赋值操作前后,加入一些处理的逻辑(类似 AOP 的方式)
void oop_field_store(oop* field, oop new_value) {
pre_write_barrier(field); // 写屏障-写前屏障
*fieild = new_value // 赋值操作
pre_write_barrier(field); // 写屏障-写后屏障
}
写屏障 + SATB
当对象E的成员变量的引用发生变化时(objE.fieldG = null;),我们可以利用写屏障,将E原来成员变量的引用对象G记录下来:
void pre_write_barrier(oop* field) {
oop old_value = *field; // 获取旧值
remark_set.add(old_value); // 记录 原来的引用对象
}
【当原来成员变量的引用发生变化之前,记录下原来的引用对象】
这种做法的思路是:尝试保留开始时的对象图,即原始快照(Snapshot At The Beginning,SATB) ,当某个时刻 的GC Roots确定后,当时的对象图就已经确定了。
比如 当时 D是引用着G的,那后续的标记也应该是按照这个时刻的对象图走(D引用着G)。如果期间发生变化,则可以记录起来,保证标记依然按照原本的视图来。
值得一提的是,扫描所有GC Roots 这个操作(即初始标记)通常是需要STW的,否则有可能永远都扫不完,因为并发期间可能增加新的GC Roots。
SATB破坏了条件一:【灰色对象 断开了 白色对象的引用】,从而保证了不会漏标。
一点小优化:如果不是处于垃圾回收的并发标记阶段,或者已经被标记过了,其实是没必要再记录了,所以可以加个简单的判断:
void pre_write_barrier(oop* field) {
// 处于GC并发标记阶段 且 该对象没有被标记(访问)过
if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
oop old_value = *field; // 获取旧值
remark_set.add(old_value); // 记录 原来的引用对象
}
}
写屏障 + 增量更新
当对象D的成员变量的引用发生变化时(objD.fieldG = G;),我们可以利用写屏障,将D新的成员变量引用对象G记录下来:
void post_write_barrier(oop* field, oop new_value) {
if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
remark_set.add(new_value); // 记录新引用的对象
}
}
【当有新引用插入进来时,记录下新的引用对象】
这种做法的思路是:不要求保留原始快照,而是针对新增的引用,将其记录下来等待遍历,即增量更新(Incremental Update)。
增量更新破坏了条件二:【黑色对象 重新引用了 该白色对象】,从而保证了不会漏标。
读屏障(Load Barrier)
oop oop_field_load(oop* field) {
pre_load_barrier(field); // 读屏障-读取前操作
return *field;
}
读屏障直接针对第一步 var objF = object.fieldG;,
void pre_load_barrier(oop* field, oop old_value) {
if($gc_phase == GC_CONCURRENT_MARK && !isMarkd(field)) {
oop old_value = *field;
remark_set.add(old_value); // 记录读取到的对象
}
}
这种做法是保守的,但也是安全的。因为条件二中【黑色对象 重新引用了 该白色对象】,重新引用的前提是:得获取到该白色对象,此时已经读屏障就发挥作用了。
三色标记法与垃圾回收器
增量更新: CMS
原始快照(STAB): G1,Shenandoah
写屏障与卡表
“写屏障”这个词虽然看起来高深,但是它的含义却相当naive——就是对一个对象引用进行写操作(即引用赋值)之前或之后附加执行的逻辑,相当于为引用赋值挂上的一小段钩子代码。
前文所述“HotSpot通过写屏障来维护卡表”,写屏障就是在将引用赋值写入内存之前,先做一步mark card——即将出现跨代引用的内存块对应的卡页置为dirty,如下图所示。
而之前提到过的JVM参数-XX:+UseCondCardMark
,就是开启有条件的写屏障:在将卡页置为dirty之前,先检查它是否已经为dirty状态,如果已经是了,就不必再执行mark card动作,以避免虚共享。
写屏障除了用于维护卡表之外,在并行GC(如CMS、G1)中的并发标记阶段还有一个更重要的用途。下面以CMS垃圾收集器为例简单解说。
并行GC中并发标记的漏标隐患
我们已经知道,CMS垃圾收集器的执行分为以下6个阶段:
- 初始标记
- 并发标记
- 并发预清理
- 重新标记
- 并发清理
- 并发重置
其中,只有不带“并发”字眼的初始标记、重新标记两个阶段是stop-the-world的,其他4个阶段都是与用户线程(GC界的术语称作mutator)并行的,这符合CMS收集器追求最少STW时间与最高响应度的宗旨。
初始标记和并发标记阶段就是进行可达性分析。CMS的根搜索机制是深度优先的三色标记(tri-color marking)算法,属于基础知识,不再展开讲了。
初始标记阶段会只遍历GC Roots直接可达的那些对象,并压入标记栈(mark stack);并发标记阶段会逐一从标记栈中弹出对象,然后不断递归标记它们直接引用的对象,重复压入-弹出过程,直到标记栈为空。
在并发标记阶段,难点在于:用户线程并未停止,仍然在改变对象的引用关系。这有可能造成原本活动的对象被漏标,进而破坏GC的正确性。示例如下图所示。
(a) 搜索对象A的直接子对象,标记对象B为可达(灰色),并将B压入标记栈。此时A的直接子对象搜索完毕,标记为存活(黑色),将A弹出标记栈;
(b) (c) 与此同时,用户线程改变引用,让A引用C,并移除掉B对C的引用;
(c) 结果:无法再由B标记到C,但也无法由A标记到C(因为A已经出栈)。C虽然仍为活动对象,但被错判为非活动(白色)对象而被回收。显然这是无法容忍的。
事实上,不止是CMS,在其他任何并行的垃圾回收器中,都有对象漏标的隐患。Wilson指出,出现漏标的充要条件是以下两个情况同时发生:
- mutator使黑色对象直接引用了白色对象;
- mutator删除了从灰色对象到白色对象之间的所有引用路径。
强三色不变式与增量更新写屏障
为了解决漏标问题,需要破坏上文所述的两个情况,亦即强制回收器满足如下两个条件之一。
- 强三色不变式:保证永远不会存在黑色对象到白色对象的引用(破坏情况1)。
- 弱三色不变式:所有被黑色对象引用的白色对象都处于灰色保护状态,即直接或间接从灰色对象可达(破坏情况2)。
强/弱三色不变式都可以通过屏障技术来实现,并且在不同环境下有多种不同的屏障技术。CMS收集器采用增量更新(incremental update)写屏障实现强三色不变式,具体来讲,是Dijkstra等人提出的Dijkstra写屏障,其逻辑是:
拦截使黑色对象引用指向白色对象的mutate操作,强制被引用指向的白色对象置为灰色状态,并将其压入标记栈。
Dijkstra写屏障的逻辑用伪码表示如下。
write_barrier(obj, field, newobj) {
if(newobj.mark == FALSE) {
newobj.mark = TRUE
push(newobj, $mark_stack)
}
*field = newobj
}
可见,之所以名为“增量更新”,是指写屏障会持续hook引用的插入和变更。下图示出了加入增量更新写屏障后,并发标记阶段引用发生更改的情况,可见对象C可以安全地存活了。
但是,增量更新写屏障无法探知堆外(如栈上)GC Roots的引用变化,所以CMS收集器在并发标记和预清理完成后,还得做一次重新标记,即再做一次根搜索。
分代收集理论的时候,会存在为了解决对象跨代引用所带来的的问题。垃圾收集器在新生代中建立了名为记忆集的数据结构,用来避免把整个老年代加进GC roots扫描范围。事 实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集(Partial GC)行为的 垃圾收集器,典型的如G1、ZGC和Shenandoah收集器,都会面临相同的问题,因此我们有必要进一步理清记忆集的原理和实现方式,以便学习几款最新的收集器相关知识时能更好地理解。
记忆集
记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
如果我们不考虑 效率和成本的话,最简单的实现可以用非收集区域中所有含跨代引用的对象数组来实现这个数据结构。
Class RememberedSet {
Object[] set[OBJECT_INTERGENERATIONAL_REFERENCE_SIZE];
}
以对象指针来实现记忆集的伪代码
这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂。
而在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。那设计者在实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本.
下面列举了一些可供选择(当然也可以选择这个范围以外的)的记录精度:
- 字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的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的卡页内存块.
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为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) 为单位存储的,
当多线程修改互相独立的变量时,如果这些变量恰好共享同一个缓存行,
就会彼此影响(写回、无效化或者同步)而导致性能降低,这就是伪共享问题。
假设处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行。这64个卡表元素对应的卡页总的内存为32KB(64×512字节),也就是说如果不同线程更新的对象正好处于这32KB的内存区域内,就会导致更新卡表时正好写入同一个缓存行而影响性能。为了避免伪共享问题,一种简单的解决方案是不采用无条件的写屏障,而是先检查卡表标记,只有当该卡表元素未被标记过时才将其标记为变脏,即将卡表更新的逻辑变为以下代码所示:
if (CARD_TABLE [this address >> 9] != 0)
CARD_TABLE [this address >> 9] = 0;
在JDK 7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡
写屏障(write barrier)
关于写屏障,其实要从垃圾回收的三色标记说起,网上关于三色标记的文章很多,具体说明也比较详细,笔者在这里就不在进行详细说明,本文的重点还是放在源码解析与阅读上。
在三色标记算法中,只有同时满足以下两种条件就会产生漏标的问题:
灰色对象断开了白色对象的引用(直接或间接的引用);即灰色对象原来成员变量的引用发生了变化。
黑色对象重新引用了该白色对象;即黑色对象成员变量增加了新的引用。
我们只要破坏其中一个条件就可以解决这个问题,而解决这个问题就需要用到读屏障和写屏障,在jvm的垃圾回收器中,zgc使用的是读屏障,笔者有篇相关博客专门介绍了zgc的技术内幕而我们现在说的g1则是使用的写屏障,准确的说是SATB+写屏障(cms用的是写屏障+增量更新)。
写屏障是在对象属性引用另一个对象的时候才会触发,我们先写一段这样的java代码:
public class Test {
public static void main(String[] args) {
A a = new A();
B b = new B();
//这里我们将A对象的两个属性以不同方式修改引用
//1.public修饰的b属性直接修改
//2.private修饰的c属性用set方法修改
a.b = b;
a.b = null;
a.setC(b);
a.setC(null);
}
}
public class A {
public B b;
private B c;
public void setC(B c) {
this.c = c;
}
}
public class B {
}
因为java是先编译成.class字节码文件,之后由jvm将字节码逐行进行解释执行(当然弱代码执行的次数达到一定阈值,也会将其编译成机器码,本文重点不在这里,笔者就不过多阐述)
我们将刚才写的代码编译成.class文件,用字节码反编译器查看下字节码:
A.class 的set方法
0 aload_0
1 aload_1
//我们看到这里调用了putfield字节码
2 putfield #2 <B.a : Ljava/lang/String;>
5 return
Test.class 的main方法
0 new #2 <A>
3 dup
4 invokespecial #3 <A.<init> : ()V>
7 astore_1
8 new #4 <B>
11 dup
12 invokespecial #5 <B.<init> : ()V>
15 astore_2
//这里是两个入栈操作,后面我们会讲到
16 aload_1
17 aload_2
//我们看到这里调用了putfield字节码
18 putfield #6 <A.b : LB;>
21 aload_1
22 aconst_null
//我们看到这里调用了putfield字节码
23 putfield #6 <A.b : LB;>
26 aload_1
27 aload_2
28 invokevirtual #7 <A.setC : (LB;)V>
31 aload_1
32 aconst_null
33 invokevirtual #7 <A.setC : (LB;)V>
36 return
由此可见putfield字节码命令就是我们这次查看源码的入口啦!
从jdk的源码中找到putfield的字节码命令,在templateTable.cpp中,这个文件是模板解释器,我们简单介绍下,模板解释器是字节码解释器(早期版本jdk的解释器)的优化,早期字节码解释器是逐条翻译,效率低下现在已经不用了,而模板解释器是将每一条字节码与一个模板函数(主要是汇编)关联,用模板函数直接生成机器码从而提高性能。
我们来看看putfield的定义:
void TemplateTable::initialize() {
......
//def方法是用来创建模板的,我们可以简单理解成会将字节码putfield和putfield模板进行关联
//当碰到putfield字节码,就会调用putfield函数模板
def(Bytecodes::_putfield, ubcp|____|clvm|____, vtos, vtos, putfield,f2_byte);
}
我们直接来看putfield函数模板:
//putfield模板
void TemplateTable::putfield(int byte_no) {
//第二个参数是是否是static属性
putfield_or_static(byte_no, false);
}
//我们看到这个方法里就由很多封装的汇编指令了,我们略过一些汇编指令,来看下写屏障的核心逻辑
void TemplateTable::putfield_or_static(int byte_no, bool is_static) {
......
//获取属性的地址(用对象和属性的偏移量封装成address)
const Address field(obj, off, Address::times_1);
......
// 对象类型
{
//这个方法会出栈一个对象引用,并将其放入rax寄存器(内存寄存器)中
//这里解释下,我们的例子中字节码是这样的
//aload_1
//aload_2
//putfield
//局部变量表中编号1是引用a, 编号2是引用b,都是引用类型,存的都是地址
//在执行aload_2前会把aload_1加载的a引用入栈
//在执行putfield前会把aload_2加载的b引用入栈
//所以这里第一次出栈是b的引用
__ pop(atos);
//第二次出栈是a的引用
if (!is_static) pop_and_check_object(obj);
//存储对象的方法,我们进去看下
do_oop_store(_masm, field, rax, _bs->kind(), false);
if (!is_static) www.wanjiashidai.com{
patch_bytecode(Bytecodes::_fast_aputfield, bc, rbx, true, byte_no);
}
//跳到结束
__ jmp(Done);
}
//后面是一些其他基本类型,这里就不进行展开
......
}
//这个方法逻辑还是比较清晰的
//这里注意obj是可以理解为a.b这个引用,后文会统一用obj代替a.b这个引用
//val也是指向B对象的引用
static void do_oop_store(InterpreterMacroAssembler* _masm,
Address obj,
Register val,
BarrierSet::Name barrier,
bool precise) {
//根据屏障类型判断
switch (barrier) {
//g1这里会走这个分支
case BarrierSet::G1SATBCT:
case BarrierSet::G1SATBCTLogging:
{
//这里判断如果obj不是属性,则直接将obj的值传输到rdx寄存器(本案例中不会进入这里)
if (obj.index() == noreg && obj.disp() == 0) {
if (obj.base() != rdx) {
__ movq(rdx, obj.base());
}
} else {
//这里会把传入的a引用地址传输到rdx寄存器
__ leaq(rdx, obj);
}
//写前屏障,主要是SATB处理
//这里的横线__是汇编器的别名,根据不同的系统会调用不同的汇编器
//本文我们只看64位linux的代码
//rdx和rbx都是内存寄存器
//rdx此时已经存储了obj的地址
__ g1_write_barrier_pre(rdx /* obj */,
rbx /* pre_val */,
r15_thread /* thread */,
r8 /* tmp */,
val != noreg /* tosca_live */,
false /* expand_call */);
//如果对象是null则进入这个方法,在a.b上存空值
if (val == noreg) {
__ store_heap_oop_null(Address(rdx, 0));
} else {
......
//把指向b对象的引用存到a.b上
//准确的说是把引用存到本例中A对象的b属性偏移量上
__ store_heap_oop(Address(rdx, 0), val);
//写后屏障
__ g1_write_barrier_post(rdx /* store_adr */,
new_val /* new_val */,
r15_thread /* thread */,
r8 /* tmp */,
rbx /* tmp2 */);
}
}
break;
//非g1会走这个分支,我们就不再展开
case BarrierSet::CardTableModRef:
case BarrierSet::CardTableExtension:
{
if (val == noreg) {
__ store_heap_oop_null(obj);
} else {
__ store_heap_oop(obj, val);
if (!precise || (obj.index() == noreg && obj.disp() == 0)) {
__ store_check(obj.base());
} else {
__ leaq(rdx, obj);
__ store_check(rdx);
}
}
}
break;
......
}
我们看到在引用对象的方法之前和之后都由屏障,类似切面,我们来看看这两个屏障方法:
//找到x86架构的汇编器文件macroAssembler_x86.cpp
//写前屏障方法
void MacroAssembler::g1_write_barrier_pre(Register obj,
Register pre_val,
Register thread,
Register tmp,
bool tosca_live,
bool expand_call) {
//前面很多封装的汇编指令我们忽略,会做一些检测
......
//如果obj不为空,我们就根据obj引用获取其之前引用的对象的地址
if (obj != noreg) {
load_heap_oop(pre_val, Address(obj, 0));
}
//这个命令其实是比较之前的对象是不是空值,如果是空值则不继续执行
cmpptr(pre_val, (int32_t) NULL_WORD);
jcc(Assembler::equal, done);
......
//这里是false
if (expand_call) {
LP64_ONLY( assert(pre_val != c_rarg1, "smashed arg"); )
pass_arg1(this, thread);
pass_arg0(this, pre_val);
MacroAssembler::call_VM_leaf_base(CAST_FROM_FN_PTR(address, SharedRuntime::g1_wb_pre), 2);
} else {
//这里会用汇编指令调用SharedRuntime::g1_wb_pre这个方法
call_VM_leaf(CAST_FROM_FN_PTR(address, SharedRuntime::g1_wb_pre), pre_val, thread);
}
......
}
//真正的写前屏障方法,JRT_LEAF可以理解是一个定义方法的宏
JRT_LEAF(void, SharedRuntime::g1_wb_pre(oopDesc* orig, JavaThread *thread))
if (orig == NULL) {
assert(false, "should be optimized out");
return;
}
//将对象的指针加入satb标记队列
thread->satb_mark_queue().enqueue(orig);
JRT_END
//写后屏障方法
void MacroAssembler::g1_write_barrier_post(Register store_addr,
Register new_val,
Register thread,
Register tmp,
Register tmp2) {
#ifdef _LP64
assert(thread == r15_thread, "must be");
#endif // _LP64
Address queue_index(thread, in_bytes(JavaThread::dirty_card_queue_offset() +
PtrQueue::byte_offset_of_index()));
Address buffer(thread, in_bytes(JavaThread::dirty_card_queue_offset() +
PtrQueue::byte_offset_of_buf()));
BarrierSet* bs = Universe::heap()->barrier_set();
CardTableModRefBS* ct = (CardTableModRefBS*)bs;
assert(sizeof(*ct->byte_map_base) == sizeof(jbyte), "adjust this code");
Label done;
Label runtime;
//下面几条命令涉及到汇编逻辑比较,有兴趣的读者可以自行查阅,笔者这里就不进行展开
//判断是否跨regions
//先将引用的地址放到r8寄存器(tmp参数上个方法传入的)中
//再将新对象的地址和r8中的地址进行异或运算,结果存入r8中
//之后将r8的结果逻辑右移LogOfHRGrainBytes位(region大小的log指数+1),并将移出的最后一位加入cf指示器
//最后判断cf中是0还是1即可判断store_addr与new_val两个地址之间是否相差一个region大小
//0即不相差,1即相差
movptr(tmp, store_addr);
xorptr(tmp, new_val);
shrptr(tmp, HeapRegion::LogOfHRGrainBytes);
jcc(Assembler::equal, done);
//判断是否为空
cmpptr(new_val, (int32_t) NULL_WORD);
jcc(Assembler::equal, done);
const Register card_addr = tmp;
const Register cardtable = tmp2;
//将存储的地址赋值给card_addr变量
movptr(card_addr, store_addr);
//将地址逻辑右移card_shift个位,可以理解为计算出其所属card的index
shrptr(card_addr, CardTableModRefBS::card_shift);
//加载卡表数组的基址的偏移量到cardtable
movptr(cardtable, (intptr_t)ct->byte_map_base);
//加上卡表数组的基址偏移量即可算出card在card数组中的有效地址
addptr(card_addr, cardtable);
//判断是否是young区的卡,如果是则不继续执行
cmpb(Address(card_addr, 0), (int)G1SATBCardTableModRefBS::g1_young_card_val());
jcc(Assembler::equal, done);
//判断是否已经是脏卡,如果是则不继续执行
cmpb(Address(card_addr, 0), (int)CardTableModRefBS::dirty_card_val());
jcc(Assembler::equal, done);
//将card赋值脏卡
movb(Address(card_addr, 0), (int)CardTableModRefBS::dirty_card_val());
......
//执行写后屏障方法
call_VM_leaf(CAST_FROM_FN_PTR(address, SharedRuntime::g1_wb_post), card_addr, thread);
......
}
//真正的写后屏障
JRT_LEAF(void, SharedRuntime::g1_wb_post(void* card_addr, JavaThread* thread))
//将card加入dcq队列
thread->dirty_card_queue().enqueue(card_addr);
JRT_END
这里用到的汇编命令比较多,笔者将几步关键步骤进行了标注,如果有兴趣,读者可以自行了解下相关命令,这里就不进行过多讲解。
到这里我们都知道g1修改对象属性引用时会使用的两种写屏障,并且为了提高效率都是先将要处理的数据放到队列中:
1.写前屏障——处理SATB(本质是快照,用于解决并发标记时修改引用可能会造成漏标的问题),将修改前引用的对象的地址加入satb队列,待到gc并发标记的时候处理。(关于写前屏障本文不重点介绍,以后笔者会介绍GC相关的文章中再介绍)
2.写后屏障——找到对应的card标记为dirty_card,加入dirty_card队列
本文我们重点关注下写后屏障,通过上面的源码分析,我们已经看到被修改过引用所处的card都已经被标记为dirty_card,即将卡表数组(本质是字节数组,元素可以理解为是一个标志)中对对应元素进行修改为dirty_card。说到card(卡页),dirty_card(脏卡),我们不得不先从他们的起源card_table(卡表)说起。
卡表(card_table)
在写后屏障的源码中有一段关于card计算的汇编代码,可能比较难以理解,笔者在这里画个图来方便解释,通过这张图我们也可以理解卡表,卡页,脏卡的概念:
结合图和我们之前看的写屏障的源码,我们概括下卡表,卡页,脏卡还有写屏障的关系:
卡表(card_table)全局只有一个可以理解为是一个bitmap,并且其中每个元素即是卡页(card)与堆中的512字节内存相互映射,当这512个字节中的引用发生修改时,写屏障就会把这个卡页标记为脏卡(dirty_card)。
接下来我们看看卡表创建的源码:
//卡表相关类的初始化列表
CardTableModRefBS::CardTableModRefBS(MemRegion whole_heap,
int max_covered_regions):
ModRefBarrierSet(max_covered_regions),
_whole_heap(whole_heap),
_guard_index(cards_required(whole_heap.word_size()) - 1),
_last_valid_index(_guard_index - 1),
_page_size(os::vm_page_size()),
_byte_map_size(compute_byte_map_size())
{
.....
//申请一段内存空间,大小为_byte_map_size
//且没有传入映射内存映射的基础地址,即从随机地址映射
//底层会调内核mmap(),这里就不进行展开
ReservedSpace heap_rs(_byte_map_size, rs_align, false);
MemTracker::record_virtual_memory_type((address)heap_rs.base(), mtGC);
...
//赋值给卡表
_byte_map = (jbyte*) heap_rs.base();
//计算偏移量
byte_map_base = _byte_map - (uintptr_t(low_bound) >> card_shift);
.....
}
网上许多文章会说卡表是在堆中的,然而从源码中我们可以看到严格来说并不是属于java_heap管理的,而是一段额外的数组进行管理。
我们再看看java_heap内存申请的代码:
//申请堆内存的方法,会在申请card_table之前申请
ReservedSpace Universe::reserve_heap(size_t heap_size, size_t alignment) {
......
//计算堆的地址
char* addr = Universe::preferred_heap_base(total_reserved, alignment, Universe::UnscaledNarrowOop);
//total_reserved是最大堆内存
//申请内存,这里会传入地址从特定地址开始申请,默认从0开始申请最大堆内存
ReservedHeapSpace total_rs(total_reserved, alignment, use_large_pages, addr);
.....
return total_rs;
}
//进入下面的初始化列表方法
ReservedHeapSpace::ReservedHeapSpace(size_t size, size_t alignment,
bool large, char* requested_address) :
//ReservedHeapSpace是ReservedSpace的子类底层还是会调用mmap()
ReservedSpace(size, alignment, large,
requested_address,
(UseCompressedOops && (Universe::narrow_oop_base() != NULL) &&
Universe::narrow_oop_use_implicit_null_checks()) ?
lcm(os::vm_page_size(), alignment) : 0) {
if (base() > 0) {
//注意这里标记的是mtJavaHeap,即为javaHeap申请的内存
MemTracker::record_virtual_memory_type((address)base(), mtJavaHeap);
}
protect_noaccess_prefix(size);
}
由于card_table在heap之后才会申请创建,且是随机映射,而heap是根据对应地址去映射,所以card_table并不是使用的heap空间。