从回收算法理解JVM(二)

在上一次的《从回收算法理解JVM》 中只是简单的提到了分区 GC,本篇文章针对 G1 做了进一步的总结,并解释了无停顿 GC 的部分实现原理

G1 在并发标记和记忆集维护中的优化

并发标记中的优化

介绍三色标记漏标时曾说过 CMS 选择的即不是前进法也不是后退法,而是复用了卡表 card Table,这种方式可以复用 cardTable 的代码逻辑是其中一个原因,另外还有一个重要原因是写屏障是需要业务线程来完成的,并且写屏障本身的效率不高,容易导致性能问题。

G1 在使用删除写屏障时也考虑到了这个问题,并发标记开始后,如果识别到对象的引用关系被删除,那么业务线程会将需要标记为灰色的对象丢入到本地的 SATB 队列中(每个业务线程都有自己的 SATB 队列),然后由 GC 线程在合适的时机去做标记。

如果本地 SATB 队列放满了。就会将对象转移到全局的 SATB 集合中,然后申请一个空的 SATB,如下所示:

image

在这种维护方式下,业务线程无需关心对象的标记操作,减轻了写屏障的负担,效率更高。

记忆集优化

①记忆集的维护时机

上一篇文章介绍过,因为 G1 的整个内存区域分为很多个 Region,为了避免通用记录集导致的很多不必要查找,G1 实际使用的是专属记忆集的方式,即每个 Region 都有自己的专属记忆集。

image

G1 在维护记忆集时依旧是使用写屏障,并且使用到了本地 SATB 队列相似的方式。写屏障只负责将需要记录到 Rset 中的内容写入到 DirtyCardQueue 中,最后再由特殊的 GC 线程(又称为 Refine 线程)来完成 Rset 的写入操作

image

②记忆集的存放形式

对于一个 Region 来说,它的记忆集可能会因为引用关系比较多,而变得很大。根据另一个 Region 对这个 Region 的引用数量,可以分为少、中、多三种情况。针对这三种情况,有三种不同的数据结构来应对,分别是稀疏表、细粒度表和粗粒度表。三种表之间的关系是不断粗化的,如下图所示:

image

关于安全点的补充

GC 线程需要进行 STW 时,会等待所有业务线程都运行到最近的安全点,我们知道,标记根对象 GCRoot 是追踪式回收算法必须的阶段, GC 线程在寻找 GCRoot 时并不会从头查找整个上下文和全局的引用位置去做遍历,因为这样做的效率太低了,而且根对象的标记时间也会随着数量变长。所以在 HotSpot 中借助了一种名叫 oopMap 的辅助结构来记录根对象。

对于静态数据来说,编译器会在解释阶段将 oopMap 维护进去。而对于运行中动态产生的对象。JIT 解释器会负责将它维护到 oopMap 中。

所以说 oopmap 的作用,就是避免直接查找根对象,但是这里有个新问题就是,运行时可以改变 oopMap 的指令太多了。不太可能每个地方都去维护一次 oopmap

所以 HotSpot 仅在一些关键地方来统一维护 oopMap ,这些地方就被称为安全点 safe point 。 这些特定的位置主要在:

  • 1、循环的末尾
  • 2、方法临返回前 / 调用方法的call指令后
  • 3、可能抛异常的位置

Pauseless GC 是如何实现的

介绍完前面的 CMSG1 等收集器之后可以发现,在原有的思想下,我们已经把能够实现与业务线程并发工作的地方都做了并发处理了。只剩下复制(G1 中称为转移)对象的地址时,仍然需要 STW 。下面我们看看 HotSpot 中无暂停 GC 的代表 ZGC 是如何做的。(并不是真的说完全无暂停,在进行 GCRoot 标记时还是需要停下来的)

ZGCG1 的思想很相似,它们都使用将存活对象拷贝到空闲区域的方式进行回收,ZGC 也将整个对区域划分为多个小块,回收时也会优先选择一部分区域,而它最主要的区别在于,ZGC 可以实现并发的活跃对象对象复制,而这依赖于两个技术:读屏障+染色指针

在了解读屏障和染色指针之前,我们需要复习一下计算机基础知识→什么是虚拟地址,以及什么是物理地址。

首先,物理地址指的是我们 PC 上的那根内存条,例如我的内存条为 8G ,那么它所能存储的地址就是从 0~8G。在原始时期,程序员想要对数据进行读写操作都得操作物理内存,这就导致每个数据的地址分配在什么地方、如何合理地进行内存划分、如何保证数据的正确性,都需要编程人员来管理,效率十分低下。为了解决这个问题,CPU 的设计人员 基于局部性原理进行了一层虚拟化,这就是虚拟内存。这样一来,我们编写程序时就只需要操作虚拟内存就可以了。

CPU 内部通过内存映射单元(MMU)为我们自动完成虚拟内存到物理内存的映射关系,大致如下:

图中的物理空间即我们的内存条,上面的可分配空间并不一定是连续的.而虚拟空间则是根据进程划分,每个进程都对应这一个虚拟空间

image

