JVM内存结构与G1垃圾回收器概述
众所周知,JVM 的内存结构由以下五部分构成:
- 堆(Heap)
- 栈(Stack)
- 方法区(Method Area)
- 本地方法区(Native Method Area)
- 程序计数器(Program Counter)
垃圾回收器主要管理的是堆内存。本文将详细介绍 G1 垃圾回收器 如何对堆内存进行管理。
G1垃圾回收算法
G1 垃圾回收器 采用以下算法对堆内存进行管理:
- 分代管理
- 复制算法
分代管理
分代管理包括:
- 新生代(包括 Eden 和 Survivor 区)
- 老年代(Old Generation)
- 超大对象区(Humongous Objects)
Region
G1 逻辑上将堆内存划分为多个大小不等的 Region。每个 Region 的大小通常在 2MB 到 32MB 之间,可通过 JVM 参数进行设置。在任何给定时间,所有 Region 必定属于以下类型之一:
- E(Eden)
- S(Survivor)
- O(Old)
- H(Humongous)
- F(Free)
划分成 Region 的好处在于,G1 能够根据需要动态调整不同代的内存大小。例如,如果新生代空间不足,G1 可以从 Free 类型的 Region 中划分一块成为 Eden 类型的 Region。
RSet (Remembered Set)
每个 Region 都会有一个自己的 RSet。RSet 用于记录不同 Region 之间的 跨 Region 引用关系。例如,有两个对象 A 和 B,且 A 在 RegionA 上,B 在 RegionB 上,对象 A 的某个属性是 B,那就意味着 A 引用着 B。此时,RegionB 对应的 RSet 上就会记录着 A 引用着 B,即 RSet 上记录的是别的区域对本区域对象的引用。
RSet 的数据结构类型
- 稀疏模式(Sparse):通过哈希表方式实现。Key 是别的 RegionID,而值是 Card 地址数组,即别的Region对应的Card的地址。
- 细粒度模式(Fine-grained):通过哈希表方式实现。Key 是别的 RegionID,而值是直接引用地址的数组。
- 粗粒度模式(Coarse-grained):一个 RegionID 数组,里面分别是别的 Region 的 ID。
内存划分与 LAB(本地缓冲区)
当需要对新的内存区域进行划分时,了解 LAB 的概念非常重要。由于 Region 通常较大,内存申请和划分往往需要更小的粒度。因此,引入了 LAB,它允许更细粒度的内存分配。
CARD
为了更有效地管理内存,G1 将每个 Region 进一步划分为多个 Card,通常大小为 512B。这样,一个 Region 就包含多个 Card,Card 是 G1 进行内存管理和垃圾回收的最小单位。一个对象可能跨越多个 Card,或者一个 Card 内存储多个对象。
CardTable
为了全局管理 Card,引入了 CardTable。CardTable 用于记录 Card 内对象的引用情况,是 G1 垃圾回收过程中的关键数据结构。CardTable 可以被理解为一个字节数组,其中 Card 的首地址就是数组的下标,下标对应的值表示该 Card 上的对象是否发生了引用修改。
LAB (Local Allocation Buffer)
LAB 是每个线程的私有内存分配区域,减少线程间的竞争,用于加速对象的分配过程。这里的私有内存是堆内存里Eden区域中的内存,只不过对应的线程管理自己负责部分的内存区域,而且如果使用完可以重新申请LAB。
G1的垃圾回收流程
堆布局
- 堆被划分为多个相同大小的区域(regions),这些区域可以属于年轻代、老年代或是空闲的。每个区域都有相应的标记,以区分其用途。
- **大对象(即“巨型对象”)**会被分配到特殊的连续区域。
并发标记周期(Concurrent Marking Cycle)
-
初始标记(Initial Marking):
- G1收集器暂停应用程序线程,并标记从GC根(GC Roots)直接可达的对象。
- 这个标记阶段通常与年轻代收集一起执行,因此暂停时间很短。
-
并发标记(Concurrent Marking):
- 在这个阶段,G1会在应用程序继续运行的同时,遍历整个堆,标记可达的对象。
-
重新标记(Remark):
- G1再次暂停应用程序线程,以确保所有存活对象都被正确标记。这个阶段的暂停时间较短。
-
最终清理(Cleanup):
- 在并发标记周期结束后,G1会进行清理,释放完全未使用的区域,并重新计算各个区域的存活对象数量。
年轻代收集
- 这是G1的常规操作,当年轻代区域填满时,会触发年轻代收集。
- G1会选择所有年轻代区域,复制存活的对象到新的区域,并回收旧区域。
- 采取的STW(Stop The World),因为新生代的整个垃圾回收很快,
混合收集
- 在并发标记周期结束后,G1会执行混合收集。
- G1会优先选择那些垃圾多、存活对象少的老年代区域,与年轻代区域一起回收。
- 混合收集的次数和频率取决于老年代的填充情况以及用户设置的暂停时间目标。
全堆收集(Full GC)
- 当G1的正常收集无法满足内存需求时,会触发全堆收集。
- 全堆收集会暂停所有应用程序线程,对整个堆进行垃圾收集和压缩。这是G1中最耗时的操作,因此通常尽量避免。
下面我们着重介绍一下G1垃圾回收器的并发标记周期流程,主要分为四步,分别是初始标记、并发标记、再次标记和垃圾清理。简而言之,垃圾回收就是对不需要的对象进行内存释放。那么,如何找到这些不需要的对象呢?我们都知道Java的对象之间存在相互引用,类似于一个网状结构,因此确定第一个需要保留下来的对象就非常关键了。因此,GC Root的概念就出现了。
GC Root分为四类:
- 虚拟机栈(栈中的本地变量表)中引用的对象。
- 方法区中类静态属性引用的对象。
- 方法区中常量引用的对象。
- 本地方法栈中JNI(即常说的native方法中)引用的对象。
凡是被GC Root引用的对象就会被保留下来,然后这些对象所引用的对象也要被保留下来,否则程序无法运行。通过这种方式拓扑开来,所有需要保留下来的对象都会被扫描到。这种算法叫三色标记法:
具体来说,三色标记法的对象初始状态为白色。如果当前对象被扫描到但其引用的对象尚未被扫描完,则该对象被标记为灰色(如对象B,其引用的D对象尚未被扫描)。如果一个对象及其引用的所有对象都被扫描完毕,则该对象被标记为黑色(如A对象)。黑色和灰色对象会被保留下来,而被标记为黑色的对象不会被再次扫描,这就是三色标记法。
这样看来,垃圾回收实际上可以分为两步:一步进行垃圾标记,然后对垃圾进行回收。那么,为什么G1要分为四步呢?这就要从G1的特性来说了。G1的特点是出色的响应时间以及可以设置垃圾回收时的停顿时间。
大家可以想象,代码在运行过程中会伴随着对象的修改。由于堆内存中的对象数量较大,且寻址扫描非常耗时,通常情况下,扫描一遍进行标记,扫描到的对象就保持其当前颜色。例如,在标记初期,A被标记为黑色并保留下来,而E没有被任何对象引用,因此被标记为白色并视为垃圾。然而,如果在标记阶段,垃圾回收线程和程序的工作线程同时运行,E对象可能会在标记后被A对象引用,但由于E已经被标记为白色,若作为垃圾回收,程序将会出错。这种情况也可能在垃圾清理过程中出现。因此,最简单的做法是停止应用线程,只让垃圾回收线程工作,这就是STW(Stop The World)。这种做法显然不符合G1的特性,因此G1将原来的两步分为四步。
初始标记阶段通过找到GC Root引用的对象,这一步需要STW,但非常快速。接下来是并发标记阶段,这一步主要对堆内存中的对象进行拓扑扫描和三色标记。由于对象数量庞大,这一环节耗时较长,因此不能STW,而是让应用线程和垃圾回收线程同时工作。被标记为黑色的对象不会被再次扫描,但这也可能出现前面提到的漏标问题,对应的也可能出现多标的问题,比如之前标记为黑色或者灰色,但是并行标记过程中不再被引用,那么这些被多标的对象就会成为浮游垃圾,本次垃圾回收不会对其回收,下次垃圾回收会回收掉,为了解决漏标问题,出现了两种理论:SATB(原始快照)和增量更新。G1采用前者,而另一种并发垃圾回收器则采用后者。这两种理论的解决方案都基于写屏障技术,写屏障分为写前屏障和写后屏障。
SATB基于写前屏障,增量更新则基于写后屏障。例如,在扫描初期,a.e = e1,此时e1被标记为不可回收,因为它被对象a引用了。在并发标记阶段,应用线程执行a.e = e2;执行这一句时会触发写屏障。写前屏障在读取e2对象的引用时记录下该引用到一个队列里,而写后屏障则记录a对象的引用。因此,这种方式可以将并发标记过程中出现的变化记录下来。
接着进入再次标记过程,这次标记实际上是重新扫描并发标记过程中记录下来的对象,因此也需要STW。由于扫描的对象较少,所以STW时间非常短。最后一步是垃圾回收,这就是整个G1垃圾回收的大致过程。
至于为什么叫原始快照,其实原始快照并不是真实存在的数据结构,而是一个概念。从初始标记到并发标记,中间发生变化的对象会保持原貌被记录下来,这样相当于实现了一个快照的作用。
其实G1有一个很大的优势,这一点不同于其他垃圾回收器,就是对堆内存的管理。其他垃圾回收器虽然也将堆内存划分为新生代(Eden和两个Survivor区)以及老年代(Old),但它们在一开始就指定了这些区域的比例。而G1则是根据Region管理的,每个Region具体属于哪个代(新生代或老年代),是根据程序运行情况和算法动态划分的。
这种动态管理堆内存的方式相较于一开始就固定比例,优势明显。因此,你会发现G1的参数配置都是配置的目标效果,算法会根据你想要的效果来管理堆内存,尽可能达到配置目标效果。
有了以上内容的铺垫,我想你基本上了解了大概流程。上面只是介绍了一个大致的流程,这样你的脑海里对整体流程会有一个清晰的认识。接下来,我们进一步探讨具体的细节,比如在 minorGC 和 mixGC 的时候。
上文提到,垃圾回收扫描的是堆,实际上是在扫描 Region。minorGC 回收的是新生代,具体而言,就是那些被标记为 E 和 S 的 Region。尽管这些对象存在于新生代,但它们并未被新生代的对象引用,而是被老年代引用,这些对象其实也是要保留的。那么,如何在不扫描老年代的情况下找到这些对象呢?要是连老年代一并扫描,那就不是 MinorGC 了,而是 FullGC。这就涉及到 Rset 了。
上文提到过,每个 Region 都有对应的 Rset,因为 Rset 记录了其他 Region(key)对当前 Region 中 Card 的引用关系。所以,我们只需要扫描对应 Region 的 Rset 即可。因为 minorGC 会对整个新生代的所有 Region 进行扫描,所以新生代 Region 对应的 Rset 只需记录老年代对本 Region 对象的引用关系即可。这样不仅能有效降低 Rset 对内存的占用,还能使 Rset 的维护和扫描更加高效简便。因此,Rset 记录的都是跨代的引用关系。
接下来,讨论一下 Rset 数据的维护方式、时机和机制。
在并发标记过程中,SATB 是通过写屏障实现的。当写屏障发生时,如果引用发生了改变,比如变为 A.b = B
,会通过异或的方式判断 A 和 B 所在的 Region 是同代还是跨代。如果是跨代,则会将 A 所在的 Card 标记为脏。其余条件,比如同代,或者 B 为 null
,则不会进行处理。
当 Card 被标记为脏时,并不会立即更新对应 Region 的 Rset,而是被记录为 Rset 日志。由于垃圾回收是多线程的,为了减少多线程的问题,提高效率,每个垃圾线程都会有自己的 Rset 日志(一个队列),此外还存在一个全局的 Rset 日志(一个队列)。
当线程的日志队列满了之后,会合并到全局队列。如果一个 Card 已经标记为脏,并不会再次进行标记。需要注意,全局 Rset 日志(即一个队列)会有专门的 GC 线程(ConcurrentRefineThread)来消费,并且根据全局队列中 Card 的数量调节线程数量。如果消费不过来,应用线程也会协助处理。
从全局队列中取出 Card 后,会保存一个 HotCardCache 用于记录热点 Card。对于热点 Card,不会记录到 Rset 中,而是在 GC 时单独处理。不是热点的 Card,则会先清理 Card 并设置为 clean
,然后扫描对应 Card 的内存范围,找出引用关系对,将每个引用关系对存入被引用对象所在 Region 的 Rset,这样就完成了 Card 和 Rset 的更新。