Back
Featured image of post JVM 学习笔记 06 - Hotspot VM 实现细节

JVM 学习笔记 06 - Hotspot VM 实现细节

JVM 学习笔记,HotSpot VM 实现的技术细节。

导航页

上篇文章我们讲解了如何清理对象,本文将介绍 Hospot 在实现时的细节。

根节点枚举

之前我们说过,JVM 使用可达性分析对象是否存活。所以需要知道所有根节点,即全局引用(常量、static 属性)和执行上下文(本地变量表)。

根节点枚举这一操作,需要扫描所有可能的节点,避免遗漏对象,所以是必然要暂停用户线程的,又有 Stop The World 的问题。就算是 CMS、G1、ZGC 等低延迟 GC,也无法避免这个问题。

当然,其实不需要一个不漏的循环检查对象。JVM 可以知道哪些地方存放着对象引用。HotSpot 就使用 OopMap (Ordinary Object Map,OOP,普通对象指针表)来存储引用。一旦类加载完成,HotSpot 就开始记录引用信息了。而不用反复循环。

安全点

有了 OopMap 还不够用,因为如果为每条指令都生成对应的 OopMap,就需要大量的额外储存空间。

因此,HotSpot VM 引入了安全点(Safepoint)机制,只是在「特定位置」记录信息。该机制要求程序到达安全点后才能够暂停,开始 GC,而不是在任意位置下想停就停。因此,安全点的位置既不能太少,让 GC 等待过长;也不能太频繁,浪费大量的内存。在哪创建安全点主要基于「是否可能长时间执行」这一特征。其中,最明显的就是具有方法调用、循环跳转、异常调转等功能的指令。

此外,还需要考虑如何让所有线程(不含 JNI 线程)暂停。常用的有两种做法:

  • 抢占式挂起(Preemptive Suspension):抢占式挂起不是协作的,系统会把所有用户线程直接中断,如果发现线程不在安全点上,就恢复它,并等待一会,再重新中断,以此反复,直到跑到安全点上。
  • 主动式挂起(Voluntary Suspension):主动式挂起是协作的,系统不直接对线程操作,仅设置对应的标识位,线程在运行时需要轮询该标识位,一旦发现标识为真,就在最近的安全点上主动挂起。轮询标识位的地方和安全点重合,此外还需要在将要创建对象、分配堆内存时主动检查,确定是否有 GC 发生,避免没有足够的内存创建新对象。

几乎没有虚拟机使用抢占式挂起,而是使用主动式挂起

安全区域

除了安全点,还需要安全区域(Safe Region)来保证 GC 执行。

安全点似乎完美解决了这一问题,但是在程序「不执行」时,就显得鸡肋了。比如典型的,线程处于 Sleep 或 Blocked 状态,肯定是无法响应虚拟机中断请求的,不能主动走到安全点挂起自己。

当用户线程执行到安全区域内的代码时,会标识自己已经进入了安全区域。若此时 GC,虚拟机就不会管这些线程。而要离开安全区域时,会检查 VM 是否完成根节点枚举(或其他需要暂停用户线程操作),如果完成了,就继续执行;否则一致等待,知道收到可以离开的信号。

记忆集和卡表

记忆集(Remembered Set)是为了解决之前提到过的,跨代引用需要扫描整个老年代的问题。事实上,不止新生代和老年代,所有涉及 Partial GC 行为的 GC 都会面临这个问题。

记忆集是一种指针集合数据结构,记录了非收集区域到收集区域的指针。如果不考虑成本,最简单的实现就是一个包含所有跨代引用的数组。

然而这种实现是不现实的,效率太低了。在 GC 场景中,因为记忆集主要用来判定,一块非收集区域是否存在对非收集区域的引用;所以,记忆集不需要知道跨代指针的所有细节,可以使用更粗的粒度记录,减少维护成本。

  • 字节精度:记录精确到一个机器字长,如常见的 32 位、64 位。
  • 对象精度:记录精确到一个对象
  • 卡精度:精确到一块内存区域

第三种「卡精度」,使用「卡表」(Card Table)实现记忆集。卡表最简单可以只是一个字节数组,HotSpot VM 也是这么做的。

卡表大小一般是 \(2^n\),若 \(n=2,\ 2^n=2^9=512\) 可得下表:

序号区间
00x0000~0x01FF
10x0200~0x03FF
20x0400~0x05FF
nPage n

