What's JVM- 垃圾收集器与内存分配策略
3.1. 对象存在与否
3.1.1. 引用计数算法
🍏给对象添加一个计数器,每次引用就把计数器+1;引用失效,计数器-1;当计数器为 0,释放对象。
🍎但是它很难解决对象之间的循环引用问题。
3.1.2. 可达性分析算法
选定一些对象作为根节点,称为 GC Roots,每次从根节点开始遍历,遍历后所有不可达节点(对象)就是不可用的,需要回收。
这个从根节点开始的路径链称为“引用链”。
既然可达性分析判定一个对象是否应该回收取决于根节点是否可达,那么根节点的选取就变得尤为重要。在 Java 中,根节点(GC Roots)可以为如下几种:
虚拟机栈引用的对象,比如各个线程调用的方法堆栈中的方法参数,局部变量,临时变量。
方法区中类静态属性引用的对象。
方法区中常量引用的对象。
本地方法栈中引用的对象。
JVM 内部的引用,比如基本数据类型对应的 Class 对象,常驻的异常对象。
所有被同步锁持有的对象。
反应 JVM 内部情况的 JMXBean,JVMTI 中注册的回调,本地代码缓存等。
🍐除了这些可以固定作为 GC Roots 的对象之外,还有其他对象可以临时性的加入。比如进行区域回收时,可能就需要把那些跨区域引用的对象的对象一块放到 GC Roots 集合进行扫描。
其实可以看到,所谓 GC Roots 更像是活跃对象集合,哪些对象是存活的,就可以作为 GC Roots。
3.1.3. 再谈引用
🍊Java 原本对引用的定义和 C 的指针一样:reference 如果存储的值表示一个内存的地址,那么 reference 就是一个对象的引用。
🍋但是在 JDK2.0 之后,觉得这样的定义有点不妥,于是引入了四种引用类型。
它们分别是:
强引用就是最传统的定义,就是最基本的 new 对象然后赋值。任何时候,只要存在强引用,对象就不会被回收。
软引用就是那些非必需的对象,在内存即将用尽时,会把这些对象进行第二次回收,如果还不够,则抛异常。
弱引用更弱,它撑不过垃圾回收,任何只被弱引用关联的对象都会在垃圾回收时被回收(ThreadLocalMap 的值就是这种类型,为了防止内存泄漏而引入)。
虚引用只能用来在对象被回收时得到一个通知,仅此而已。
3.1.4. 回收方法区
🍌方法区的垃圾回收主要回收两个部分:废弃的常量和不再使用的类型(多用于动态代理生成的动态类型)。
🍉回收常量很简单,就是看是否还存在对它的引用。但是回收类型比较复杂且收益低。
对于类型的回收,需要判断一个类型是否属于不再使用的类,条件会比较苛刻:
该类及其子类的所有实例都被回收。
加载该类的类加载器已经被回收。
该类的 Class 对象没有在任何地方被引用。
在使用了大量的动态代理,反射,CGLib 的地方,类型回收就显得比较重要。
3.2. 垃圾收集算法
🍇垃圾收集算法有引用计数式垃圾收集和追踪式垃圾收集。
🍓因为当前主流的垃圾收集都是后者,所以下面的讲解也是围绕后者展开的。
3.2.1. 分代收集理论
🫐目前主流的理论(更多像是经验总结)主要有两个:弱分代假说,强分代假说。
🍈弱分代假说:绝大多数对象都是朝生夕灭的(越往后越容易死亡)。
🍒强分代假说:熬过越多次垃圾回收的对象越难死亡(越往后越不容易死亡)。
收集器根据这两个假说把 Java 堆划分成了不同区域,根据对象的年龄(指对象熬过垃圾回收的次数)划分出了两个主要的区域:新生代和老年代。
新生代关注的更多是如何保留少量存活,使用弱分代假说;而老年代则可以使用更低的频率来回收,使用强分代假说。新生代回收之后剩下的对象会逐步晋升代老年代。
🍑跨代引用假说:跨代引用相比于同代引用只占少数。这个理论很容易得到推导:如果老年代引用了新生代,随着引用的存在和老年代对象的长久存活,被它引用的新生代对象也会一直存活然后称为老年代。
因为跨代引用假说的存在,所以可以把老年代划分出不同区域,这样在进行 Minor GC 时,仅仅把这些区域内的老年代加入到 GC Roots,以它们为根节点进行扫描。
3.2.2. 标记-清除算法
🥭标记就是判断对象是否属于垃圾的过程,这个可由可达性分析算法进行标记,在此不再描述。
🍍清除就是把标记过的区域清空。
这个算法足够简单,但是它有两个缺点:
执行效率不稳定,它的执行效率随着堆中需要回收的对象数量增加而下降。
第二个是碎片化问题,过多的碎片会导致没有足够大的空间分配对象进而触发进一步垃圾收集。
3.2.3. 标记-复制算法
为了解决标记清除算法的执行效率不稳定问题,引入了标记-复制算法。
🥥标记还是判断对象是否需要回收。
🥝复制则是把需要保留的对象复制到另一半区域。
此算法会把堆分为两个区域,其中一个保留,另一个存放对象;每次垃圾回收就把需要存活的对象复制到另一半内存,然后清空整个内存半区,这样这一半就完全空闲,而且所有的存活对象都会整齐地排列在另一半中。下次同样这样操作。
这种算法的缺点显而易见:每次只有一半内存得以使用,未免有点太浪费空间了。
为了解决这个问题,可以把内存划分比例换一下,因为统计发现,绝大多数新生代对象撑不过第一轮垃圾回收。
目前有一种更好的解决方案:把内存划分成两个较小的 Survivor(以下简称 S)和一个较大的 Eden(以下简称 E)。每次只使用一个 S 和一个 E 来分配内存。垃圾回收时,把这个 E 和 S 上的存活对象移动到另一个 S 上,然后清空 E 和刚刚那个 S。
凡事总有个例外,如果 S 不够容纳一次 GC 之后的存活对象,就需要老年代的部分区域来存放。这部分实现的安全性由 JVM 担保。
由此可见这个算法主要用于新生代的 GC。
3.2.4. 标记-整理算法
🍅标记依旧是判断对象是否需要回收。
🍆而整理则是把存活对象移动到一端,然后直接清除边界以外的内存区域即可。
这个算法如果用在老年代上的话,估计不是很理想,因为老年代存活对象太多了。而且这个算法在移动对象时,必须暂停用户程序,就像 JOJO 里 Dio 喊出:The World!全世界冻结一样。然后搬运它需要搬运的对象。
是否移动对象有弊有利。移动的好处在于内存空间整齐,方便新空间的申请,且加大系统吞吐量;弊端就是会因为移动对象而产生过多的停顿。
还有一种中和式,就是在内存碎片多到无法容忍时进行整理,CMS 便是这样的原理。
3.3. HotSpot 算法细节
3.3.1. 根节点枚举
🥑在 JVM 里,固定可作为根节点的有全局性引用和执行上下文(栈帧中的本地变量表)。
🥦迄今为止,所有的根节点枚举都是需要暂停用户程序的。
🥬通过一种称为 OOPMap 的数据结构,JVM 可以快速得知所有引用的位置。
因为在类加载时会确定对象偏移量多少的位置上,数据是什么类型,加上即时编译时,也会记录栈和寄存器里哪些保存的是引用类型,所以最终可以直接得到所有引用的位置。然后把它们加入到 OOPMap,进而选出根节点。
JIT 记录 OOPMap 具体过程:一个线程意味着一个栈,一个栈由多个栈帧组成,一个栈帧对应着一个方法,一个方法里面可能有多个安全点(见下面)。 GC 发生时,程序首先运行到最近的一个安全点停下来,然后更新自己的 OOPMap ,记下栈上哪些位置代表着引用。枚举根节点时,递归遍历每个栈帧的 OOPMap,通过栈中记录的被引用对象的内存地址,即可找到所有的 GC Roots。
OOPMap 还可用作准确式 GC(以下内容为转载)。
保守式 GC 在进行 GC 的时候,会从一些已知的位置(GC Roots)开始扫描内存,扫描到一个数字就判断他是不是可能是指向 GC 堆中的一个指针(这里会涉及上下边界检查(GC 堆的上下界是已知的)、对齐检查(通常分配空间的时候会有对齐要求,假如说是 4 字节对齐,那么不能被 4 整除的数字就肯定不是指针),之类的)。然后一直递归的扫描下去,最后完成可达性分析。这种模糊的判断方法因为无法准确判断一个位置上是否是真的指向 GC 堆中的指针,所以被命名为保守式 GC。这种可达性分析的方式因为不需要准确的判断出一个指针,所以效率快,但是也正因为这种特点,它存在下面两个明显的缺点:
因为是模糊的检查,所以对于一些已经死掉的对象,很可能会被误认为仍有地方引用他们,GC 也就自然不会回收他们,从而引起了无用的内存占用,就是典型的占着茅坑不拉屎,造成资源浪费。
由于不知道疑似指针是否真的是指针,所以它们的值都不能改写;移动对象就意味着要修正指针。换言之,对象就不可移动了。有一种办法可以在使用保守式 GC 的同时支持对象的移动,那就是增加一个间接层,不直接通过指针来实现引用,而是添加一层“句柄”(handle)在中间,所有引用先指到一个句柄表里,再从句柄表找到实际对象。这样,要移动对象的话,只要修改句柄表里的内容即可。但是这样的话引用的访问速度就降低了。Sun JDK 的 Classic VM 用过这种全 handle 的设计,但效果实在算不上好。
准确式 GC
与保守式 GC 相对的就是准确式 GC,何为准确式 GC?就是我们准确的知道,某个位置上面是否是指针,对于 Java 来说,就是知道对于某个位置上的数据是什么类型的,这样就可以判断出所有的位置上的数据是不是指向 GC 堆的引用,包括栈和寄存器里的数据。
网上看了下说是实现这种要求的方法有好几种,但是在 java 中实现的方式是:从我外部记录下类型信息,存成映射表,在 HotSpot 中把这种映射表称之为 OOPMap,不同的虚拟机名称可能不一样。
实现这种功能,需要虚拟机的解释器和 JIT 编译器支持,由他们来生成 OOPMap。生成这样的映射表一般有两种方式:
每次都遍历原始的映射表,循环的一个个偏移量扫描过去;这种用法也叫“解释式”;
为每个映射表生成一块定制的扫描代码(想像扫描映射表的循环被展开的样子),以后每次要用映射表就直接执行生成的扫描代码;这种用法也叫“编译式”。总而言之,GC 开始的时候,就通过 OOPMap 这样的一个映射表知道,在对象内的什么偏移量上是什么类型的数据,而且特定的位置记录下栈和寄存器中哪些位置是引用。
3.3.2. 安全点
🥒因为在一个程序里,导致引用关系变化的指令非常多,所以不可能为此一一记录,那样会造成过多空间来记录。由此引入安全点。
🌶安全点的意义在于,引用关系时常变化,做不到对于所有引用关系的记录,于是当需要 GC 时就把程序停靠在安全点上,然后统计 GCRoots。
安全点的选择基本遵循“是否具有让程序长时间执行的特征”来选择的,比如循环,异常抛出,方法调用等。只有具有这些特征的指令才会被放置安全点。说白了就是如果一段代码会长时间执行(比如循环,或者方法调用),那么我们不能等到这段代码执行完再添加安全点,只能把安全点插入到这段代码内部,以此防止它长时间执行影响 GC;比如插入在循环块内最后的位置,方法返回的位置(相当于在方法与方法之间插入)。
另一个问题是,怎么让程序在安全点停下来?有两个方案,抢占式中断和主动式中断。前者基本不再使用,来看看后者。
主动式中断比较简单,在安全点前面添加一个 test 汇编指令,当希望线程停下时,就通过 JVM 把 test 后面的地址设置为不可读,这样就可以触发线程产生自陷异常,被预先注册好的处理程序挂起。
在这里可以看出,所谓的枚举 GC Roots 时发动 The World 能力是通过让线程发生中断来实现的。
线程中断的位置是离它最近的安全点,所以 GC 开始枚举时必须等待所有线程全部跑到最近的安全点才行(此时忽略那些非运行的线程)。因为安全点有很多,所以当 JVM 发动能力后,很快啊!所有还在运行的线程基本马上立刻就停了。
然后更新 OOPMap,进行枚举 GC Roots。
3.3.3. 安全区域
🌽安全点很好的解决了程序运行时的 OOPMap 更新问题,但是如果程序不在运行,而是在阻塞或者被调度离开 CPU 怎么办?此时就需要安全区域。
🥕安全区域指的是在某个代码片段内,引用关系不会发生变化,因此在这个区域任何一个位置进行 GC 都是安全的。
安全区域具体过程:
1️⃣当程序进入安全区域时,首先进行标记,表明自己到了安全区域,此时 GC 就可以忽略这些在安全区域内的程序了;
2️⃣然后当它准备离开时,会判断 GC 是否完成了根节点的枚举,如果未完成,则等待,否则继续执行。
3.3.4. 记忆集
🧄记忆集是为了解决跨代引用的问题而引入的,有了记忆集,就可以知道哪块区域存在跨代引用。
记忆集一般有这三种:
1️⃣字长精度:精确代机器字长,就是处理器寻址位数,查看该字是否包含跨代指针。
2️⃣对象精度:精确到每个对象,查看对象是否含有跨代指针。
3️⃣卡精度:精确到某个内存区域,查看这个内存区域是否含有跨代指针。
其中,卡精度是使用最多的,它有点像分页,把内存划分成固定大小的区域,然后
如果这块内存区域含有跨代指针,则卡表对应元素为 1,否则为 0。
3.3.5. 写屏障
写屏障的存在是为了实现对卡表的更新,它使用类似 AOP 的技术。
首先,写屏障会对引用赋值代码进行 AOP 切入,使用的是环形通知(Around)。赋值代码之前的部分是写前屏障,而之后的成为写后屏障。
在应用了写屏障技术后,JVM 就会为赋值指令生成相应的更新卡表指令。
但是还有一个问题,称为伪共享问题(缓存系统中是以缓存行(CacheLine)为单位存储的,当多线程修改互相独立的变量时,如果这些变量共享同一个缓存行,就会无意中影响彼此的性能,这就是伪共享),这个问题在此不再描述。一种简单的解决方案是:不采用无条件的写屏障,而是使用判断-更新操作来进行,每次更新卡表之前,首先检查卡表的值,如果是 0,则更新为 1,否则不变。
3.3.6. 并发的可达性分析
🧅根节点枚举可以控制在范围时间内,但是对堆内对象的标记会随着对象的增加而增加。
🥔为了保证标记时不会因为引用关系变化而进行错误标记,所以依旧需要发动 The World 能力;如果堆内对象过多的话,就会导致时停过长,影响程序运行。
暂停用户程序的原因是为了保证标记时不会发生引用关系变化,既然是为了标记这段时间引用关系不变,那么可以生成快照,然后在快照上进行。
但是有什么办法可以在生成快照时也不会暂停用户程序或者不会被运行时的用户程序影响快照呢?
引入了三色标记原理。
首先定义定义三个标记色:
⚪️白色:表示对象尚未被标记,在标记开始之前,所有对象应该都是白色的。如果标记完成还是白色,表明对象不可达。
🟤灰色:表示对象已经被 GC 访问过,但是至少还有一个它引用的对象没被标记。
⚫️黑色:表示对象已被 GC 访问过且它引用的对象也全部被访问过,黑色对象表示可存活。GC Roots 默认黑色。
如果无法保证快照一致性,那么在标记时会出现两种情况:
原本是应该保留的对象,结果用户程序删除了引用关系,导致它应该被回收却没有回收,反应在图中就是所有指向某个黑色节点的引用全部消失了。
原本是应该删除的对象,结果用户程序又重新引用了,导致它应该被保留却被回收了,反应在图中就是某个白色节点在扫描完成后突然有了指向它的引用。这种情况比第一种更加危险,因为未回收的对象,可以在下一次 GC 回收,但是如果应该保留的对象被删除了,就会造成空指针异常。
第一种抛弃不说,因为这体现出来的仅仅是回收不及时(下次就会回收)罢了,但是第二种需要格外注意。
对于第二种情况产生的原因,有两个:
赋值操作添加了一条或多条从黑色对象到白色对象的引用。
赋值操作删除了全部从灰色对象到某个白色对象的直接或间接引用。
为了解决误删问题,就需要破坏这两个条件。于是引入增量更新和原始快照。
增量更新:当有黑色对象向白色对象添加引用时,就记录,然后在扫描完成后,以刚刚那些黑色对象为根,再进行一次扫描。
原始快照:当有灰色对象想删除对白色对象的引用时,就记录下来,扫描结束,以灰色对象为根,再来一次扫描。
一句话:增量更新就是你想增加存活对象我就给你记录然后增加;原始快照就是你想删除可能存活的对象我就记录,不给你删,想删吗?等下次 GC 吧!
不论是增量更新还是原始快照,对它们(增量更新或原始快照记录的变化)进行处理依旧需要暂停用户程序,但是因为它们量比较少,所以产生的停顿时间可以暂时忽略。
这是在并发条件下的分析,如果回收器不是并发的,而是直接发动时停能力,那就直接扫描标记,也不存在上述问题。
对了,引用关系的删除和添加的记录操作是通过写屏障实现的。
标记完了,具体的回收行为取决于具体的回收器。
3.5. 经典垃圾收集器
⚽️Serial 收集器。负责对新生代进行收集,使用标记-复制算法。它在运行时必须暂停用户程序,因此可能交互体验不太好,也是客户端默认的收集器。但是它是单线程的,所以适合单核处理器或核数较少的情形;加之内存消耗最小,没有线程交互的开销,是一款很经典的收集器。
🏀SerialOld 收集器。同样是单线程的,前者收集器的老年代版本。所以使用标记-整理算法。除此之外,它还可以作为 CMS 发生失败时的预选方案(见后述)。
🏈ParNew 收集器。是 Serial 的并行版本,除此之外和 Serial 没什么区别。
⚾️ParallelScavenge。作用于新生代,多线程,使用标记-复制算法,但是它和 ParNew 的区别在于,它更注重吞吐量而不是延迟。
🏐ParallelOld 收集器。作用于老年代,是 ParallelScavenge 的老年代版本,基于标记-整理算法。
🏓CMS 收集器。关注响应速度,以停顿时间为目标,使用标记-清除算法。
它的实现略微有点复杂,但是它刚好体现了前面讨论的并发的可达性分析原理。整体分为四个步骤:
A. 🔧初始标记。仅仅标记一下 GC Roots 能直接关联到的对象。
B. 🔨并发标记。与用户线程一起执行,遍历整个对象图,同时记录这期间用户程序作出的更改。
C. ⛏重新标记。修正并发标记期间用户程序产生的修改,就是把并发期间标记为不可达的但因为引用关系变化而又可达的对象,重新标记为可达。这一步需要暂停用户程序。但是因为数量少,所以并不会花费很多时间。
D. 🛠并发清除。回收对象,由于不需要移动对象,所以也是可以于用户程序并行执行的。
但是它也有三个明显的缺点:
一、🙅🏻对处理器资源很敏感。
二、🙅🏼无法处理浮动垃圾(因为标记是并发的,所以标记过程可能还有垃圾产生,这只能留到下一次回收),导致内存空间被垃圾占用,如果不幸的话,甚至可能导致在并发标记阶段预留的堆大小不够用户程序使用。这样就会触发“并发失败”;此时将会冻结用户线程,使用 SerialOld 进行老年代垃圾收集。
三、🙅标记-清除算法会导致空间碎片,此时不得不进行一个 Full GC(整堆和方法区收集)。这样会导致空间整理时产生更长的停顿。
关于浮动垃圾,其产生是因为重新标记只会处理并发标记阶段被记录的变化中从不可达->可达的对象,反之不会处理,所以才会产生浮动垃圾;要么就是并发标记的记录不会记录可达->不可达的变化。总之,要么是没记录新的垃圾,要么是记录了但是没处理新的垃圾。
以上仅为本人理解,为防误导请读者自行谷歌。
🎱G1 收集器。G1 是一个里程碑意义的收集器,其重要意义在于开创了局部收集的设计思路和基于 Region 的内存布局。它还是一款主要面向服务端的垃圾收集器。
G1 为了更好地回收,取消了传统的新生代和老年代的概念,回收的范围扩大到了整个堆。最后通过特殊的算法判定哪个区域回收收益最大,组成回收集进行回收。
在 Region 中有一类特殊的 Humongous 区域,专门用来存放大对象。大对象可以跨区存储,即使用连续的 Region 来存储。G1 的大多数行为都把 Humongous 当初老年代来处理。
G1 还把 Region 当成最小回收单位,每次优先回收价值最大的 Region。
看一下 G1 的内存布局:
对于 Region 的跨引用问题,G1 的解决方案是进行跨区域记录且使用更加复杂的记忆集,具体表现为:谁指向我和我指向谁;是一个双向的映射集。
G1 还使用原始快照(SATB)算法来保证并发标记过程中的引用变化,同时为了保证收集过程中的新对象分配,设置了两个 TAMS 指针,指针上的区域 G1 默认是标记过的,所以新的对象可以分配在指针之上来确保存活。
还有一些其他的问题,可以参考G1的原理实现
现在来看看 G1 的实际工作过程:
A. 📪初始标记。仅仅标记一下 GC Roots 能直接关联到的对象,并设置新的 TAMS 指针的值。此阶段可以借助 Minor GC 同步完成,所以耗时几乎忽略不计。
B. 📫并发标记。扫描并标记整个对象图,并使用 SATB 进行记录此过程中的引用变化。
C. 📬最终标记。暂停用户程序,处理 SATB 记录。
D. 📭筛选回收。重新更新 Region 统计数据并排序,或通过用户自定的策略选定 Region(s)构成回收集,然后把存活 Region 复制到空的 Region(自动整理内存空间)并清空旧的区域,这个移动对象的过程需要暂停用户程序。
用户可以选择停顿时间是 G1 的一个很重要的特性。可以在停顿时间和吞吐量之间取得一个实际的平衡。
同时为了实现原始快照搜索,G1 还使用了写前屏障,不过由于 G1 的写屏障更复杂,所以使用的是异步实现。
3.6. 低延迟垃圾收集器
Shenandoah
和 G1 一样,Shenandoah 支持 Region,支持 Humongous 和回收价值最大的,但是 Shenandoah 支持回收阶段与用户线程并发,默认不使用分代收集,使用连接矩阵而不是记忆集。
连接矩阵很简单,就是一个 M*N 的表格,如果 i 和 j 存在引用关系,那么 i-j 就被打上标记。
来看看 Shenandoah 的工作原理:
🚗初始标记:和 G1 一样,找到 GC Roots 可以直接访问到的,需要时停。
🚕并发标记:并发标记,遍历图,找到不可达节点,不需要时停。
🚙最终标记:和 G1 一样,处理 SATB 扫描。并统计回收价值最高的 Region,生成回收集。会产生小时段时停。
🚌并发清理:清理那些整个 Region 没有一个存活对象的 Region。
🚎并发回收:把回收集中的对象复制到空白 Region 中,通过“转发指针”解决,不需要时停,这也是 Shenandoah 最不同的地方。
🏎初始引用更新:把堆中指向旧对象的引用修正到新的对象上去,这个阶段并不做实际工作,仅仅建立线程集合点并确保所有并发回收阶段的线程完成了对象移动工作,非常短暂的时停。
🚓并发引用更新:与用户线程并发,取决于内存中涉及的引用数量;仅需按照地址序更新引用值。
🚑最终引用更新:修正对 GC Roots 的引用,需要时停,取决于 GC Roots 的数量。
🚒并发清理:释放回收集中的 Region。
所谓引用指针。就是给对象头添加一个指针,正常下这个指针指向自己,此方式有点像前面提到的句柄定位。更新对象引用时,仅仅需要更改这一处即可。让每次访问旧对象时,自动转发到新对象位置,这样只要旧对象还在,就会访问到新的对象。
但是转发指针必然存在并发问题,比如收集器线程设置指针,而用户线程访问,还有就是对于对象的写入,只能写入到新对象上。对于并发问题,可以使用 CAS+失败重试解决。
对于转发指针的设置,是在并发回收阶段处理的,然后在最终引用更新更新引用(指针)的值。
要覆盖全部对象访问操作,Shenandoah 必须使用读,写屏障去拦截。
对于读写屏障,尤其是读屏障,性能代价是很大的,因为系统中读操作更多。可以改成基于引用访问屏障来解决,也就是仅拦截引用类型的读写操作,而忽略普通类型。
ZGC
ZGC 是一款基于 Region 内存布局,不设分代(暂时)。使用了读屏障,染色指针和多重内存映射技术来实现可并发的标记-整理算法,以低延迟为目标的一款垃圾收集器。
ZGC 的 Region 分为三个区域:
1️⃣小型 Region:固定为 2MB,用于放置<256KB 的小对象。
2️⃣中型 Region:固定为 32MB,用于放置 256KB<=对象大小<4MB。
3️⃣大型 Region:容量可以变化,但必须是 2MB 的整数倍,用于放置>=4MB 的对象,每个大型 Region 只会放置一个大对象。大型 Region 不会被重分配。
ZGC 的另一个标志性技术是染色指针。之前的三色标记法,看起来是标记对象,实则可以是标记引用,什么意思呢?
“让所有指向对象的指针失效可以达到标记对象为不存活的效果。”
一个对象的存活与否,取决于是否有引用指向它,所以如果可以通过设置引用(地址指针)某些 bit 位来指明引用的状态,也是一样的标记作用。如果指向这个对象的所有指针都被标记为不可用,那么这个对象就是需要回收的。
此时三色标记成了一开始让每个指针的标记位都为不可达,遍历一遍引用图,把可达的引用标记位设为可达,最后还是不可达的引用指向的对象(指向这个对象的所有引用都不可达)就是需要回收的。
而染色指针就是把记录指针是否可用的机制放在了指针构成上。为什么可以这样?因为目前 Linux 支持的最大虚拟地址空间为 47 位,也就是 128TB,和最大物理地址空间 46 位,也就是 64TB。而在 64 位机中,Linux 保留了高 18 位,如果我们可以缩减堆地址的话,那就会有部分高位怎么也用不到。
所以 ZGC 最高支持 4TB 堆和 64 位机,这样第 43-46 位就可为我们所用(实际上目前 ZGC 支持到了 16TB,这个是后话)。
把 4 位分成:保留位-Remapped 位-Mark1 位-Mark0 位这四种便可以实现把引用状态信息保存在指针中。
染色指针有三大优势:
某个 Region 一旦被移走就立刻可用,因为移走之后指向这个位置的指针被标记为 Remapped,接下来通过“自愈”技术修复即可。所以下一次访问这里的指针会自动重定向到新的位置。
是一种可扩展的结构,方便日后提高性能。
可以大幅减少内存屏障使用的数量。因为 ZGC 不需要记录跨代引用,其一是它可以把这些信息记录在指针里,二是它不支持跨代引用。
但是有一个问题,Java 作为一个进程,直接修改内存指针,OS 是否支持?事实上,因为虚拟地址到物理地址的映射关系,加上 ZGC 使用了空间换时间,使得这个问题得以解决。所谓空间换时间,只是把三(如果算上原本的地址就是四个)个地址映射到了同一个物理地址空间。
首先,OS 在做虚拟->物理空间映射时,把地址后 m 位提出来,把前 64-m 位当成页表索引,找到 n,再把 n+m 拼接到一起(不是加到一起),作为物理地址,n 的范围,m 的值由 OS 和实际内存大小决定。
所以可以知道,如果后 m 位一致,那么多个虚拟地址会映射到同一个物理地址的。详见。所以 ZGC 利用这一点,把四个标志位不同的虚拟地址提前申请了,让它们都映射到同一个物理地址,解决了 OS 不支持的问题。
如果把 4 位标示位看成内存分段符,算上原本的,四位标识位,有 0000,0001,0010,0100 四种,所以它们在后 42 位相同的情况下,有 0+4TB,4+4TB,8+4TB,16+4TB 的内存空间起始间隔。
现在来看看实际的工作流程:
⌚️并发标记。做图的可达性分析,类似于 G1 和 Shenandoah 的初始标记,最终标记。此阶段会更新染色指针的 Mark0 和 Mark1 标志位。
💻并发预备重分配。根据特定的查询条件找出哪些 Region 需要清理,并组成重分配集。ZGC 通过全堆扫描获取重分配集,替代了 G1 维护记忆集的成本。
🖥并发重分配。这是 ZGC 的核心阶段。首先把重分配集中的存活对象复制到新的 Region 中,并维护一个转发表,用来记录对象旧地址到对象新地址的映射。因为染色指针的特点,ZGC 可以通过引用(指针值)得知一个对象是否处于重分配集。如果程序访问处于重分配集中的对象,会被内存屏障捕获,然后转发到新的对象位置去,同时更新引用的值。这样的话,只有第一次访问被移动的对象会陷入处理,往后都一步到位,这样的话比 Shenandoah 的转发指针负载更小一些。这个称为 ZGC 的“自愈”,一旦某个 Region 里的对象全部复制完毕,便可释放,但是保留转发表,以防其他对旧对象的引用找不到新对象在哪。无论某个旧对象还有多少个引用,都可以通过“自愈”技术解决。
📱并发重映射。修改整堆中指向重分配集中的旧对象的引用,这么做的目的是为了让程序不变慢,以及完全修正后释放转发表的收益。
还有一个问题,就是 ZGC 取消了新生代,所以在对象生命周期很短的场景下,可能回收的不会那么及时。
同时还有一个优点,在支持 NUMA 的处理器上,会优先在当前线程所在的 CPU 核心本地缓存上分配,以保证高效的内存访问。
参考
G1: One Garbage Collector To Rule Them All
版权声明: 本文为 InfoQ 作者【CodeWithBuff】的原创文章。
原文链接:【http://xie.infoq.cn/article/79e2a1becfa1089a0098a34f5】。文章转载请联系作者。
评论 (1 条评论)