前言
其实关于GC已经写了四篇文章了:
- 《对于JVM,你就只知道堆和栈吗?》
- 《JVM学习笔记(一) 初遇篇》
- 《我们来聊聊JVM的GC吧》
- 《我们来聊聊JVM的G1吧》
以我现在的眼光来看,这些文章还是不好的地方居多,可能是创作者对自己的作品太过挑剔,但我也是真的看到了不好,不好的地方是内容重复的地方比较多,比如《JVM学习笔记(一) 初遇篇》和《我们来聊聊JVM的G1吧》都讲了JVM是怎么划分内存的,这个其实一笔带过,说在《对于JVM,你就只知道堆和栈吗?》讨论过了即可。然后关于垃圾回收算法,《我们来聊聊JVM的GC吧》和《我们来聊聊JVM的G1吧》都重合,新内容并不多,该压缩的没有压缩,有的地方只是罗列笔记,对实践的指导并不强,结构上相对有些松散。当我去带着问题看以前的这些文章,一部分上篇幅太长,导致重点偏移严重,看不到主题。
但要说全是缺点,那也不全是,介绍了G1的演变,回收过程、分区。让人对G1有一个基本的了解还是可以的。本篇尝试以三个问题牵引来引出G1的基本设计动机、原理,理解了这些就能做一些取舍和现象。第一个问题是为什么要分区,第二个是关于预测停顿时间模型是如何实现的。第三个问题是该怎么理解G1的增量回收。
鉴于AIGC文章泛滥,本人以后的每篇文章对于显而易见的点,不会列出参考点,对于一些明显的点,会给出是怎么得出这个结论的。一些结论和论据引用会列出参考文献。
我们学习理论是为了解释一些现象,理论结合实践,不是为了单纯的忽略实践,单独的强调理论。
概述
在《我们来聊聊JVM的GC吧》这里面我们讲了衡量GC的三个指标吞吐量、延迟、内存占用。那么在G1之前,我们的选择要么是吞吐量,也就是,Parallel Scavenge(作用于年轻代)和Parallel Old(作用于老年代),要么是CMS垃圾回收器追求低延迟。Serial对于服务端应用来说少见一点,追求内存占用和启动速度。那有没有中间值呢,就是平衡吞吐量和延迟,这也就是G1的引入动机之一。那在平衡吞吐量和延迟之间,就要做出对暂停时间的承诺,这也就是MaxGCPauseMillis,最大暂停时间设置,G1将最大努力达到这个目标。
注意这个最大暂停时间这个带来了预测停顿模型,这是相对于CMS 和 并行垃圾回收器所不具备的, 因为G1的论文是这么论述的随着时代的发展,硬件变得越来越富裕的情况下,CMS和并行垃圾回收器的停顿时间变得被难以接受起来。CMS是追求延时, 并行垃圾回收器追求吞吐量,这两个都有些剑走偏锋。我们能不能折中呢,也就是平衡吞吐量和延迟,在吞吐量和延迟之间取一个中间点位, 这也就是G1 的设计动机。
对G1的定位我们可以在参考文档[1] 里面看到G1的介绍
The Garbage-First (G1) garbage collector is targeted for multiprocessor machines scaling to a large amount of memory. It attempts to meet garbage collection pause-time goals with high probability while achieving high throughput with little need for configuration.
G1是专门为具备大容量内存的多处理器机器设计。几乎无需额外调参,就能在保持高吞吐量的同时,就能以高概率满足暂停时间目标。
G1 is a generational, incremental, parallel, mostly concurrent, stop-the-world, and evacuating garbage collector which monitors pause-time goals in each of the stop-the-world pauses.
G1是一种分代、增量、并行、大部分阶段是并发,带有STW阶段,疏散的垃圾回收器,每次stw停顿都会监控既定的停顿时间目标。
这里谈一下我的理解,evacuating的语义是疏散,在GC的语境下应当是标记-复制-整理算法,也就是将对象进行转移,然后整理堆空间。监控是为了满足停顿暂停模型。
下面我们将分别讲: 分代、增量、并行、停顿预测模型、大部分并发,这些词的意义。
分代假说与分区
概述
基本上所有的GC的内存回收过程宏观都可以分为三个步骤:标记、回收内存空间、整理内存空间。