这里有几个重要的知识点需要掌握:

  • 1.CPU每个进程都虚拟化了同样大小的虚拟内存空间
  • 2.虚拟内存是有大小的,例如 32 位系统是 2^32 ,也就是 4G 大小。而 64 系统只使用了低 48 位,大小为 2^48,即 256T
  • 3.虽然虚拟内存很大,但要实际使用这些内存,还是需要分配在物理内存上能使用。

虚拟内存与物理内存的映射可以用人数和酒店的房间数来做类比,酒店只有 100 间房,在 100 天之内可以住 10000 人,而同时住房的→只有 100 人。(所以如果需要申请的内存超出了 100,就会发生 OOM

读屏障

CMSG1 中都用到了写屏障(write barrier)来解决漏标问题,而 ZGC 中则使用到了与之相对的读屏障(read barrier)技术。

当业务线程触发访问动作时,如果该对象正在转移过程中,那么访问该对象的原地址。如果对象已经转移完成,那么则访问该对象的新地址。read barrier 用于判断该对象是否已经转移完成

image

如上图所示,线程 a 访问时对象 A 还没有完成地址转移,所以访问的是原地址,当线程 b 访问时,对象已经转移完成了,此时就会触发 write barrier 访问转移后的 A' 新地址

对于一个常规的程序来说,对象的读操作数量通常都会高于写操作的数量,所以读屏障对性能的要求更高,为了解决这个问题,ZGC 使用了染色指针

染色指针

前面说到,在 64 位机器上,CPU 为每个进程都虚拟化了 256T 大小的虚拟地址,而我们的程序是用不了这么大内存的,于是 ZGC 将虚拟地址的第 42-45 四个位置作为标记位,把 0-41 位的地址留给程序作为堆空间来使用(依然还能使用 4T

image

ZGC 将不同的标记位置为 1,用于代表对象所处的不同状态,目前包括 Marked0Marked1RemappedFinalizable 四个状态,我们只需关注前三个状态即可,Finalizable 说的是 与弱引用有关(我也没去细看,忽略忽略),我们需要关注的重点是,不管这个标记位的值是什么,它们都指向的是同一个物理地址,为什么要这么做呢?

试想一下,在以前如果我们要在对象上存储一些额外的、只供收集器或者虚拟机本身使用的数据,通常会在对象头中增加额外的存储字段。例如对象的 Hashcode、分代年龄、forwarding 指针、锁标记位等等。这种记录方式在有对象访问的场景下是很自然流畅的,不会有什么额外负担。但是如果对象存在被移动的可能性,在不知道是否能成功访问到对象的情况下,甚至是不访问对象,但是却希望得知该对象的某些信息的情况下,对象头中的信息就排不上用场了, 于是乎,ZGC 把主意打在了对象的虚拟地址上。

下面为染色指针技术带来的一些优势总结:

  • 1.有了染色指针的支持后,无需直接访问对象,就可以根据地址的标记位判断是否发生了迁移,也就解决了读屏障的性能问题。并且,一旦某个 Region 中的存活对象被移走之后,原来的 Region 就能够释放和重用;
  • 2.可以减少收集过程中内存屏障的使用数量,在此之前写屏障的目的通常是为了记录对象引用的变动情况,而现在可以直接维护在指针中,并且目前 ZGC 没有使用任何写屏障,一部分是指针带来的优势,另一部分是 ZGC 目前还不支持分代收集,不存在跨代引用。
  • 3.这种类似的使用地址位进行标记的方式,可以作为一种扩展的存储结构来记录更多的一些信息,以便日后进一步提高性能。

下面来看看 ZGC 的回收流程

GC 线程开始工作前,所有的对象都处于 Remapped 状态,经过 GCRoot 标记之后,会把存活对象的状态标记修改为 Marked0 状态,仍然处于 Remapped 状态的对象则会被当做垃圾对象。在这个过程中新产生的对象也像 G1 那样,均当做存活对象处理

正因为不同标记位指向的是同一个物理地址,所以,从地址上业务线程就能够判断出该对象是否发生了转移

image

标记结束后,接下来 GC 线程会把存活对象转移到空闲区域,并为他们分配新的地址空间,此时会涉及到对象的移动。这个过程总体分为以下两个步骤:

  • 选择一个区域 ,将其中存活的对象,迁移到另一个区域
  • 将这些存活对象,放到 forwarding table 中(记录了对象的新地址)

image

由于业务线程访问对象的时机不确定

  • 如果访问时还没有完成转移(还处于 Marked0 状态),则业务线程会主动完成转移操作(也称为“自愈”过程),并将信息维护到 forwarding table 中。
  • 如果已经完成了转移,则直接去 forwarding table 中查找映射,修改引用即可

image

那 Marked1 状态是什么时候使用的呢?

还记得年轻代,区域划分中的 S0S1 两个 survicor 区域么- -,Marked0Marked1 的关系与它们类似,会在每一次 GC 时互换身份。

参考

  • 周志明-《深入理解java虚拟机第三版》
  • 海纳-《编程高手必学的内存知识》
0%