《深入理解Java虚拟机》ch3.4 HotSpot的算法细节实现
HotSpot的算法细节实现
HotSpot虚拟机通过 根节点枚举算法 判断需要回收的对象;运用 安全点 和 安全区域 解决了多线程查找根节点的问题;其中跨代引用使用 记忆集 中的 卡表 进行维护,而卡表的维护由 写屏障 解决;采用 增量更新 或者 原始快照 方法解决了并发中可达性分析算法遇到的问题。
根节点枚举算法
-
必须暂停用户线程 (STW,stop the world)
根节点的枚举必须在保证一致性的快照中执行 —— 枚举期间根节点集合的对象引用关系保持不变。
-
采用 OopMap (普通对象指针map) 来快速获取 GC Roots
在HotSpot中,对象的类型信息里有记录自己的 OopMap,记录了在该类型的对象内什么偏移量上是什么类型的数据。
OopMap 在不被使用时会被 JVM 压缩,只有在 GC 时才会按需解压出来。
-
OopMap 的前提是虚拟机实现的是 准确式GC,有以下几种GC:
-
保守式GC:不知道内存中数据的准确类型,在GC时从已知位置开始扫描,使用上下边界检查、对齐检查等方法来判断对象是否可以作为 GC Roots。
保守式GC的两个问题:
- 由于不能准确判断类型,所以某些对象即使已经不可达但是保守式GC任然不会将其回收,这不会造成程序错误,但是会造成内存的浪费。
- 在处理引用类型时,需要中间层 (句柄) 来支持对象的移动。如果JVM需要支持反射功能的话,保守式GC是不可取的。
-
半保守式GC:将类的信息存放在对象上,因此又被称为根上保守,支持部分对象的移动。
-
准确式GC:在外部存储下类的信息,放在映射表中,虚拟机能够直到内存中数据的准确类型,支持对象的移动。在HotSpot中被称为 OopMap。
-
-
在类加载动作完成的时候,就可以将对象信息 (栈和寄存器中哪些位置是引用) 加入到 OopMap 中。收集器在扫描时直接访问 OopMap 从而获得 GC Roots,不需要遍历整个栈、方法区等 (这里面有很多对象不是引用)。
-
调用 OopMap 的方法:
- 解释式:每次遍历 OopMap —— HotSpot 虚拟机采用解释式方法来访问 OopMap。
- 编译式:为 OopMap 生成单独的访问代码,访问时直接调用代码。
-
JNI 方法没有 OopMap。在调用 JNI 方法时,JVM 为其包装了一层句柄,每次访问都有句柄的拆箱、装箱过程,因此 JNI 方法效率较低。
-
安全点
在安全点对 OopMap 进行维护,即在安全点处才能发起 GC。
-
更新 OopMap 的问题:
-
很多指令可以改变引用关系,如果在每一条指令上操作 OopMap 的话,效率会非常低下。因此HotSpot选择在一些特定位置停下来更新OopMap:
- 循环的末尾
- 方法临返回前
- 调用方法的call指令后
- 可能抛异常的位置
因此一个方法会有多个安全点,对应的也有多个 OopMap 去对应不同区域的内存情况。
-
-
如果采用安全点,需要确保多线程并发时所有线程都处于安全点
- 抢先式中断 (Premmptive Suspension):将所有线程中断,如果有线程不在其安全点,那么恢复此现成的运行直到最近的一个安全点。
- 主动式终端 (Voluntary Suspension):设置一个所有线程共有的标志位,线程运行时不断地检查标志位,当设置标志位为真时所有线程主动地在最近的安全点挂起。
- 由于要不断地访问这个标志位,所以轮询操作精简为一条汇编指令以提高性能 —— 当需要暂停线程时,将作为标志位的内存页设置为不可读,线程抛出异常后经由异常处理器处理。
安全区域
安全区域是安全点的一个超集。在安全区域内,引用关系不会发生变化。
- 安全区域的意义:多线程并发时,并非有所有的线程都正在执行,有些线程会处于 Sleep 或者 Blocked 状态,从而无法响应虚拟机的中断请求在安全点挂起。
- 安全区域的使用方法:
- 线程执行到安全区域时,标识自己进入了安全区域,JVM 发起 GC 时就不会对这些线程进行操作。
- 线程离开安全区域时,如果 JVM 还未完成 GC,那么等待 JVM 完成 GC。
记忆集与卡表
记忆集:用于记录从非收集区域指向收集区域的指针集合的抽象数据结构。
- 记忆集的访问精度:字长精度、对象精度、卡精度。
卡表:实现记忆集的数据结构,HotSpot 中使用字节数组作为卡表。
CARD_TABLE [this address >> 9] = 1;
- HotSpot 虚拟机的卡表中内存区域大小 (卡页) 是 512 字节 (),只要这个内存区域内有跨代引用,那么这个卡页被标志为1。
写屏障
在发生赋值操作时就可能需要对卡表进行维护,为了同时支持字节码指令和机器码指令,维护卡表的动作下降到机器码层面放到了每个赋值操作中。
void opp_field_store(opp* field, oop new_value) {
*field = new_value; // 引用字段赋值操作
post_write_barrier(field, new_value); // 写后屏障,在这里实现对卡表状态的更新
}
- 写屏障可以看作为虚拟机层面对“引用字段类型赋值” 这个动作的 AOP 切面 (我个人感觉和 c++ 的运算符重载差不多?等以后学到了再来更新吧)
- 写屏障会带来一定的开销,不过仍然优于扫描整个老年代。由于CPU缓存中缓存行的存在,高并发会卡表会遇到伪共享问题,如果卡表某个卡页已经为 true,那不就不需要更新了,减少一次更新操作。
if(CARD_TABLE [this address >> 9] != 1)
CARD_TABLE [this address >> 9] = 1;
并发的可达性分析
和根节点的枚举算法一样,可达性分析原则上也需要维护一致性。
不过由于对象实在是太多了,如果中断用户线程进行可达性分析是无法忍受的。
-
三色标记法 (Tri-color Marking) (依我看,和 tarjan 求最大连通分量的思路很相似)
- 白色:尚未被垃圾收集器访问。开始前大家都是白色,结束后认为白色的就是不可达的对象。
- 黑色:自己以及这个对象的所有引用都被垃圾收集器访问过了。
- 灰色:被垃圾收集器访问过,但是还有一个或者多个引用未被垃圾收集访问。
-
在并发条件下进行三色标记时,会存在两种典型的问题:
-
浮动垃圾(多标):将应该被清除的对象,误标记为存活对象。会造成浮动垃圾,但是问题不大,可以在下个GC时被回收;
-
对象消失(漏标):将应该存活的对象,误标记为需要清理的对象,会造成程序运行错误!
只有在同时满足以下两个条件时,才会发生这个错误:
- 赋值器插入了一条或者多条从黑色对象到白色对象的新引用
- 赋值器删除了所有灰色对象到该白色对象的直接或间接引用;
-
-
处理第二个问题的方法:使其中一个条件不被满足即可
- 增量更新 (Incremental Update) + 写屏障:当黑色对象插入新的指向白色对象的引用时,将该黑色对象记录下来,最后再以这些黑色对象为根节点再进行一次 GC 即可。(CMS垃圾收集器)
void write_barrier(obj, field, newobj) { // 如果建立引用的出发对象是黑色,且指向对象是白色或者灰色 // 那么将出发对象涂成灰色 if($gc_phase == GC_MARK && obj.mark == TRUE && newobj.mark == FALSE) { obj.mark = FALSE; push(obj, $mark_stack); } *field = newobj; }
-
原始快照 (Snapshot At The Begining, STAB) + 写屏障:当灰色对象要删除指向白色对象的引用关系时,将这个记录下来,并发标记完成后,对该记录进行重新扫描。(G1垃圾收集器)
这意味着不能够准确地清除对象,实际上不会删除引用,在后续的深度 GC 后才能回收
void write_barrier(oldobj, field, newobj) { oldobj = *field; // 记录下旧的引用 // 如果标记前是白色对象,那么将其置为灰色对象 if(gc_phase == GC_MARK && oldobj.mark == FALSE) { oldobj.mark = TRUE; push(oldobj, $mark_stack); } *field = newobj; }
两种方法的优劣分析:
- 增量分析比较准确,原始快照会造成浮动垃圾
- 增量分析需要重新枚举 GC Roots,而原始快照只需要枚举标记的灰色节点
【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 分享4款.NET开源、免费、实用的商城系统
· 全程不用写代码,我用AI程序员写了一个飞机大战
· MongoDB 8.0这个新功能碉堡了,比商业数据库还牛
· 白话解读 Dapr 1.15:你的「微服务管家」又秀新绝活了
· 上周热点回顾(2.24-3.2)