一、根节点枚举
固定可作为GC Roots的节点主要在全局性的引用(如常量或类静态属性)与执行上下文(如栈帧中的本地变量表)中,尽管目标明确,但查找要做到高效很难。现在java应用越来越庞大,光方法区的大小就常有数百上千兆,里面的类、常量等更是恒河沙数,逐个检查以这里为起源的引用肯定得消耗不少时间。
同时迄今为止,所有收集器在根节点枚举这一步时都是必须暂停用户线程的。根节点枚举必须在一个保障一致性的快照中进行。一致性的意思是整个枚举期间执行子系统看起来就像被冻结在某一个时间点上,不会出现分析过程中,根节点集合的对象引用关系还在不断的变化的情况,若这点不能满足,分析结果准确性也就无法保证。
由于目前主流java虚拟机使用的都是准确式垃圾收集(准确式内存管理:虚拟机可以知道内存中某个位置的数据具体是什么类型),所以当用户线程停顿时,不需要一个不漏的检查完所有执行上下文和全局的引用位置,虚拟机应当是有办法直接得到哪些地方存放着对象的引用。在HotSpot的解决方案里,是使用一组称为OopMap的数据结构来达到这个目的。
类加载完成时,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置时引用。这样收集器扫描时就可以直接知道这些信息,并不需要真正一个不漏从方法区等GC Roots考试查找。
下面的代码是 HotSpot 虚拟机客户端模式下生成的一段 String::hashCode() 方法的本地代码,可以看到在0x026eb7a9 处的 call 指令有 OopMap 记录,它指明了 EBX 寄存器和栈中偏移量为 16 的内存区域 中各有一个普通对象指针(Ordinary Object Pointer , OOP )的引用,有效范围为从 call 指令开始直到0x026eb730(指令流的起始位置) +142 ( OopMap 记录的偏移量) =0x026eb7be ,即 hlt 指令为止。
二、安全点
HotSpot没有为每一条指令都生成OopMap,上面提到的“类加载完成时,HotSpot就会把对象内什么偏移量上是什么类型的数据计算出来,在即时编译过程中,也会在特定的位置记录下栈里和寄存器里哪些位置时引用”中提到的特定的位置记录了这些信息,这些位置被称为安全点(Safepoint)。因此,用户程序执行时并非在任意位置都能停下来进行垃圾收集,强制要求必须执行到安全点后才能暂停。所以,安全点的选定既不能太少以至于让收集器等待时间过长,也不能太频繁以至于过分增大运行时的内存负荷。
安全点位置的选取标准:是否具有让程序长时间执行的特征。因为每条指令执行的时间都非常短暂,程序不太可能因为指令流长度太长这样的原因而长时间执行,长时间执行的最明显特征就是指令序列的复用,例如方法调用、循环跳转、异常跳转等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点。
如何让所有线程都跑到最近的安全点停顿下来:
抢先式中断:
抢先式中断不需要线程的执行代码主动去配合。在垃圾收集发生时,系统首先把所有用户线程全部中断,如果发现有用户线程中断的地方不在安全点上,就恢复这条线程执行,让它一会儿再重新中断,直到跑到安全点上。现在几乎没有虚拟机实现采用抢先式中断来暂停线程响应GC事件。主动式中断:
设置一个标志位,线程执行时会不停的主动轮询这个标志,一旦发现中断标志位为真自己在最近的安全点上主动中断挂起。
轮询标志的地方和安全点是重合的,另外还要加上所有创建对象和其他需要在Java堆上分配内存的地方,这是为了检查是否即将要发生垃圾收集,避免没有足够内存分配新对象。
由于轮询操作在代码中会频繁出现,这要求它必须足够高效。HotSpot 使用内存保护陷阱的方式,把轮询操作精简至只有一条汇编指令的程度。下面代码清单中的 test 指令就是 HotSpot 生成的轮询指令,当需要暂停用户线程时,虚拟机把0x160100 的内存页设置为不可读,那线程执行到 test 指令时就会产生一个自陷异常信号,然后在预先注册的异常处理器中挂起线程实现等待,这样仅通过一条汇编指令便完成安全点轮询和触发线程中断了。
三、安全区域
程序“不执行”时(没有分配处理器时间,如sleep和blocked状态),线程无法响应虚拟机的中断请求,不能走到安全点主动挂起,虚拟机也不可能等线程重新被激活。所以引入安全区域。
安全区域是指能够确保在某一段代码片段之中,引用关系不会发生改变。因此,在这个区域中任意地方开始垃圾收集都是安全的。可以看作扩展延伸了的安全点。
进入安全区域的代码,会标识自己已经进入安全区域,虚拟机发起垃圾收集时不用去管这些线程。当线程要离开安全区域时,他要检查虚拟机是否已经完成了根节点枚举(或垃圾收集过程中其他需要暂停用户线程的阶段),完成,那线程就当作没事发生,继续执行。否则一直等待直到收到可以离开安全区域的信号。
四、记忆集与卡表
在分代收集中,为了解决对象跨代引用所带来的问题,在新生代中建立记忆集的数据结构,用以避免把整个老年代加进GC Roots扫描范围。事实上所有部分区域收集行为的垃圾收集器都会有跨代引用问题。
记忆集是一种记录从非收集区域指向收集区域的指针集合的抽象数据结构。不考虑效率和成本,最简单的实现用非收集区域中所有含跨代引用的对象数组来实现这个数据结构。
在垃圾收集场景中,收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向收集区域的指针就行,不需要了解跨代指针的全部细节。
设计者在实现记忆集的时候,便可以选择更为粗犷的记录粒度来节省记忆集的存储和维护成本,下面列举了一些可供选择(当然也可以选择这个范围以外的)的记录精度:
- 字节精度:每个记录精确到机器字长(就是处理器的寻址位数,如常见的32位或64位,这个精度决定了机器访问物理内存地址的指针长度),该字包含跨代指针。
- 对象精度:每个记录精确到一个对象,该对象里有字段含有跨代指针。
- 卡精度:每个记录精确到一块内存区域,该区域内有对象含有跨代指针。(用一种称为“卡表”的方式去实现记忆集,是常用记忆集实现方式之一。)
记忆集其实是一种“抽象 ” 的数据结构,抽象的意思是只定义了记忆集的行为意图,并没有定义其行为的具体实现。卡表就是记忆集的一种具体实现,它定义了记忆集的记录精度、与堆内存的映射关系等。
卡表最简单的形式可以只是一个字节数组,下面这行代码是HotSpot默认的卡表标记逻辑:
CARD_TABLE[this address >> 9] = 0;
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏( Dirty ),没有则标识为 0 。在垃圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots 中一并扫描。
五、写屏障
何时变脏:有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。
如何变脏,即如何更新维护卡表:
- 若是解释执行的字节码,虚拟机负责每条字节码指令的执行,有充分的介入空间。
- 若是编译执行,经过即时编译后的代码已经是纯粹的机器指令流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。
HotSpot通过写屏障(Writer Barrier)技术维护卡表。写屏障可以看作在虚拟机层面对“引用类型字段赋值”这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值前后都在写屏障的覆盖范围内。在赋值前的部分的写屏障叫做写前屏障(Pre-Write Barrier),赋值后则叫写后屏障(Post-Write Barrier)。
G1之前只用到写后屏障。
写后屏障更新卡表,如下图。
除了写屏障开销外(相较于扫描整个老年代的代价低),卡表在高并发下面临着“伪共享”问题。中央处理器的缓存系统是以缓存行为单位存储,多线程修改独立变量,这些变量恰好共享同一个缓存行,就会彼此影响(写回,无效化或同步)而导致性能降低。
解决伪共享办法:不采用无条件的写屏障,先检查卡表标记,只有卡表元素未被标记过时才将其标记变脏,即卡表更新逻辑变为:
if(CARD_TABLE[this address >> 9] != 0)
CARD_TABLE[this address >> 9] = 0;
在JDK 7 之后, HotSpot 虚拟机增加了一个新的参数 -XX : +UseCondCardMark ,用来决定是否开启卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题,两者各有性能损耗,是否打开要根据应用实际运行情况来进行测试权衡。
六、并发的可达性分析
在根节点枚举这个步骤中,GC ROOTS相比起整个java堆中全部的对象已经减少了很多,且在各种优化技巧(如OopMap)的加持下,它带来的停顿已经非常短暂且相对固定。可从GC Roots继续往下遍历对象图,这一步骤的停顿时间必定与java堆容量成正比关系:堆越大,存储的对象越多,对象图结构越复杂,要标记更多对象而产生的停顿时间的更长。
首先了解一下为什么在一个能保障一致性的快照下才能进行对象图的遍历?我们使用三色标记辅助推导:
- 白色:对象尚未被垃圾收集器访问到。在可达性分析刚刚开始阶段,所有阶段对象都是白色,分析结束阶段,仍为白色,即代表不可达。
- 黑色:对象已经被垃圾收集器访问过,且这个对象的所有引用都已经扫描过。黑色对象代表已经扫描过,它是安全存活的,如有其他对象引用指向黑色对象,无须重新扫描。黑色对象不可能直接(不经过灰色对象)指向某个白色对象。
- 灰色:对象已经被垃圾收集器访问过,但这个对象上至少存在一个引用还没有被扫描过。
可达性分析的扫描过程,可以看作对象图上一股以灰色为波峰的波纹从黑向白推进的过程。用户线程冻结不会有任何问题。但用户线程并发,收集器在标记时,用户线程在修改引用,会导致两种结果:一种是把原本消亡的对象错误标记为存活,即产生浮动垃圾,下次收集即可,可以容忍。另一种是把原本存活的对象标记为已消亡,这就很致命了,程序肯定会因此发生错误,下面演示这样的致命错误是怎样产生的。
“对象消失”问题:原本应该是黑色的对象被误标为白色。
“对象消失”问题产生的条件(需要同时满足):
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用
解决“对象消失”问题:增量更新 和 原始快照。
- 增量更新:破坏第一个条件,当黑色对象插入新的指向白色对象的引用关系时,就将这个新插入的引用记录下来,等并发扫描结束后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。简化理解为:黑色对象一旦新插入了指向白色对象的引用之后,他就变回灰色对象了。
- 原始快照:破坏第二个条件,当灰色对象要删除指向白色对象的引用关系时,就将这个要删除的引用记录下来,在并发扫描结束之后,再将这些记录过的引用关系中的灰色对象为根,重新扫描一次。简化理解为:无论引用关系删除与否,都会按照刚刚开始扫描那一刻的对象图快照来进行搜索。
在 HotSpot虚拟机中,增量更新和原始快照这两种解决方案都有实际应用,譬如,CMS 是基于增量更新来做并发标记的,G1 、 Shenandoah 则是用原始快照来实现。