深入理解 JVM 垃圾回收机制及其实现原理

 2022-09-03
原文地址:https://blog.csdn.net/qq_35246620/article/details/80522720

前言

对于 JVM 来说,我们都不陌生,其是 Java Virtual Machine(Java 虚拟机)的缩写,它也是一个虚构出来的计算机,是通过在实际的计算机上仿真模拟各种计算机功能来实现的。JVM 有自己完善的硬件架构,如处理器、堆栈等,还具有相应的指令系统,其本质上就是一个程序,当它在命令行上启动的时候,就开始执行保存在某字节码文件中的指令。

Java 语言的可移植性就是建立在 JVM 的基础之上的,任何平台只要装有针对于该平台的 Java 虚拟机,字节码文件(.class)就可以在该平台上运行,这就是“一次编译,多次运行”。除此之外,作为 Java 语言最重要的特性之一的自动垃圾回收机制,也是基于 JVM 实现的。那么,自动垃圾回收机制到底是如何实现的呢?在本文中,就让我们一探究竟。

垃圾

什么是垃圾?

在 JVM 进行垃圾回收之前,首先就是判断哪些对象是垃圾,也就是说,要判断哪些对象是可以被销毁的,其占有的空间是可以被回收的。根据 JVM 的架构划分,我们知道, 在 Java 世界中,几乎所有的对象实例都在堆中存放,所以垃圾回收也主要是针对堆来进行的。

在 JVM 的眼中,垃圾就是指那些在堆中存在的,已经“死亡”的对象。而对于“死亡”的定义,我们可以简单的将其理解为“不可能再被任何途径使用的对象”。那怎样才能确定一个对象是存活还是死亡呢?这就涉及到了垃圾判断算法,其主要包括引用计数法和可达性分析法。

垃圾判断算法

引用计数法

在这种算法中,假设堆中每个对象(不是引用)都有一个引用计数器。当一个对象被创建并且初始化赋值后,该对象的计数器的值就设置为 1,每当有一个地方引用它时,计数器的值就加 1,例如将对象 b 赋值给对象 a,那么 b 被引用,则将 b 引用对象的计数器累加 1。

反之,当引用失效时,例如一个对象的某个引用超过了生命周期(出作用域后)或者被设置为一个新值时,则之前被引用的对象的计数器的值就减 1。而那些引用计数为 0 的对象,就可以称之为垃圾,可以被收集。

特别地,当一个对象被当做垃圾收集时,它引用的任何对象的计数器的值都减 1。

  • 优点 :引用计数法实现起来比较简单,对程序不被长时间打断的实时环境比较有利。
  • 缺点 :需要额外的空间来存储计数器,难以检测出对象之间的循环引用。

可达性分析法

可达性分析法也被称之为根搜索法,可达性是指,如果一个对象会被至少一个在程序中的变量通过直接或间接的方式被其他可达的对象引用,则称该对象就是可达的。更准确的说,一个对象只有满足下述两个条件之一,就会被判断为可达的:

  • 对象是属于根集中的对象
  • 对象被一个可达的对象引用

在这里,我们引出了一个专有名词,即根集,其是指正在执行的 Java 程序可以访问的引用变量(注意,不是对象)的集合,程序可以使用引用变量访问对象的属性和调用对象的方法。在 JVM 中,会将以下对象标记为根集中的对象,具体包括:

  • 虚拟机栈(栈帧中的本地变量表)中引用的对象
  • 方法区中的常量引用的对象
  • 方法区中的类静态属性引用的对象
  • 本地方法栈中 JNI(Native 方法)的引用对象
  • 活跃线程(已启动且未停止的 Java 线程)

根集中的对象称之为GC Roots,也就是根对象。可达性分析法的基本思路是:将一系列的根对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链,如果一个对象到根对象没有任何引用链相连,那么这个对象就不是可达的,也称之为不可达对象。