一卡页中通常包含不止一个对象,若有一个或对象字段存在跨代引用,那就将对应卡表元素值标识为 1,称为变脏(dirty),否则为 0。

在 GC 时,只需要留下为值 1 的元素,扫描它们即可。

写屏障

Hotspot VM 使用写屏障(Write Barrier)维护卡表状态。针对引用字段赋值这一动作,虚拟机在前后可以执行一定的操作。即写屏障前(Pre-Write Barrier),写屏障后(Post-Write Barrier)。

void oop_field_store(oop* field, oop new_value) {
  *field = new_value;
  post_write_barrier(field, new_value);
}

虽然这样也会有一定的开销,但比 Minor GC 时扫描整个老年代相比,简直是大象对蚊子。

除了这点开销,卡表在高并发场景下还可能存在「伪共享」(False Sharing)问题。现代 CPU 使用缓存行作为最小缓存单位。若多线程修改一个变量,而他们恰恰在同一个缓存行,就会彼此影响(写回、无效化、同步等),导致性能降低。

为了避免这种问题,最简单的解决办法是,在写屏障前检查卡表标记,若卡表未被标记时才标记为脏。即:

if (CARD_TABLE [this address >> 9] != 0)
  CARD_TABLE [this address >> 9] = 0;

可以使用 -XX:+UseCondCardMark 选择是否开启。

并发可达性分析

根节点枚举虽然不可优化了,但 GC Roots 本来就是 Heap 中的极少数,不随容量增长而增长,暂停时间已经非常短暂且固定。

但可达性分析的过程显然是会随着 Heap 变大,耗时也越大的。而且,「标记」这一过程在任何 GC 中都要用到。所以优化可达性分析,是非常重要的。

为了并发进行可达性分析,首先需要知道扫描过程。我们可以通过三色标记(Tri-color Marking)表示,按照「是否访问」标记为 3 种颜色:

  • 白色:未被 GC 访问。在可达性分析开始的阶段,显然所有对象都是白色的;若在分析结束时,白色就等同于不可达。
  • 灰色:已被 GC 访问,但至少还有一个引用未被扫描。
  • 黑色:已被 GC 访问,且所有引用也已被扫描。也就是,黑色对象一定是存活的。若有其他对象引用了黑色对象,无需重新扫描。黑色对象不可能直接指向白色对象,只可能通过灰色对象指向白色对象。

若可达性不是并发的,不会出现问题;但如果是并发的,即用户线程可能在修改对象,收集器也在对象图上标记状态。以保留为 True,清理为 False。可得以下混淆矩阵:

混淆矩阵

真阴和真阳没什么好说的,即 GC 正确清理或保留了对象。而假阴虽然也不是什么好结果,但容忍,原本消亡的对象错误标记为存活,下次收集时再清除即可。然而如果是假阳,就会造成致命的后果,把原本应存活的对象清理了,是一定会报错的。

三色 GC 示意图

Wilson 在 1994 年在理论上证明,当且仅当以下两个条件同时满足时,会产生假阳:

  1. 赋值器插入了黑色对象到白色对象的引用
  2. 赋值器删除了全部从灰色对象到白色对象的直接或间接引用

因此,若要解决假阳,只需要破坏两个条件中的任意一个即可。由此产生了两种解决办法:

  1. 增量更新(Incremental Update):要破坏的是第一个条件,当黑色对象插入指向白色对象的引用关系时,将这个新插入的引用记录下来,等待并发扫描结束后,以记录过的黑色对象为根,再重新扫描一次。也就是,黑色对象一旦插入了指向白色对象的引用,它就变回灰色。
  2. 原始快照(Snapshot At The Beginning, SATB):要破坏的是第二个条件,当灰色对象删除指向白色对象的引用关系时。将这个要删除的引用记录下来,在并发扫描结束之后,再以记录过的灰色对象为根,重新扫描一次。也就是,无论引用关系是否删除,都会按照快照来进行搜索。

记录操作都是通过写屏障实现的。在 HotSpot VM 中,两种方法在不同的 GC 中都有应用。

到这里,HotSpot VM 如何发起、加速内存回收,保证正确性等问题都已介绍,但具体如何回收还未涉及。因为回收是由具体的 GC 决定的,将会在后面详细介绍。

下篇文章会介绍 Hotspot VM 中的经典垃圾收集器。

comments powered by Disqus