HotSpot的细节实现 (读书笔记)
1. 根节点(GC Roots)枚举
枚举出所有GC Roots根节点
可以作为GC Roots的节点主要有:全局性的引用与上下文执行,详见JVM垃圾回收的可达性分析小节。目前,所有收集器进行根节点(GC Roots)枚举必须STW。现在可达性分析算法耗时查找引用链可以与用户线程一起并发。
目前的Java虚拟机使用准确式垃圾回收集,所以当用户线程1后,并不需要检查完所有上下文和全局引用位置,虚拟机应当直接得到哪些地方存放对象引用。HotSpot使用OopMap的数据结构。(并不需要真的全部从方法区等GC Roots开始查找)。
2. 安全点(SafePoint)
在"特定的位置"记录修改引用关系的指令
在程序运行期间很多指令都是有可能修改引用关系的,即要修改OopMap,如果对每一条指令都生成OopMap,将会需要大量的空间存储数据结构。因此设置了SafePoint去强制用户程序执行到SafePoint暂停开始处理OopMap 并GC。
安全点位置的选取基本上是以“是否具有让程序长时间执行的特征”为标准进行选定的。(“长时间执行”的最明显特征就是指令序列的复用,与指令长短无关。例如方法调用、循环跳转、异常跳转 等都属于指令序列复用,所以只有具有这些功能的指令才会产生安全点)。
需要考虑所有线程都在安全点停顿:抢先式中断、主动式中断。
- 抢先式中断:GC时先中断所有用户线程,如果发现有线程没有到达安全点,恢复执行,直到所有线程到安全点(目前几乎不用)
- 主动式中断:GC时,不直接对线程操作,记录一个标志,各线程不断轮询这个标志(标志与安全点重合),一旦发现中断标志为真时,在自己最近的中断点主动中断挂起。
HotSpot为了提高轮询的效率,使用内存保护陷阱方式,将轮询操作精简至汇编指令。
内存保护陷阱:需要暂停用户线程,把xx内存页设置不可读,执行test指令会产生一个自陷异常信号,被预先注册的异常挂起等待。
3. 安全区域
指能够确保在某一段代码片段之中,引用关系不会发生变化,因此,在这个区域中任意地方开始垃圾收集都是安全的。我们也可以把安全区域看作被扩展拉伸了的安全点。
长时间不执行的某段代码,该线程无法响应虚拟机的中断请求,必须引入安全区域(Safe Region)解决。
当用户线程执行到安全区域里面的代码时,首先标识自己进入安全区域。垃圾收集时,不需要处理这些安全区域的线程。当线程离开安全区域,要检查虚拟机是否已经完成根节点枚举。如果完成,线程正常继续执行,否则一直等待,直到收到可以离开安全区域的信号。
4. 记忆集与卡表
4.1 记忆集
记忆集:记录从非收集区域指向收集区域的指针集合的抽象数据结构。
记忆集是为了解决对象跨代引用的问题,新生代建立的数据结构,避免把老年代加入GC Roots扫描范围。如果不考虑效率和成本,最简单的实现可以用非收集区域中所有跨代引用的对象数组实现。垃圾收集器不需要这么完整的记录精度。
- 字长精度:每个记录精确到一个机器字长(处理器寻址位,如32位、64位),该字包含跨代指针。
- 对象精度:每个记录精确到一个对象,该对象字段含有一个跨代指针。
- 卡精度:每个记录精确到一块内存区域,该区域含有跨代指针。
4.2 卡表
上面的卡精度就是用卡表实现的记忆集,卡表就是记忆集的一种具体体现,它定义了记忆集的记录精度、与堆内存映射的关系(卡表与记忆集类比HashMap与Map的关系记忆)
卡表的最简单的形式可以只是一个字节数组,HotSpot默认卡表逻辑
1 | CARD_TABLE [this address >> 9] = 0; |
字节数组CARD_TABLE的每一个元素都对应着其表示区域中一块大小的内存块,这个内存块被称作"卡页"(Card Page)。一般卡页都是字节数。如上代码,HotSpot虚拟机使用的卡页是,即512字节(地址右移9位,地址除以)。
如果卡表标识内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了 地址范围为0x0000~0x01FF、0x0200~0x03FF、0x0400~0x05FF的卡页内存块。
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代 指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃 圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它们加入GC Roots中一并扫描。
5. 写屏障
使用记忆集缩减GC Roots扫描范围后,还需要解决卡表的元素维护问题,例如它们何时变脏、谁把它们变脏。
- 何时变脏:有其他分代区域对象引用本区域对象时,卡表变脏。
- 谁操作:写屏障(Writte Barrier)。
写屏障:看作虚拟机层面对"引用类型字段赋值"这个动作的AOP切面,在引用对象赋值时会产生一个环形通知,供程序执行额外动作。直至G1收集器出现之前,其他收集器都只用到了写后屏障。
开启写屏障会导致每次引用更新产生额的开销。
卡表在高并发场景下面临"伪共享"问题:现代处理器的缓存系统是以缓存行为单位存储的,当多线程修改互相独立的变量时,如果这些变量恰好位于同一个缓存行,会彼此影响(写回、无效化、同步),导致性能降低。
如果处理器的缓存行大小为64字节,由于一个卡表元素占1个字节,64个卡表元素将共享同一个缓存行,这64个卡表元素对应的卡页总内存为32KB(64*512字节)。如果不同线程更新的对象正好处于32KB的内存区域,导致更新卡表时正好写入同一个缓存而影响性能。
避免伪共享问题:先检查卡表标记,只有当卡表元素未被标记时才将其变脏。在JDK 7之后,HotSpot虚拟机增加了一个新的参数-XX:+UseCondCardMark,用来决定是否开启 卡表更新的条件判断。开启会增加一次额外判断的开销,但能够避免伪共享问题。
6. 并发的可达性分析
可达性分析理论上要求全部过程都基于一个能保证一致性的快照中才能进行分析。意味着要全程STW。
在根节点枚举中,由于OopsMap的存在,停顿相对固定,不与堆相关。但是GC Roots往下遍历耗时依旧与Java堆成正比例关系。
为生么必须保证一致快照才能遍历?
6.1 三色标记:
- 白色:对象尚未被垃圾收集器访问过。如果在分析结束阶段,仍是白色,则不可达。
- 黑色:对象已经被垃圾收集器访问过,且该对象的所有引用都访问过,黑色对象不可能直接指向白色对象。
- 灰色:对象已经被垃圾收集器访问过,但对象上至少还有一个引用每一被扫描过。
5.2 三色标记的过程:
首先GCRots被染成灰色,从GCRoots向下遍历。将GCRoots指向白色的对象在染成灰色,在该GCRoots遍历完直接指向的对象后,该GCRoots染成黑色。再从被染灰色的对象向下遍历,重复上面的过程,遍历完成后。剩余的白色对象被判断为垃圾。
5.3 并发三色标记的问题:
如果用户收集线程与GC线程并发工作, 可能会出现两种问题。
- 将原本消亡的对象标记为存活:
GC线程将某对象标记为黑色,这时用户线程将该对象的引用去除,导致该对象实际应该消亡,但是被标记为黑色,导致存活。(可以忍受,称为浮动垃圾)。(独立的非GCRoots的黑色对象) - 将原本存活的对象标记为垃圾:
- 赋值器插入了一条或多条从黑色对象到白色对象的新引用。(添加黑色指向白色)
- 赋值器删除了全部从灰色对象到该白色对象的直接或间接引用。(删除灰色指向白色的链接)
由于GC不会再从黑色对象开始遍历,所以该白色对象不会再被标记。标记完成后,该对象被误判为垃圾。(不可忍受),需要两个条件同时满足。
5.4 三色并发标记问题的解决
由于必须同时满足两个条件,只需破坏其中一个即可。
增量更新与原始快照。
5.4.1 增量更新
增量更新破坏的是第一个条件,当黑色对象插入新的指向白色对象的引用关系时,将这个新的插入记录下来,等并发扫描结束后,再将这些记录过的引用关系中的黑色对象为根,重新扫描一次。(一旦黑色对象指向指向白色对象,将黑色对象转变为灰色)。
5.4.2 原始快照
原始快照破坏第二个条件,当灰色对象删除指向白色对象的引用时,将这个要删除的引用记录下来,在并发扫描结束后,将这些记录过的灰色对象为根,从新扫描一次。(无论引用关系删除与否,按刚开始扫描的那一刻的对象快照搜索)。
5.5 三色标记补充
以上对引用关系的插入或删除,都是通过写屏障实现的。例如CMS是基于增量更新做并发标记,G1、Shenandoah使用原始快照。
参考
- 《深入理解Java虚拟机 JVM高级特性与最佳实践》第二部分第三章