202209032224552151.png
如上图所示,形象的展示了可达对象与不可达对象的示例,其中灰色的对象都是不可达对象,表示可以被垃圾收集的对象。在可达性分析法中,对象有两种状态,那么是可达的、要么是不可达的,在判断一个对象的可达性的时候,就需要对对象进行标记。关于标记阶段,有几个关键点是值得我们注意的,分别是:

  • 开始进行标记前,需要先暂停应用线程,否则如果对象图一直在变化的话是无法真正去遍历它的。暂停应用线程以便 JVM 可以尽情地收拾家务的这种情况又被称之为安全点(Safe Point),这会触发一次 Stop The World(STW)暂停。触发安全点的原因有许多,但最常见的应该就是垃圾回收了。

    • 安全点的选定基本上是以程序“是否具有让程序长时间执行的特征”为标准进行选定的。“长时间执行”的最明显特征就是指令序列复用,例如方法调用、循环跳转、异常跳转等,所以具有这些功能的指令才会产生安全点。对于安全点,另一个需要考虑的问题就是如何在 GC 发生时让所有线程(这里不包括执行 JNI 调用的线程)都“跑”到最近的安全点上再停顿下来。两种解决方案:

      • 抢先式中断(Preemptive Suspension) :抢先式中断不需要线程的执行代码主动去配合,在 GC 发生时,首先把所有线程全部中断,如果发现有线程中断的地方不在安全点上,就恢复线程,让它“跑”到安全点上。现在几乎没有虚拟机采用这种方式来暂停线程从而响应 GC 事件。
      • 主动式中断(Voluntary Suspension) :主动式中断的思想是当 GC 需要中断线程的时候,不直接对线程操作,仅仅简单地设置一个标志,各个线程执行时主动去轮询这个标志,发现中断标志为真时就自己中断挂起。轮询标志地地方和安全点是重合的,另外再加上创建对象需要分配内存的地方。
  • 暂停时间的长短并不取决于堆内对象的多少也不是堆的大小,而是存活对象的多少。因此,调高堆的大小并不会影响到标记阶段的时间长短。

  • 在根搜索算法中,要真正宣告一个对象死亡,至少要经历两次标记过程:

    • 如果对象在进行根搜索后发现没有与根对象相连接的引用链,那它会被第一次标记并且进行一次筛选。筛选的条件是此对象是否有必要执行 finalize()方法(可看作析构函数,类似于 OC 中的dealloc,Swift 中的deinit)。当对象没有覆盖finalize()方法,或finalize()方法已经被虚拟机调用过,虚拟机将这两种情况都视为没有必要执行。
    • 如果该对象被判定为有必要执行finalize()方法,那么这个对象将会被放置在一个名为F-Queue的队列中,并在稍后由一条由虚拟机自动建立的、低优先级的Finalizer线程去执行finalize()方法。finalize()方法是对象逃脱死亡命运的最后一次机会(因为一个对象的finalize()方法最多只会被系统自动调用一次),稍后 GC 将对F-Queue中的对象进行第二次小规模的标记,如果要在finalize()方法中成功拯救自己,只要在finalize()方法中让该对象重新引用链上的任何一个对象建立关联即可。而如果对象这时还没有关联到任何链上的引用,那它就会被回收掉。
  • GC 判断对象是否可达看的是强引用。

当标记阶段完成后,GC 开始进入下一阶段,删除不可达对象。当然,可达性分析法有优点也有缺点,

  • 优点 :可以解决循环引用的问题,不需要占用额外的空间
  • 缺点 :多线程场景下,其他线程可能会更新已经访问过的对象的引用

在上面的介绍中,我们多次提到了“引用”这个概念,在此我们不妨多了解一些引用的知识,在 Java 中有四种引用类型,分别为:

  • 强引用(Strong Reference) :如Object obj = new Object(),这类引用是 Java 程序中最普遍的。只要强引用还存在,垃圾收集器就永远不会回收掉被引用的对象。
  • 软引用(Soft Reference) :它用来描述一些可能还有用,但并非必须的对象。在系统内存不够用时,这类引用关联的对象将被垃圾收集器回收。JDK1.2 之后提供了SoftReference类来实现软引用。
  • 弱引用(Weak Reference) :它也是用来描述非必须对象的,但它的强度比软引用更弱些,被弱引用关联的对象只能生存到下一次垃圾收集发生之前。当垃圾收集器工作时,无论当前内存是否足够,都会回收掉只被弱引用关联的对象。在 JDK1.2 之后,提供了WeakReference类来实现弱引用。
  • 虚引用(Phantom Reference) :也称为幻引用,最弱的一种引用关系,完全不会对其生存时间构成影响,也无法通过虚引用来取得一个对象实例。为一个对象设置虚引用关联的唯一目的是希望能在这个对象被收集器回收时收到一个系统通知。JDK1.2 之后提供了PhantomReference类来实现虚引用。