我们姑且可以将内存看做一个字节数组的情况下,标记的过程就是遍历所有的GC Roots, 找出所有存活的对象,剩下的就是需要待回收对象。但这个过程中我们首先面对的一个问题就是对象的生命周期问题,这些对象散落在堆里面,有些对象存活的久一点,有些对象朝生夕死。那对于朝生夕死的对象,我们将其放在一块,就方便采取统一的GC算法进行回收。因此不妨观察并引入假设,绝大多数对象都是朝生夕灭的。这也就是GC领域著名的弱分代假说(Weak Generational Hypothesis)。
那在朝生夕死对象之外呢,我们观察也不妨引入这种假设熬过越多次垃圾收集过程的对象就越难以消亡。这也就是GC领域著名的强分代假设(Strong Generational Hypothesis)
现在我们将朝生夕死的这类对象划分到一块内存区域,对于年龄比较大的对象专门划出一个区域去存储。那现在有新的问题,两个内存区域有互相引用怎么办? 我们的分代假设本身是为了让我们少遍历一点对象,但是为了处理跨代引用,我们似乎不得不再度遍历整个堆。这无疑会为内存回收带来很大的负担。因此我们不妨接着向前推导,我们否认这个现象的存在,就能避免这个性能负担。这也就引出了第三条隐含的分代假说规则,跨代引用相对于同代引用来说仅占极 少数。
这其实是可根据前两条假说逻辑推理得出的隐含推论:存在互相引用关系的两个对象,是应该倾向于同时生存或者同时消亡的。举个例子,如果某个新生代对象存在跨代引用,由于老年代对象难以消亡,该引用会使得新生代对象在收集时同样得以存活,进而在年龄增长之后晋升到老年代中,这时 跨代引用也随即被消除了。 引自《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》 周志明著
对于不同的生命周期方便我们采用对应的算法。对于绝大多数对象都是朝生夕死的区域,我们可以采取标记复制算法,在参考文档[2]里面我们可以看到存活对象越少,标记复制算法越是高效。对于存活比较久的对象,或者换一种说法难以消亡的对象,就把他们集中在一块,虚拟机便可以使用较低的频率来回收这个区域。我们应当尽可能的避免老年代和年轻代,将GC重点工作区域集中在回收价值高的区域。
在Java堆划分不同区域之后,垃圾回收器才可以每次只回收其中某一个或某些区域的部分,才有"Minor GC(回收年轻代)"、 "Major GC(回收老年代)"、"Full GC(回收全堆)"这样的回收类型的划分。
分代的代价
上面讲了分代的好处,现在让我们来讲讲分代的代价,首先我们面临的第一个问题就是跨代引用,我们需要避免将整个老年代都加入到GC roots的扫描范围,因此我们就需要引入一个数据结构记录,记录哪些老年代对象引用了哪些新生代的对象。 这个数据结构我们姑且可以起一个名字就叫Remembered Set,也就是记忆集,我们可以将记忆集理解为一种抽象概念,只定义了行为意图。
对于收集器只需要通过记忆集判断出某一块非收集区域是否存在有指向了收集区域的指针就可以了,并不需要了解这些跨代指针的全部细节。最常见的实现方式是卡表,每个记录精确到一块内存区域,该区域内有对象含有跨代指针。
卡表最简单的形式可以只是一个字节数组,而HotSpot虚拟机确实也是这样做的:
ini
CARD_TABLE [this address >> 9] = 0;
字节数组Card_TABLE 的每一个元素都对应着其标识的内存区域中一块特定大小的内存块,这个内存块被称作"卡页"(Card Page)。一般来说,卡页大小都是以2的N次幂的字节数,通过上面的代码可以看出Hotspot使用的卡页是2的9次幂,即512字节(地址右移9位,相当于用地址除以512)。那如果卡表标识的内存区域的起始地址是0x0000的话,数组CARD_TABLE的第0、1、2号元素,分别对应了地址范围为0x0000到0x01ff、0x0200到0x03FF、0x0400到0x5FF的卡爷内存块。
一个卡页的内存中通常包含不止一个对象,只要卡页内有一个(或更多)对象的字段存在着跨代 指针,那就将对应卡表的数组元素的值标识为1,称为这个元素变脏(Dirty),没有则标识为0。在垃 圾收集发生时,只要筛选出卡表中变脏的元素,就能轻易得出哪些卡页内存块中包含跨代指针,把它 们加入GC Roots中一并扫描。

