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