垃圾回收

通过上面的介绍,我们已经知道了什么是垃圾以及如何判断一个对象是否是垃圾。那么接下来,我们就来了解如何回收垃圾,这就是垃圾回收算法和垃圾回收器需要做的事情了。

垃圾回收算法

标记-清除算法

标记-清除(Tracing Collector)算法是最基础的收集算法,为了解决引用计数法的问题而提出。它使用了根集的概念,它分为“标记”和“清除”两个阶段:首先标记出所需回收的对象,在标记完成后统一回收掉所有被标记的对象,它的标记过程其实就是前面的可达性分析法中判定垃圾对象的标记过程。

  • 优点 :不需要进行对象的移动,并且仅对不存活的对象进行处理,在存活对象比较多的情况下极为高效。
  • 缺点 :标记和清除过程的效率都不高,这种方法需要使用一个空闲列表来记录所有的空闲区域以及大小,对空闲列表的管理会增加分配对象时的工作量;标记清除后会产生大量不连续的内存碎片,虽然空闲区域的大小是足够的,但却可能没有一个单一区域能够满足这次分配所需的大小,因此本次分配还是会失败,不得不触发另一次垃圾收集动作。

下图为“标记-清除”算法的示意图:

202209032224575372.png
下图为使用“标记-清除”算法回收前后的状态:

202209032224592703.png

标记-整理算法

标记-整理(Compacting Collector)算法标记的过程与“标记-清除”算法中的标记过程一样,但对标记后出的垃圾对象的处理情况有所不同,它不是直接对可回收对象进行清理,而是让所有的对象都向一端移动,然后直接清理掉端边界以外的内存。在基于“标记-整理”算法的收集器的实现中,一般增加句柄和句柄表。

  • 优点 :经过整理之后,新对象的分配只需要通过指针碰撞便能完成,比较简单;使用这种方法,空闲区域的位置是始终可知的,也不会再有碎片的问题了。
  • 缺点 :GC 暂停的时间会增长,因为你需要将所有的对象都拷贝到一个新的地方,还得更新它们的引用地址。

下图为“标记-整理”算法的示意图:

202209032225008164.png
下图为使用“标记-整理”算法回收前后的状态:

202209032225023705.png

复制算法

复制(Copying Collector)算法的提出是为了克服句柄的开销和解决堆碎片的垃圾回收。它将内存按容量分为大小相等的两块,每次只使用其中的一块(对象面),当这一块的内存用完了,就将还存活着的对象复制到另外一块内存上面(空闲面),然后再把已使用过的内存空间一次清理掉。

复制算法比较适合于新生代(短生存期的对象),在老年代(长生存期的对象)中,对象存活率比较高,如果执行较多的复制操作,效率将会变低,所以老年代一般会选用其他算法,如“标记-整理”算法。一种典型的基于复制算法的垃圾回收是stop-and-copy算法,它将堆分成对象区和空闲区,在对象区与空闲区的切换过程中,程序暂停执行。

  • 优点 :标记阶段和复制阶段可以同时进行;每次只对一块内存进行回收,运行高效;只需移动栈顶指针,按顺序分配内存即可,实现简单;内存回收时不用考虑内存碎片的出现。
  • 缺点 :需要一块能容纳下所有存活对象的额外的内存空间。因此,可一次性分配的最大内存缩小了一半。

下图为复制算法的示意图:

202209032225036516.png

下图为使用复制算法回收前后的状态:

202209032225049907.png

分代收集算法

分代收集(Generational Collector)算法的将堆内存划分为新生代、老年代和永久代。新生代又被进一步划分为 Eden 和 Survivor 区,其中 Survivor 由 FromSpace(Survivor0)和 ToSpace(Survivor1)组成。所有通过new创建的对象的内存都在堆中分配,其大小可以通过-Xmx-Xms来控制。分代收集,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,可以将不同生命周期的对象分代,不同的代采取不同的回收算法进行垃圾回收,以便提高回收效率。