这里我有一些不理解的地方就是,年轻代回收的时候不应该是避免扫描这些包含跨代指针的对象嘛? 为什么要将这些包含跨代指针的对象一并加入到GC Roots。我们不妨这么想,对于年轻代的回收来说(Mirror GC),我们的目标是快速地回收掉年轻代已经死掉的对象,那死对象和活对象就是互斥的集合,我们不妨找活着的对象。
我们要快速的找到"还活着"的对象。对于任何一个"还活着的对象,都必须能从一个起始点(GC Roots)被访问到。那么对于GC Roots来说,主要包括:
- 虚拟机栈(本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈(JNI)中引用的对象。
- 以及,在Minor GC时,所有从老年代指向年轻代的对象。
前四类好找,第五类就要从卡表里面去找对应的内存区域的对象了,这也就是卡表的意义所在。
注意这里,我在阅读《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》 这本书的时候将这个卡表的实现理解为是堆的全局内存区域,事实上仔细阅读推敲就会发现里面讲到了是为了年轻代的回收,避免扫描整个老年代,因此这个卡表描述的是老年代的哪一块区域的对象存在指向年轻代对象的指针,扫描年轻代的时候将卡表中的脏表区域拎出来加入GC Roots里面,竭尽可能的避免扫描全堆。 因此描述的是一种我指向谁的结构
那卡表该如何维护
那卡表该如何维护? 换一种问题也就是卡表是什么时候变脏的,答案是明确的,在《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》是这么论述的:
有其他分代区域中对象引用了本区域对象时,其对应的卡表元素就应该变脏,变脏时间点原则上应该发生在引用类型字段赋值的那一刻。
我们尝试来理解"有其他分代区域中对象引用了本区域对象时" 这句话,这句话从语序上给人的理解是谁引用了我,但结合我们的上文,这个是为老年代准备的,标识老年代的某块内存区域存在对年轻代的引用,也就是说是在老年代的对象产生赋值操作的时候,表达的是我引用的谁的关系。在《深入理解Java虚拟机:JVM高级特性与最佳实践(第3版)》后面也有对应的论述印证我们的推断:
G1的记忆集在存储结构的本质上是一 种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这 种"双向"的卡表结构(卡表是"我指向谁",这种结构还记录了"谁指向我")比原来的卡表实现起来更 复杂,同时由于Region数量比传统收集器的分代数量明显要多得多,因此G1收集器要比其他的传统垃 圾收集器有着更高的内存占用负担。
我们知道了卡表在什么时刻变脏,我们需要在老年代对象发生引用类型赋值那一刻,将这个对象对应的卡表变脏。那该怎么实现呢? HotSpot的解释场景中,虚拟机油充分的介入空间,但是对于编译执行的场景就是纯粹的机器码执行流了,这就必须找到一个在机器码层面的手段,把维护卡表的动作放到每一个赋值操作之中。
Hotspot通过写屏障这个技术来实现对卡表的维护,注意这里的写屏障和JMM中的写屏障并不是一个含义的名词,写屏障可以看作在虚拟机层面对"引用类型字段赋值"这个动作的AOP切面,在引用对象赋值时会产生一个环形(Around)通知,供程序执行额外的动作,也就是说赋值的前后都在写屏障的覆盖范畴内。在赋值前的部分的写屏障叫作写前屏障(Pre-Write Barrier),在赋值 后的则叫作写后屏障(Post-Write Barrier)。HotSpot虚拟机的许多收集器中都有使用到写屏障,但直 至G1收集器出现之前,其他收集器都只用到了写后屏障。
G1的额外代价之RSet

传统的GC收集器将连续的内存空间划分为新生代、老年代 。 这种划分的特点是各代的存储地址(逻辑地址,下同)是连续的。
而G1的各代存储地址是不连续的,每一代都使用了n个不连续的大小相同的Region,每个Region占有一块连续的虚拟内存地址。如上图所示。注意有一些Region标明了H, 这个H代表的是humongous object, 即大小等于region一半的对象, 避免了CMS 和并行垃圾回收器的反复拷贝过程,分配之前就会安排一次检测IHOP,如果超过了,安排一次YGC和并发标记过程。
注意尽量将大对象完全放在一个region里面,不然G1会寻找一块连续的未被分配的region,将这个大对象专门放入。我们举一个极端的例子来说明这种对象会造成一定的资源浪费,一般region默认的大小是2MB,设我们的对象为5.5MB,于是我们需要三个region专门存放这个大型对象,这样还有0.5MB的空间被浪费。
除了空间浪费,我们应当也要看到,G1采用的是标记-复制算法来进行内存回收,对于大对象来说,寻找一块连续的内存空间容纳如果找不到就会触发全局的FullGC。
现在我们我们来考虑G1划分region之后怎么处理跨代引用问题,我们可以依然引入全局卡表来帮助我们解决跨代引用这个问题,但是G1相对于CMS GC和并行GC的不同之处在于,G1可以选择性的只回收一部分区域,可以全是年轻的region,也可以年老的region搭配年轻的region。
写到这里的时候,我还是旧有的思维在作祟,我还是认为GC的过程是将不可达的对象标记出来,然后回收这些对象占用的内存空间了。但其实我的思维也没错,只不过JVM是通过找活对象,排除掉活的剩下的就是死对象。那我们想一下在混合回收的情况下,过去的卡表还够不够我们用,卡表本质上表达的是一种非收集区域是否存在对旧有内存区域的引用,也就是我指向谁。
在以前的分代假设下我们从老年代区域拎出来对年轻代的引用,我们在分代假说下,将主要精力放在年轻代,这样在扫描年轻代的时候就要找到所有活的对象,我们需要找到老年代有哪些对象指向年轻代。其他就遍历年轻代的对象找可达的对象即可,这种回收回收的整个年轻代。
但是注意G1特有的混合回收模式下,我们是可以回收年轻代搭配老年代的,那上面的卡表就有些不够用了,前面的卡表宏观上的概念是非收集区域对收集区域的引用,现在非收集区域是不连续的,在过去的实现下卡表中只是老年代的区域,现在我们就要扩展卡表的实现,我们回收任意一个Region的时候都需要知道这个Region被哪些Region引用。
于是我们就需要为每个区域维护一个记忆集,G1的记忆集在存储结构的本质上是一 种哈希表,Key是别的Region的起始地址,Value是一个集合,里面存储的元素是卡表的索引号。这样虽然浪费了一点空间,但是换来了性能提升是典型的空间换时间。根据经验,G1至少要耗费大约相当于Java堆容量10%至20%的额 外内存来维持收集器工作。
G1 付出的额外代价之Collection Set
从参考文档[11] ,我们可以看到由于G1的停顿预测模型,G1可以根据预期暂停时间调整GC的行为,来选取更激进更保守的频率进行回收,G1会优先选择回收价值最高的Region,那怎么选择呢?G1准备了一个叫Collection Set的结构来存储要回收的Region,G1在标记阶段会根据下面两点将Region加入到collection-set中:
- 存活数据量: 存活数据少(空闲空间多)的 Region 优先;
- 与其他 Region 的引用连通度: 连通度低的 Region 优先,因为回收它们所需更新的引用更少
若某个Region对释放内存的贡献不大,G1会将它提出候选集合。 这是G1会内存占用比较多的另一个来源。
G1后续的优化
事实上早期的G1实现的记忆集还有些粗糙,理论上的性能是理论上的性能,实际归实际。 在参考文档[14] 可以看到对应的论述:
The current G1 remembered set implementation has been designed for use cases and Java heaps and applications from 20 years ago.
Over time many problems with performance and in particular memory usage have been observed:
G1提出来的时候比较早是为二十年前的硬件设计的,那个时候单核还是主流,随着时间的观察,已经有许多性能问题,不适应现代硬件。
比如更新G1的记忆集为了避免并发问题,需要获取一把锁, 在参考文档[14]里面换成了无锁实现。还有一些降低内存占用的需要一些其他的单独介绍这里不做单独展开。不过参考文档[14] 修复在JDK 18,不晓得后面有没有向后移植的打算。 后续G1还有很多优化,会专门开新的文章去解读,这里只简单介绍一点点。
写在最后
本篇重构了自己对G1的理解,我们主要讲的是G1分代的动机,主要还是为了避免扫描全堆,在假设大部分对象都是朝生夕死的情况下,我们引出老年代对新生代的引用是极少的,同时新生代指向老年代的引用,这两个对象具备相同的生命周期。我们找出垃圾的过程是通过排除法来实现的,即找出哪些对象是存活的,剩下的就是死对象。在后文我们会介绍怎么标记存活对象的两种算法,这里不做过多介绍。
那么在回收年轻代的时候,一部分GC roots 就在老年代,我们选择用卡表来标记存在跨代引用的内存区域。但是对于G1来说,一般的卡表还是不太够用,因为可以回收任意分区的组合,不像过去的分代回收器一样,一次回收的是一块连续的内存区域。回收Region的时候,我们就需要当前Region被哪些Region所引用,如果我们还沿用全局的卡表,这就会有些慢。因此我们引入了空间换时间的思路,为每个Region都准备了一个记忆集。这意味着G1会更消耗内存。
由于G1垃圾回收器的可预测暂停时间的特性,G1这次会主动的评估垃圾回收的收益,所以G1引入了Collection Set来存储需要回收的Region,这也是G1会更占用内存的另外一个原因。到现在我们从分代这个最基础的特性一路向前推导,推导出了为什么要引入分代,分代的代价。我们就可以用这些理论来解释现象,来解释现象,比如我们采用了G1为什么会更消耗内存。
我们在参考文档[14] 里面可以看到,记忆集大部分用的是堆外,所以当你采用了G1,堆外内存明显上升,也是在我们的理论预测范围之内。
但是我还是要指出要旗帜鲜明的反对本本主义,在高版本G1做了很多优化,比如在参考文档[14]里面可以看到对g1记忆集的重构。
参考资料
1\] [docs.oracle.com/en/java/jav...](https://link.juejin.cn?target=https%3A%2F%2Fdocs.oracle.com%2Fen%2Fjava%2Fjavase%2F17%2Fgctuning%2Fgarbage-first-g1-garbage-collector1.html%23GUID-ED3AB6D3-FD9B-4447-9EDF-983ED2F7A573 "https://docs.oracle.com/en/java/javase/17/gctuning/garbage-first-g1-garbage-collector1.html#GUID-ED3AB6D3-FD9B-4447-9EDF-983ED2F7A573") \[2\] Java中9种常见的CMS GC问题分析与解决 [tech.meituan.com/2020/11/12/...](https://link.juejin.cn?target=https%3A%2F%2Ftech.meituan.com%2F2020%2F11%2F12%2Fjava-9-cms-gc.html "https://tech.meituan.com/2020/11/12/java-9-cms-gc.html") \[3\] JEP 439: Generational ZGC [openjdk.org/jeps/439](https://link.juejin.cn?target=https%3A%2F%2Fopenjdk.org%2Fjeps%2F439 "https://openjdk.org/jeps/439") \[4\] 《A Real-time Garbage Collector with Low Overhead and Consistent Utilization》 [www.cs.purdue.edu/homes/hoski...](https://link.juejin.cn?target=https%3A%2F%2Fwww.cs.purdue.edu%2Fhomes%2Fhosking%2F690M%2Fp285-bacon.pdf "https://www.cs.purdue.edu/homes/hosking/690M/p285-bacon.pdf") \[5\] 《List Processing in Real Time on a Serial Computer》 [dl.acm.org/doi/pdf/10....](https://link.juejin.cn?target=https%3A%2F%2Fdl.acm.org%2Fdoi%2Fpdf%2F10.1145%2F359460.359470 "https://dl.acm.org/doi/pdf/10.1145/359460.359470") \[6\] 《Incremental incrementally compacting garbage collection》 [dl.acm.org/doi/10.1145...](https://link.juejin.cn?target=https%3A%2F%2Fdl.acm.org%2Fdoi%2F10.1145%2F29650.29677 "https://dl.acm.org/doi/10.1145/29650.29677") \[7\] G1 GC 停顿预测模型 [sdww2348115.github.io/jvm/g1/Paus...](https://link.juejin.cn?target=https%3A%2F%2Fsdww2348115.github.io%2Fjvm%2Fg1%2FPausePredictionModel "https://sdww2348115.github.io/jvm/g1/PausePredictionModel") \[8\] Implementation for JEP 363: Remove the Concurrent Mark Sweep (CMS) Garbage Collector [bugs.openjdk.org/browse/JDK-...](https://link.juejin.cn?target=https%3A%2F%2Fbugs.openjdk.org%2Fbrowse%2FJDK-8233382 "https://bugs.openjdk.org/browse/JDK-8233382") \[9\] Major GC和Full GC的区别是什么?触发条件呢? [www.zhihu.com/question/41...](https://link.juejin.cn?target=https%3A%2F%2Fwww.zhihu.com%2Fquestion%2F41922036%2Fanswer%2F93079526 "https://www.zhihu.com/question/41922036/answer/93079526") \[10\] 官方文档竟然有坑!关于G1参数InitiatingHeapOccupancyPercent的正确认知 [heapdump.cn/article/271...](https://link.juejin.cn?target=https%3A%2F%2Fheapdump.cn%2Farticle%2F2712390 "https://heapdump.cn/article/2712390") \[11\] Garbage-First (G1) Garbage Collector [docs.oracle.com/en/java/jav...](https://link.juejin.cn?target=https%3A%2F%2Fdocs.oracle.com%2Fen%2Fjava%2Fjavase%2F17%2Fgctuning%2Fgarbage-first-g1-garbage-collector1.html%23GUID-ED3AB6D3-FD9B-4447-9EDF-983ED2F7A573 "https://docs.oracle.com/en/java/javase/17/gctuning/garbage-first-g1-garbage-collector1.html#GUID-ED3AB6D3-FD9B-4447-9EDF-983ED2F7A573") \[12\] [www.bilibili.com/opus/592749...](https://link.juejin.cn?target=https%3A%2F%2Fwww.bilibili.com%2Fopus%2F592749304108986223 "https://www.bilibili.com/opus/592749304108986223") \[13\] \[[www.zhangy-lab.cn/teaching/%E...](https://link.juejin.cn?target=https%3A%2F%2Fwww.zhangy-lab.cn%2Fteaching%2F%25E7%25AC%25AC%25E4%25B8%2589%25E7%25AB%25A0%2520%25E6%25AD%25A3%25E6%2580%2581%25E5%2588%2586%25E5%25B8%2583%25E4%25B8%258E%25E5%258C%25BB%25E5%25AD%25A6%25E5%258F%2582%25E8%2580%2583%25E5%2580%25BC%25E8%258C%2583%25E5%259B%25B4-zhy.pdf "https://www.zhangy-lab.cn/teaching/%E7%AC%AC%E4%B8%89%E7%AB%A0%20%E6%AD%A3%E6%80%81%E5%88%86%E5%B8%83%E4%B8%8E%E5%8C%BB%E5%AD%A6%E5%8F%82%E8%80%83%E5%80%BC%E8%8C%83%E5%9B%B4-zhy.pdf")
14\] JDK-8017163 [bugs.openjdk.org/browse/JDK-...](https://link.juejin.cn?target=https%3A%2F%2Fbugs.openjdk.org%2Fbrowse%2FJDK-8017163 "https://bugs.openjdk.org/browse/JDK-8017163")