垃圾收集器与内存分配策略(17)


当用户线程执行到安全区域里面的代码时,首先会标识自己已经进入了安全区域,那样当这段时间里虚拟机要发起垃圾收集时就不必去管这些已声明自己在安全区域内的线程了 。当线程要离开安全区域时,它要检查虚拟机是否已经完成了根节点枚举(或者垃圾收集过程中其他需要暂停用户线程的阶段),如果完成了,那线程就当作没事发生过,继续执行;否则它就必须一直等待,直到收到可以离开安全区域的信号为止 。
记忆集与卡表
讲解分代收集理论的时候,提到了为解决对象跨代引用所带来的问题,垃圾收集器在新生代中建立了名为记忆集( Set)的数据结构,用以避免把整个老年代加进GC Roots扫描范围 。事实上并不只是新生代、老年代之间才有跨代引用的问题,所有涉及部分区域收集( GC)行为的垃圾收集器,典型的如G1、ZGC,都会面临相同的问题,因此我们有必要进一步理清记忆集的原理和实现方式,记忆集是一种用于记录从非收集区域指向收集区域的指针集合的抽象数据结构 。
这种记录全部含跨代引用对象的实现方案,无论是空间占用还是维护成本都相当高昂 。而在垃圾收集的场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节 。那设计者在实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择(当然也可以选择这个范围以外的)的记录精度:
·字长精度:每个记录精确到一个机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针 。
·对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针 。
·卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针 。
其中,第三种“卡精度”所指的是用一种称为“卡表”(Card Table)的方式去实现记忆集,这也是目前最常用的一种记忆集实现形式,卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等 。
卡表最简单的形式可以只是一个字节数组,而虚拟机确实也是这样做的 。
字节数组的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作“卡页”(Card Page) 。一般来说,卡页大小都是以2的N次幂的字节数 。一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0 。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描 。
解决卡表元素如何维护的问题,例如它们何时变脏、谁来把它们变脏?
卡表元素何时变脏的答案是很明确的——有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻 。但问题是如何变脏,即如何在对象赋值的那一刻去更新维护卡表呢?
在虚拟机里是通过写屏障(Write )技术维护卡表状态的 。先请读者注意将这里提到的“写屏障”,以及后面在低延迟收集器中会提到的“读屏障”与解决并发乱序执行问题中的“内存屏障”区分开来,避免混淆 。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形()通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内 。在赋值前的部分的写屏障叫作写前屏障(Pre-Write ),在赋值后的则叫作写后屏障(Post-Write ) 。应用写屏障后,虚拟机就会为所有赋值操作生成相应的指令,一旦收集器在写屏障中增加了更新卡表操作,无论更新的是不是老年代对新生代对象的引用,每次只要对引用进行更新,就会产生额外的开销,不过这个开销与Minor GC时扫描整个老年代的代价相比还是低得多的 。