202209032225064678.png

  • 新生代(Young Generation) :几乎所有新生成的对象首先都是放在年轻代的。新生代内存按照 8:1:1 的比例分为一个 Eden 区和两个 Survivor(Survivor0,Survivor1)区。大部分对象在 Eden 区中生成。当新对象生成,Eden 空间申请失败(因为空间不足等),则会发起一次 GC(Scavenge GC)。回收时先将 Eden 区存活对象复制到一个 Survivor0 区,然后清空 Eden 区,当这个 Survivor0 区也存放满了时,则将 Eden 区和 Survivor0 区存活对象复制到另一个 Survivor1 区,然后清空 Eden 和这个 Survivor0 区,此时 Survivor0 区是空的,然后将 Survivor0 区和 Survivor1 区交换,即保持 Survivor1 区为空, 如此往复。当 Survivor1 区不足以存放 Eden 和 Survivor0 的存活对象时,就将存活对象直接存放到老年代。当对象在 Survivor 区躲过一次 GC 的话,其对象年龄便会加 1,默认情况下,如果对象年龄达到 15 岁,就会移动到老年代中。若是老年代也满了就会触发一次 Full GC,也就是新生代、老年代都进行回收。新生代大小可以由-Xmn来控制,也可以用-XX:SurvivorRatio来控制 Eden 和 Survivor 的比例。
  • 老年代(Old Generation) :在新生代中经历了 N 次垃圾回收后仍然存活的对象,就会被放到年老代中。因此,可以认为年老代中存放的都是一些生命周期较长的对象。内存比新生代也大很多(大概比例是 1:2),当老年代内存满时触发 Major GC 即 Full GC,Full GC 发生频率比较低,老年代对象存活时间比较长,存活率高。一般来说,大对象会被直接分配到老年代。所谓的大对象是指需要大量连续存储空间的对象,最常见的一种大对象就是大数组。当然分配的规则并不是百分之百固定的,这要取决于当前使用的是哪种垃圾收集器组合和 JVM 的相关参数。
  • 永久代(Permanent Generation) :用于存放静态文件(class类、方法)和常量等。永久代对垃圾回收没有显著影响,但是有些应用可能动态生成或者调用一些class,例如 Hibernate 等,在这种时候需要设置一个比较大的持久代空间来存放这些运行过程中新增的类。对永久代的回收主要回收两部分内容:废弃常量和无用的类。永久代在 Java SE8 特性中已经被移除了,取而代之的是元空间(MetaSpace),因此也不会再出现java.lang.OutOfMemoryError: PermGen error的错误了。

特别地,在分代收集算法中,对象的存储具有以下特点:

  1. 对象优先在 Eden 区分配。
  2. 大对象直接进入老年代。
  3. 长期存活的对象将进入老年代,默认为 15 岁。

对于晋升老年代的分代年龄阈值,我们可以通过-XX:MaxTenuringThreshold参数进行控制。在这里,不知道大家有没有对这个默认的 15 岁分代年龄产生过疑惑,为什么不是 16 或者 17 呢?实际上,HotSpot 虚拟机的对象头其中一部分用于存储对象自身的运行时数据,如哈希码、GC 分代年龄、锁状态标志、线程持有的锁、偏向线程 ID、偏向时间戳等,这部分数据的长度在 32 位和 64 位的虚拟机(未开启压缩指针)中分别为 32bit 和 64bit,官方称它为Mark word

例如,在 32 位的 HotSpot 虚拟机中,如果对象处于未被锁定的状态下,那么Mark Word的 32bit 空间中 25bit 用于存储对象哈希码,4bit 用于存储对象分代年龄,2bit 用于存储锁标志位,1bit 固定为 0,其中对象的分代年龄占 4 位,也就是从00001111,而其值最大为 15,所以分代年龄也就不可能超过 15 这个数值了。

除此之外,我们再来简单了解一下 GC 的分类:

  • 新生代 GC(Minor GC / Scavenge GC) :发生在新生代的垃圾收集动作。因为 Java 对象大多都具有朝生夕灭的特性,因此 Minor GC 非常频繁(不一定等 Eden 区满了才触发),一般回收速度也比较快。在新生代中,每次垃圾收集时都会发现有大量对象死去,只有少量存活,因此可选用复制算法来完成收集。
  • 老年代 GC(Major GC / Full GC) :发生在老年代的垃圾回收动作。Major GC 经常会伴随至少一次 Minor GC。由于老年代中的对象生命周期比较长,因此 Major GC 并不频繁,一般都是等待老年代满了后才进行 Full GC,而且其速度一般会比 Minor GC 慢10倍以上。另外,如果分配了 Direct Memory,在老年代中进行 Full GC 时,会顺便清理掉 Direct Memory 中的废弃对象。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用“标记-清除”算法或“标记-整理”算法来进行回收。

新生代采用空闲指针的方式来控制 GC 触发,指针保持最后一个分配的对象在新生代区间的位置,当有新的对象要分配内存时,用于检查空间是否足够,不够就触发 GC。当连续分配对象时,对象会逐渐从 Eden 到 Survivor,最后到老年代。

再多说一句,在某些场景下,老年代的对象可能引用新生代的对象,那标记存活对象的时候,需要扫描老年代中的所有对象。因为该对象拥有对新生代对象的引用,那么这个引用也会被称为GC Roots。那是不是要做全堆扫描呢?成本也太高了吧?

HotSpot 给出的解决方案是一项叫做卡表(Card Table)的技术,该技术将整个堆划分为一个个大小为 512 字节的卡,并且维护一个卡表,用来存储每张卡的一个标识位。这个标识位代表对应的卡是否可能存有指向新生代对象的引用。如果可能存在,那么我们就认为这张卡是脏的。

在进行 Minor GC 的时候,我们便可以不用扫描整个老年代,而是在卡表中寻找脏卡,并将脏卡中的对象加入到 Minor GC 的GC Roots里。当完成所有脏卡的扫描之后,Java 虚拟机便会将所有脏卡的标识位清零。

想要保证每个可能有指向新生代对象引用的卡都被标记为脏卡,那么 Java 虚拟机需要截获每个引用型实例变量的写操作,并作出对应的写标识位操作。

卡表能用于减少老年代的全堆空间扫描,这能很大的提升 GC 效率。

垃圾回收器

垃圾回收(GC)线程与应用线程保持相对独立,当系统需要执行垃圾回收任务时,先停止工作线程,然后命令 GC 线程工作。以串行模式工作的收集器,称为Serial Collector,即串行收集器;与之相对的是以并行模式工作的收集器,称为Paraller Collector,即并行收集器。

Serial 收集器

串行收集器采用单线程方式进行收集,且在 GC 线程工作时,系统不允许应用线程打扰。此时,应用程序进入暂停状态,即 Stop-the-world。Stop-the-world 暂停时间的长短,是衡量一款收集器性能高低的重要指标。Serial 是针对新生代的垃圾回收器,采用“复制”算法。

ParNew 收集器

并行收集器充分利用了多处理器的优势,采用多个 GC 线程并行收集。可想而知,多条 GC 线程执行显然比只使用一条 GC 线程执行的效率更高。一般来说,与串行收集器相比,在多处理器环境下工作的并行收集器能够极大地缩短 Stop-the-world 时间。ParNew 是针对新生代的垃圾回收器,采用“复制”算法,可以看成是 Serial 的多线程版本

Parallel Scavenge 收集器

Parallel Scavenge 是针对新生代的垃圾回收器,采用“复制”算法,和 ParNew 类似,但更注重吞吐率。在 ParNew 的基础上演化而来的 Parallel Scanvenge 收集器被誉为“吞吐量优先”收集器。吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)。如虚拟机总运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是99%

Parallel Scanvenge 收集器在 ParNew 的基础上提供了一组参数,用于配置期望的收集时间或吞吐量,然后以此为目标进行收集。通过 VM 选项可以控制吞吐量的大致范围:

  • -XX:MaxGCPauseMills:期望收集时间上限,用来控制收集对应用程序停顿的影响。
  • -XX:GCTimeRatio:期望的 GC 时间占总时间的比例,用来控制吞吐量。
  • -XX:UseAdaptiveSizePolicy:自动分代大小调节策略。

但要注意停顿时间与吞吐量这两个目标是相悖的,降低停顿时间的同时也会引起吞吐的降低。因此需要将目标控制在一个合理的范围中。

Serial Old 收集器

Serial Old 是 Serial 收集器的老年代版本,单线程收集器,采用“标记-整理”算法。这个收集器的主要意义也是在于给 Client 模式下的虚拟机使用。

Parallel Old 收集器

Parallel Old 是 Parallel Scanvenge 收集器的老年代版本,多线程收集器,采用“标记-整理”算法。

CMS收集器

CMS(Concurrent Mark Swee)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS 收集器仅作用于老年代的收集,采用“标记-清除”算法,它的运作过程分为 4 个步骤:

  • 初始标记(CMS initial mark)
  • 并发标记(CMS concurrent mark)
  • 重新标记(CMS remark)
  • 并发清除(CMS concurrent sweep)

其中,初始标记、重新标记这两个步骤仍然需要 Stop-the-world。初始标记仅仅只是标记一下GC Roots能直接关联到的对象,速度很快,并发标记阶段就是进行GC Roots Tracing的过程,而重新标记阶段则是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始阶段稍长一些,但远比并发标记的时间短。

CMS 以流水线方式拆分了收集周期,将耗时长的操作单元保持与应用线程并发执行。只将那些必需 STW 才能执行的操作单元单独拎出来,控制这些单元在恰当的时机运行,并能保证仅需短暂的时间就可以完成。这样,在整个收集周期内,只有两次短暂的暂停(初始标记和重新标记),达到了近似并发的目的。

  • CMS 收集器优点:并发收集,低停顿。

  • CMS 收集器缺点:

    • CMS 收集器对 CPU 资源非常敏感;
    • CMS 收集器无法处理浮动垃圾;
    • CMS 收集器是基于“标记-清除”算法,该算法的缺点都有。

CMS 收集器之所以能够做到并发,根本原因在于采用基于“标记-清除”的算法并对算法过程进行了细粒度的分解。前面已经介绍过“标记-清除”算法将产生大量的内存碎片这对新生代来说是难以接受的,因此新生代的收集器并未提供 CMS 版本。

G1 收集器

G1(Garbage First)重新定义了堆空间,打破了原有的分代模型,将堆划分为一个个区域。这么做的目的是在进行收集时不必在全堆范围内进行,这是它最显著的特点。区域划分的好处就是带来了停顿时间可预测的收集模型:用户可以指定收集操作在多长时间内完成,即 G1 提供了接近实时的收集特性。G1 与 CMS 的特征对比如下:

特征 G1 CMS
并发和分代
最大化释放堆内存
低延时
吞吐量
可预测性
新生代和老年代的物理隔离
使用范围 新生代和老年代 老年代

G1 具备如下特点:

  • 并行与并发 :G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU 来缩短 Stop-the-world 停顿的时间,部分其他收集器原来需要停顿 Java 线程执行的 GC 操作,G1 收集器仍然可以通过并发的方式让 Java 程序继续运行。
  • 分代收集 :打破了原有的分代模型,将堆划分为一个个区域。
  • 空间整合 :与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器,从局部(两个 Region 之间)上来看是基于“复制”算法实现的。但无论如何,这两种算法都意味着 G1 运作期间不会产生内存空间碎片,收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。
  • 可预测的停顿 :这是 G1 相对于 CMS 的一个优势,降低停顿时间是 G1 和 CMS 共同的关注点。

在 G1 之前的其他收集器进行收集的范围都是整个新生代或者老年代,而 G1 不再是这样。在堆的结构设计时,G1 打破了以往将收集范围固定在新生代或老年代的模式,G1 将堆分成许多相同大小的区域单元,每个单元称为 Region,Region 是一块地址连续的内存空间,G1 模块的组成如下图所示:

202209032225083969.png

堆内存会被切分成为很多个固定大小的 Region,每个是连续范围的虚拟内存。堆内存中一个 Region 的大小可以通过-XX:G1HeapRegionSize参数指定,其区间最小为 1M、最大为 32M,默认把堆内存按照 2048 份均分。

每个 Region 被标记了 E、S、O 和 H,这些区域在逻辑上被映射为 Eden,Survivor 和老年代。存活的对象从一个区域转移(即复制或移动)到另一个区域,区域被设计为并行收集垃圾,可能会暂停所有应用线程。

如上图所示,区域可以分配到 Eden,Survivor 和老年代。此外,还有第四种类型,被称为巨型区域(Humongous Region)。Humongous 区域是为了那些存储超过 50% 标准 Region 大小的对象而设计的,它用来专门存放巨型对象。如果一个 H 区装不下一个巨型对象,那么 G1 会寻找连续的 H 分区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC。

G1 收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。G1 会通过一个合理的计算模型,计算出每个 Region 的收集成本并量化,这样一来,收集器在给定了“停顿”时间限制的情况下,总是能选择一组恰当的 Region 作为收集目标,让其收集开销满足这个限制条件,以此达到实时收集的目的。

对于打算从 CMS 或者 ParallelOld 收集器迁移过来的应用,按照官方的建议,如果发现符合如下特征,可以考虑更换成 G1 收集器以追求更佳性能:

  • 实时数据占用了超过半数的堆空间;
  • 对象分配率或“晋升”的速度变化明显;
  • 期望消除耗时较长的GC或停顿(超过 0.5 ~ 1 秒)。

G1 收集的运作过程大致如下:

  • 初始标记(Initial Marking) :仅仅只是标记一下GC Roots能直接关联到的对象,并且修改 TAMS(Next Top at Mark Start)的值,让下一阶段用户程序并发运行时,能在正确可用的 Region 中创建新对象,这阶段需要停顿线程,但耗时很短。
  • 并发标记(Concurrent Marking) :是从GC Roots开始堆中对象进行可达性分析,找出存活的对象,这阶段耗时较长,但可与用户程序并发执行。
  • 最终标记(Final Marking) :是为了修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中,这阶段需要停顿线程,但是可并行执行。
  • 筛选回收(Live Data Counting and Evacuation) :首先对各个 Region 的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。这个阶段也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅提高收集效率。

G1 的 GC 模式可以分为两种,分别为:

  • Young GC :在分配一般对象(非巨型对象)时,当所有 Eden 区域使用达到最大阀值并且无法申请足够内存时,会触发一次 YoungGC。每次 Young GC 会回收所有 Eden 以及 Survivor 区,并且将存活对象复制到 Old 区以及另一部分的 Survivor 区。
  • Mixed GC :当越来越多的对象晋升到老年代时,为了避免堆内存被耗尽,虚拟机会触发一个混合的垃圾收集器,即 Mixed GC,该算法并不是一个 Old GC,除了回收整个新生代,还会回收一部分的老年代,这里需要注意:是一部分老年代,而不是全部老年代,可以选择哪些 Old 区域进行收集,从而可以对垃圾回收的耗时时间进行控制。G1 没有 Full GC概念,需要 Full GC 时,调用 Serial Old GC 进行全堆扫描。

查看 JVM 使用的默认垃圾收集器

在 Mac 终端或者 Windows 的 CMD 执行如下命令:

  • java -XX:+PrintCommandLineFlags -version

以我的电脑为例,执行结果为:

2022090322251090910.png
在此,给出垃圾收集相关的常用参数及其含义:

参数 含义
UseSerialGC 虚拟机运行在Client模式下的默认值,打开次开关后,使用Serial+SerialOld的收集器组合进行内存回收
UseParNewGC 打开次开关后,使用ParNew+SerialOld的收集器组合进行内存回收
UseConcMarkSweepGC 打开次开关后,使用ParNew+CMS+SerialOld的收集器组合进行内存回收,SerialOld收集器将作为CMS收集器出现ConcurrentModeFailure失败后的备用收集器使用
UseParallelGC 虚拟机运行在Server模式下的默认值,打开此开关后,使用ParallelScavenge+SerialOld(PSMarkSweep)的收集器组合进行内存回收
UseParallelOldGC 打开此开关后,使用ParallelScavenge+ParallelOld的收集器组合进行内存回收

由此可知,JDK 8 默认打开了UseParallelGC参数,因此使用了Parallel Scavenge + Serial Old的收集器组合进行内存回收。

到这里,关于 JVM 垃圾回收机制及其实现原理,我们就讲完了,希望能够对大家有所帮助!

参考资料