Java 虚拟机入门教程之二 JVM 垃圾收集

大纲

JVM 的垃圾收集机制

确定对象是否可以回收

在 Java 中,如何确定一个对象是否可以回收呢?常用的算法有两种:引用计数算法、可达性分析算法。

引用计数算法

引用计数算法(Reference Counting)是通过判断对象的引用数量来决定对象是否可以被回收。它的思路是给对象中添加一个引用计数器,每当有一个地方引用它时,计数器值就加 1;当引用失效时,计数器值就减 1;任何时刻计数器为 0 的对象就是不可能再被使用的。大部分场景下,这个算法都是不错,效率也比较高;但是 Java 虚拟机里面没有选用引用计数算法来管理内存,其中最主要的原因是它很难解决对象之间相互循环引用的问题;而且对对象赋值时均要维护引用计数器,同时计数器本身也有一定的消耗。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
/**
* 引用计数算法的缺陷
*/
public class ReferenceCountingGC {
public Object instance = null;

public static final int _1MB = 1024 * 1024;

/**
* 占点内存,以便GC日志观看
*/
private byte[] bigSize = new byte[2 * _1MB];

public static void main(String[] args) {
testGC();
}

public static void testGC() {
ReferenceCountingGC objA = new ReferenceCountingGC();
ReferenceCountingGC objB = new ReferenceCountingGC();
objA.instance = objB;
objB.instance = objA;

objA = null;
objB = null;

//这里发生GC, objA 和 objB能否被回收?
System.gc();
}
}

上述代码最后面两句将 objA 和 objB 赋值为 null,也就是说 objA 和 objB 指向的对象已经不可能再被访问,但是由于它们互相引用对方,导致它们的引用计数器都不为 0,那么垃圾收集器就永远不会回收它们。

可达性分析算法

可达性分析算法(Reachability Analysis)用于判断对象的引用链是否可达。它的思路是:通过一系列的称为 “GC Roots” 的对象作为起始点,从这些节点开始向下搜索,搜索所走过的路径称为引用链(Reference Chain),当一个对象到 GC Roots 没有任何引用链相连(用图论的话来说就是从 GC Roots 到这个对象不可达)时,则证明此对象是不可用的,如图所示:

jvm-reachability-analysis

在 Java 中,可作为 GC Root 的对象包括以下几种:

  • 在 Java 虚拟机栈(栈帧中的本地变量表)中引用的对象,例如各个线程被调用的方法栈中使用到的参数、局部变量、临时变量等。
  • 在方法区中类静态属性引用的对象,例如 Java 类的引用类型静态变量。
  • 在方法区中常量引用的对象,例如字符串常量池(String Table)里的引用。
  • 在本地方法栈中 JNI(即通常所说的 Native 方法)引用的对象。
  • Java 虚拟机内部的引用,如基本数据类型对应的 Class 对象,一些常驻的异常对象(比如 NullPointExcepiton、OutOfMemoryError)等,还有系统类加载器。
  • 所有被同步锁(synchronized 关键字)持有的对象。
  • 反映 Java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。

除了这些固定的 GC Roots 集合以外,根据用户所选用的垃圾收集器以及当前回收的内存区域不 同,还可以有其他对象 “临时性” 地加入,共同构成完整 GC Roots 集合。

GC 算法基础

垃圾收集算法主要有:复制算法(Copying)、标记 - 清除算法(Mark-Sweep)、标记 - 整理算法(Mark-Compact)、分代收集算法(Generational Collection)。

标记 - 清除算法

“标记 - 清除” 算法是最基础的算法,它分为 “标记” 和” 清除” 两个阶段:首先标记出所有需要回收的对象,在标记完成后统一回收所有被标记的对象。它有两个不足:一个是效率问题,标记和清除两个过程的效率都不高(两次扫描,耗时严重);另一个是空间问题,标记清除之后会产生大量的不连续的内存碎片,内存碎片太多可能会导致以后在程序运行过程中需要分配较大对象时,无法找到足够的连续内存从而不得不提前触发另一次垃圾收集动作。

jvm-biaoji-qingchu

复制算法

为了解决效率问题,一种称为 “复制”(Copying)的收集算法出现了,它将可用内存按容量划分为大小相等的两块,每次只使用其中的一块。当这块的内存用完了,就将还存活着的对象复制到另一块上面,然后再把已使用过的内存空间一次清理掉。这样使得每次都是对整个半区进行内存回收,内存分配时也就不用考虑内存碎片等复杂情况,只要移动堆顶指针,按顺序分配内存即可,实现简单,运行高效。

jvm-copy

将现有的内存空间分为两快,每次只使用其中一块,在垃圾收集时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,完成垃圾收集。如果系统中的垃圾对象很多,复制算法需要复制的存活对象数量并不会太大。因此在真正需要垃圾收集的时刻,复制算法的效率是很高的。又由于对象在垃圾收集过程中统一被复制到新的内存空间中,因此,可确保回收后的内存空间是没有碎片的。复制算法的高效性是建立在存活对象少、垃圾对象多的前提下的。这种情况在新生代经常发生,但是在老年代更常见的情况是大部分对象都是存活对象。如果依然使用复制算法,由于存活的对象较多,复制的成本也将很高。该算法的缺点是将系统内存折半。

Java 虚拟机的新生代串行垃圾收集器中使用了复制算法的思想。新生代分为 Eden 空间、From Survivor 空间、To Survivor 空间。其中 From Survivor 空间和 To Survivor 空间可以视为用于复制的两块大小相同、地位相等,且可进行角色互换的空间块。From Survivor 和 To Survivor 空间也称为 Survivor 空间,即幸存者空间,用于存放未被回收的对象。在垃圾收集时,Eden 空间中的存活对象会被复制到未使用的 Survivor 空间中(假设是 To Survivor),正在使用的 Survivor 空间(假设是 From) 中的年轻对象也会被复制到 To Survivor 空间中 (大对象或者老年对象会直接进入老年代,如果 To Survivor 空间已满,则对象也会直接进入老年代)。此时,Eden 空间和 From Survivor 空间中的剩余对象就是垃圾对象,可以直接清空,To Survivor 空间则存放此次回收后的存活对象。这种改进的复制算法既保证了空间的连续性,又避免了大量的内存空间浪费。

标记 - 整理算法

复制收集算法在对象存活率较高时就要进行较多的复制操作,效率将会变低。如果不想浪费 50% 的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都 100% 存活的极端情况,所以老年代不能直接选用这种算法。标记整理算法中,标记过程仍然与 “标记 - 清除” 算法一样,但是后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。值得一提的是,” 标记 - 整理” 算法又叫 “标记 - 压缩” 算法。

jvm-biaoji-zhengli

GC 算法进阶

分代收集算法

对于一个大型的系统,当创建的对象和方法变量比较多时,堆内存中的对象也会比较多,如果逐一分析对象是否该回收,那么势必造成效率低下。分代收集算法是基于这样一个事实:不同的对象的生命周期(存活情况)是不一样的,而不同生命周期的对象位于堆内存中不同的区域,因此对堆内存不同区域采用不同的策略进行回收可以提高 JVM 的执行效率。“分代收集”(Generational Collection)算法,根据对象存活周期的不同将内存划分为几块。一般是把 Java 堆分为新生代和老年代,这样既可以根据各个年代的特点采用最适当的收集算法。在新生代中,每次垃圾收集时都发现有大批对象死去,只有少量存活,那就选用复制算法,只需要付出少量对象的复制成本就可以完成收集。而老年代中因为对象存活率高、没有额外空间对它进行分配担保,就必须使用 “标记 - 清除” 或者 “标记 - 整理” 算法来进行回收。

GC 算法对比

复制算法:

  • 复制算法执行的速度较快,典型的空间换时间
  • 当对象的存活率很高的时候,不断的复制操作会显得耗时
  • 复制算法很明显的缺点就是浪费内存空间,因为将内存分为两块,一次只能使用一块,这也意味着分的块越大,浪费的内存越多

标记 - 清除算法:

  • 首先是速度慢,因为” 标记 - 清除算法” 在标记阶段需要使用递归的方式从根结点出发,不断寻找可达的对象;而在清除阶段又需要遍历堆内存中的所有对象,查看其是否被标记,然后再清除;并且在程序进行 GC 的时候,JVM 中所有的 Java 程序都要进行暂停,俗称 Stop-The-World,后面会提到。
  • 其次是其最大的缺点,使用这种算法进行清理而得的堆内存的空闲空间一般是不连续的,由于对象实例在堆内存中是随机存储的,所以在清理之后,会产生许多的内存碎片,如果这个时候来了一个很大的对象实例,尽管显示内存还足够,但是已经存不下这个大对象了,内存碎片太多会导致当程序需要为较大对象分配内存时无法找到足够的连续内存而不得不提前触发另一次垃圾收集动作。再者,这种零散的碎片对于数组的分配也不是很方便。

标记 - 整理算法:

  • 首先这种算法克服了” 标记 - 清除算法” 中会产生内存碎片的缺点,也解决了复制算法中内存减半使用的不足
  • 而其缺点则是速度也不是很快,不仅要遍历标记所有可达结点,还要一个个整理可达存活对象的地址,所以导致其效率不是很高

不同 GC 算法的适用场景

GC (Generational Collection) 是分代收集,分别有 Minor GC 和 Full GC 两种类型,主要发生在 JVM 的堆内存中,其使用的 GC 算法如下:

  • Minor GC:次数上频繁收集新生代,一般采用 "复制" 算法来进行垃圾收集
  • Full GC:次数上频繁收集老年代,一般采用 "标记 - 清除" 或者 "标记 - 压缩" 算法来进行垃圾收集
  • 以上两种 GC 类型基本上都不会动永久代(方法区)

内存分配策略

Java 的自动内存管理,最终可以归结为自动化地解决了两个问题:给对象分配内存、回收分配给对象的内存。对象的内存分配通常是在堆上分配(除此以外还有可能经过 JIT 编译后被拆散为标量类型并间接地在栈上分配),对象主要分配在新生代的 Eden 空间上,如果启动了本地线程分配缓冲,将按线程优先在 TLAB 上分配。少数情况下也可能会直接分配在老年代中,分配的规则并不是固定的,实际取决于垃圾收集器的具体组合以及虚拟机中与内存相关的参数的设置。下面以使用 Serial/Serial Old 收集器,介绍内存分配的策略。

对象优先在 Eden 空间分配

大多数情况下,对象在新生代的 Eden 空间中分配,当 Eden 空间没有足够空间进行分配时,虚拟机将发起一次 Minor GC。

大对象直接进入老年代

所谓的大对象是指需要大量连续内存空间的 Java 对象,最典型的大对象就是很长的字符串以及数组。大对象对虚拟机的内存分配来说是一个坏消息(尤其是遇到朝生夕灭的 “短命大对象”,写程序时应避免),经常出现大对象容易导致内存还有不少空间时,就提前触发 GC 以获取足够的连续内存空间来安置它们。虚拟机提供了一个 -XX:PretenureSizeThreshold 参数,令大小超过这个设置值的对象直接在老年代分配。这样做的目的是避免在 Eden 空间及两个 Survivor 空间之间发生大量的内存复制(新生代采用复制算法来回收内存)。

长期存活的对象将进入老年代

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。为了做到这点,虚拟机给每个对象定义了一个对象年龄(Age)计数器。如果对象在 Eden 空间出生并经过第一次 Minor GC 后仍然存活,并且能被 Survivor 空间容纳的话,将被移动到 Survivor 空间中,并且对象年龄设为 1。对象在 Survivor 空间中每 “熬过” 一次 Minor GC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就将会被晋升到老年代中。对象晋升老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 设置。

动态对象年龄判定

为了能更好地适应不同程序的内存状况,虚拟机并不是永远地要求对象的年龄必须达到了 MaxTenuringThreshold 才能晋升老年代,如果在 Survivor 空间中相同年龄所有对象大小的总和大于 Survivor 空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代,无须等到 MaxTenuringThreshold 中要求的年龄。

空间分配担保

在发生 Minor GC 之前,虚拟机会先检查老年代最大可用的连续内存空间是否大于新生代所有对象总空间,如果这个条件成立,那么 Minor GC 可以确保是安全的。如果不成立,则虚拟机会查看 HandlePromotionFailure 的设置值是否允许担保失败。如果允许,那么会继续检查老年代最大可用的连续内存空间是否大于历次晋升到老年代对象的平均大小;如果大于,将尝试着进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于或者 HandlePromotionFailure 的设置不允许冒险,那这时也要改为进行一次 Full GC。新生代使用复制算法,但为了内存利用率,只使用其中一个 Survivor 空间来作为轮换备份,因此当出现大量对象在 Minor GC 后仍然存活的情况(最极端的情况就是内存回收后新生代中所有对象都存活),就需要老年代进行分配担保,把 Survivor 无法容纳的对象直接进入老年代。与生活中的贷款担保类似,老年代要进行这样的担保,前提是老年代本身还有容纳这些对象的剩余空间,一共有多少对象会活下来在实际完成内存回收之前是无法明确知道的,所以只好取之前每一次回收晋升到老年代对象容量的平均大小值作为经验值,与老年代的剩余空间进行比较,决定是否进行 Full GC 来让老年代腾出更多空间。使用平均值进行比较其实仍然是一种动态概率的手段,也就是说,如果某次 Minor GC 存活后的对象突增,远远高于平均值的话,依然会导致担保失败(Handle Promotion Failure)。如果出现了 HandlePromotionFailure 失败,那就只好在失败后重新发起一次 Full GC。虽然担保失败时绕的圈子是最大的,但大部分情况下都还是会将 HandlePromotionFailure 开关打开,避免 Full GC 过于频繁。

GC 的触发条件

Minor GC 的触发条件

对于 Minor GC,其触发条件非常简单,当新生代的 Eden 空间满时,就将触发一次 Minor GC。

Full GC 的触发条件

调用 System.gc ()

System.gc() 方法的作用是建议 JVM 进行 Full GC,虽然只是建议而非一定,但很多情况下它会触发 Full GC,从而增加 Full GC 的频率,也即增加了间歇性停顿的次数。因此强烈建议能不使用此方法就不要使用,让虚拟机自己去管理它的内存,可通过 -XX:+ DisableExplicitGC 来禁止 RMI 调用 System.gc()

老年代空间不足

老年代空间不足的常见场景为大对象直接进入老年代、长期存活的对象进入老年代等,当执行 Full GC 后空间仍然不足,则抛出如下错误: Java.lang.OutOfMemoryError: Java heap space,为避免以上两种状况引起的 Full GC,调优时应尽量做到让对象在 Minor GC 阶段被回收、让对象在新生代多存活一段时间及不要创建过大的对象及数组。

空间分配担保失败

在新生代使用复制算法的 Minor GC,需要老年代的内存空间作担保,如果出现了 HandlePromotionFailure 担保失败,则会触发 Full GC。

Concurrent Mode Failure

执行 CMS GC 的过程中同时有对象要放入老年代,而此时老年代空间不足(有时候空间不足是由于 CMS GC 执行时,当前的浮动垃圾过多导致暂时性的空间不足触发 Full GC),便会报 Concurrent Mode Failure 错误,并触发 Full GC。

JDK 1.7 及以前的永久代空间不足

在 JDK 1.7 及以前,HotSpot 虚拟机中的方法区是用永久代实现的,永久代中存放的为一些类信息、静态变量、常量、常量池等数据,当系统中要加载的类、反射的类和调用的方法较多时,Permanet Generation 可能会被占满,在未配置为采用 CMS GC 的情况下也会执行 Full GC。如果经过 Full GC 仍然回收不了,那么 JVM 会抛出错误信息 java.lang.OutOfMemoryError: PermGen space,为避免 Permanet Generation 占满造成 Full GC 现象,可采用的方法为增大 Permanet Generation 空间或转为使用 CMS GC。在 JDK 1.8 中用元空间替换了永久代作为方法区的实现,元空间是本地内存,因此减少了一种 Full GC 触发的可能性。

  • Java 虚拟机规范里,使用方法区作为默认实现
  • JDK 1.7 及以前,HotSpot 虚拟机中的方法区使用永久代实现
  • JDK 1.8,HotSpot 虚拟机中的方法区使用元空间实现

JVM 的垃圾收集器

如果说垃圾收集算法是内存回收的方法论,那么垃圾收集器就是垃圾收集算法的具体实现。GC 的部分参数说明如下,可用于分析 -XX:+PrintGCDetails 打印出的 GC 详细信息:

  • DefNew:Default New Generation
  • Tenured:Old
  • ParNew:Parallel New Generation
  • PSYoungGen:Parallel Scavenge
  • ParOldGen:Parallel Old Generation

概念理解

吞吐量

  • 吞吐量就是 CPU 用于运行用户代码的时间与 CPU 总消耗时间的比值,即:吞吐量 = 运行用户代码时间 /(运行用户代码时间 + 垃圾收集时间)
  • 虚拟机总共运行了 100 分钟,其中垃圾收集花掉 1 分钟,那吞吐量就是 99%
  • 高吞吐量意味着可以高效率地利用 CPU 的时间,尽快完成程序的运算任务,适合用于后台运算而不需要太多交互的任务

并发和并行

  • 这两个名词都是并发编程中的概念,在谈论垃圾收集器的上下文语境中,它们的解释如下
  • 并行(Parallel):指多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态
  • 并发(Concurrent):指用户线程与垃圾收集线程同时执行(但不一定是并行的,可能会交替执行),用户线程在继续运行,而垃圾收集程序运行于另一个 CPU 核上

Stop-The-World 现象

在垃圾收集器执行时,比如 Serial、Parallel 垃圾收集器,所有正在执行中的 Java 程序都会被挂起(被暂停),只有 Native 方法可以执行,但是也不能和 JVM 进行交互,这样一来似乎整个 Java 世界都停止了,这也就是为什么叫做 “Stop-The-World (STW)”;等到 GC 程序执行完毕后,Java 程序才会重新恢复执行。这个其实很好理解,因为 GC 程序是一个线程,Java 程序也是一个线程,它们操作的堆内存是一片共享的区域。假设一种情况,Java 程序 A 新建了一个对象 object,new Object() 被存放在堆内存,但是很不巧的是,堆内存刚刚执行过复制算法,前一步存活的对象已经被转移到另一块空间了,而 new Object() 就留在了原来的空间,无辜地被清除了。这显然是不可接受的,因为线程不安全。

提示

Java 虚拟机提供的 7 种默认垃圾收集器都存在 "Stop-The-World (STW)" 问题,只是不同垃圾收集器之间,对应用程序暂停运行时间的控制(优化)不一样,比如 CMS 与 G1 收集器极大减少了暂停应用程序的运行时间。

Minor GC 和 Full GC

  • 新生代 GC(Minor GC):指发生在新生代的垃圾收集动作,因为 Java 对象大多都具备朝生夕灭的特性,所以 Minor GC 非常频繁,一般回收速度也比较快
  • 老年代 GC(Full GC / Major GC):指发生在老年代的 GC,出现 Full GC 的时候,经常会伴随至少一次的 Minor GC(但非绝对的,在 Parallel Scavenge 垃圾收集器的收集策略里就有直接执行了 Full GC 的策略选择过程)。Full GC 的速度一般会比 Minor GC 慢 10 倍以上

Minor GC 的执行过程

JVM 的堆内存从 GC 的角度可以细分为:新生代(包括 Eden 区、SurvivorFrom 区和 SurvivorTo 区)和老年代,其中新生代占 1/3 堆空间,老年代占 2/3 堆空间,如下图所示:

第一步:Eden、SurvivorFrom 复制到 SurvivorTo,且将对象的年龄加一

首先,当 Eden 区满的时候会触发第一次 GC ,将还活着的对象拷贝到 SurvivorFrom 区。当 Eden 区再次触发 GC 的时候,会扫描 Eden 区和 SurvivorFrom 区,对这两个区域进行垃圾回收,经过这次回收后还存活的对象,则直接拷贝到 SurvivorTo 区域(如果有对象的年龄已经达到了老年的标准,则拷贝到老年代区),同时将这些对象的年龄加一。

第二步:清空 Eden、SurvivorFrom

然后,清空 Eden 和 SurvivorFrom 中的对象,而且在拷贝之后 SurvivorFrom 区和 SurvivorTo 区交换角色,谁空谁就是 SurvivorTo 区。

第三步:SurvivorFrom 和 SurvivorTo 交换

最后,SurvivorFrom 区和 SurvivorTo 区交换角色,原 SurvivorTo 成为下一次 GC 时的 SurvivorFrom 区。部分对象会在 SurvivorFrom 区和 SurvivorTo 区中复制来复制去,如此交换 15 次(由 JVM 参数 Maxenuringhreshold 决定,这个参数值默认是 15)后,最终如果还是存活,就存入到老年代区中。

Server 和 Client 模式

在 Java 虚拟机(JVM)中,”Server” 和 “Client” 模式通常指的是两种不同的 JIT(Just-In-Time)编译器模式,这些模式会影响 Java 应用程序的性能和启动时间。

  • Client 模式:在 Client 模式下,JIT 编译器更加注重启动速度和内存占用。它会尽快编译代码以提供快速的启动,并且通常会选择更快的编译方法,但生成的代码可能不够优化。这种模式适用于客户端应用程序,例如桌面应用程序或移动应用程序,因为这些应用程序通常更关注启动时间和响应速度。

  • Server 模式:在 Server 模式下,JIT 编译器更加注重长期运行时的性能优化。它会花费更多时间来分析和优化代码,以提供更高的执行性能。尽管在启动时会有一些性能损失,但是随着应用程序的长期运行,Server 模式下的性能通常会比 Client 模式更好。这种模式适用于服务器端应用程序,例如 Web 服务器或大型企业应用程序,因为这些应用程序更关注长时间的稳定性和性能表现。

要在 Java 虚拟机中启用特定的模式,可以使用 -client-server 参数。例如:

  • java -client MyApp:启用客户端模式运行名为 MyApp 的 Java 应用程序
  • java -server MyApp:启用服务器模式运行名为 MyApp 的 Java 应用程序

特别注意

从 Java 9 开始,JIT 编译器已经改为使用 Graal 编译器,而不再有明确的 "Client" 和 "Server" 模式。不过,仍然可以通过 -XX:+UseJVMCICompiler-XX:-UseJVMCICompiler JVM 参数来选择使用 Graal 编译器,以及通过其他参数来调整编译器的行为和性能特性。

Server 和 Client 模式的使用范围

  • 一般使用 Server 模式,Client 模式基本不会使用。
  • 64 位的操作系统,只能使用 Server 模式,点击查看截图。
  • 32 位的 Window 操作系统,不论硬件如何都默认使用 Client 的 JVM 模式。
  • 32 位的其它操作系统(如 Linux),2G 内存同时有 2 个 cpu 以上使用 Server 模式,低于该配置还是使用 Client 模式。

收集器的分类

Java 虚拟机一共有 7 种不同类型的垃圾收集器,每种垃圾收集器都有自身的特性和适用场景。

常见的收集器

  • Serial

    • 串行垃圾收集器,它为单线程环境设计,只使用一个线程进行垃圾收集,会暂停所有的用户线程(通常称为 “Stop The World” 事件)。只有当垃圾回收完成时,才会重新唤醒主线程继续执行,所以不适合服务器环境。该垃圾收集器占用内存空间比较小,因此这是嵌入式应用程序的首选垃圾收集器类型,适用于能够承受短暂停顿的应用程序。
  • Parallel

    • 并行垃圾收集器,多个垃圾收集线程并行工作,此时用户线程也是阻塞的(通常称为 “Stop The World” 事件),适用于科学计算 / 大数据处理等弱交互场景。也就是说 Serial 和 Parallel 其实是类似的,不过是多了几个线程进行垃圾收集,但是主线程都会被暂停,但是并行垃圾收集器花费的处理时间,肯定比串行的垃圾收集器要更短。
  • CMS

    • 并发垃圾收集器(Concurrent Mark Sweep - 并发标记清除,简称 CMS),用户线程和垃圾收集线程同时执行(不一定是并行,有可能是交替执行),不需要暂停用户线程。并发是可以有交互的,也就是说可以一边进行垃圾收集,一边执行应用程序的代码。
    • 在 CMS 收集器运行的过程中,应用程序将暂停两次。首次暂停发生在标记可直接访问的存活对象时,这个暂停被称为 “初始标记”。第二次暂停发生在 CMS 收集器结束时期,用于修正在并发标记过程中,应用程序线程在 CMS 垃圾回收完成后更新对象时被遗漏的对象,这就是所谓的 “重新标记”。
    • CMS 收集器在以前被互联网公司使用得比较多,适用于对应用程序的响应时间有要求的场景。CMS 收集器在 Java 8 中已被标记为过时,并在 Java 14 中被移除。
  • G1

    • G1 收集器旨在替代 CMS 收集器,它的特点是支持并行、并发以及增量压缩,且暂停时间较短。与 CMS 收集器使用的内存布局不同,G1 收集器将堆内存划分为大小相同的区域,然后并发地进行垃圾回收。G1 收集器会使用多个线程,触发全局标记阶段。标记阶段完成后,G1 就知道哪个区域可能大部分是空的,并首选该区域作为清除 / 删除阶段。

新兴的收集器

  • Epsilon

    • 该垃圾收集器是在 Java 11 中引入的,是一个 no-op(无操作)收集器。它不做任何实际的内存回收,只负责管理内存分配。Epsilon 只在当用户知道应用程序的确切内存占用情况,并且不需要垃圾回收时才使用。
  • Shenandoah

    • 该垃圾收集器是在 JDK 12 中引入的,是一种 CPU 密集型垃圾收集器。它会进行内存压缩,立即删除无用对象并释放操作系统的内存空间,所有的这一切都是与应用程序线程并行发生。
  • ZGC

    • 该垃圾收集器为低延迟、大量堆空间使用的场景而设计,允许在垃圾收集器执行垃圾回收时,同时让 Java 应用程序继续运行。ZGC 收集器在 JDK 11 中引入,在 JDK 12 中改进。在 JDK 15 中,ZGC 和 Shenandoah 都被移出了实验阶段。

七种默认的收集器

Java 虚拟机默认提供了 7 种垃圾收集器,分别是:

  • UseSerialGC:串行垃圾收集器
  • UseParallelGC:并行垃圾收集器
  • UseConcMarkSweepGC:并发垃圾收集器,也叫并发标记清除垃圾收集器(CMS)
  • UseG1GC:G1 垃圾收集器
  • UseParNewGC:新生代的并行垃圾回收器
  • UseParallelOldGC:老年代的并行垃圾回收器
  • UseSerialOldGC:老年代的串行垃圾收集器(已经被官方移除)

不同垃圾收集器的组合搭配如下:

收集器的使用范围

Java 虚拟机规范中对垃圾收集器应该如何实现并没有任何规定,因此不同的厂商、不同版本的虚拟机所提供的垃圾收集器都可能会有很大差别,并且一般都会提供参数供用户根据自己的应用特点和要求组合出各个年代所使用的垃圾收集器。下面的左图展示了 HotSpot 中 7 种作用于不同分代的垃圾收集器,如果两个收集器之间存在连线,就说明它们可以搭配使用。垃圾收集器所处的区域,则表示它是属于新生代收集器还是老年代收集器。

jvm-gc-collector

  • 新生代使用的收集器

    • Serial (Serial Copying):串行垃圾回收器 (UseSerialGC)
    • Parallel Scavenge:并行垃圾收集器 (UseParallelGC)
    • ParNew:新生代的并行垃圾收集器 (UseParNewGC)
  • 老年代使用的收集器

    • Serial Old:老年代的串行垃圾收集器 (UseSerialOldGC)
    • Parallel Old (Parallel Compacting):老年代的并行垃圾收集器 (UseParallelOldGC)
    • CMS:并发垃圾收集器 (UseConcMarkSweepGC)
  • 新生代和老年代都能使用的收集器

    • G1:G1 垃圾收集器 (UseG1GC)

新生代的收集器

Serial 收集器

jvm-collector-serial

Serial 收集器是最基本、发展历史最悠久的收集器,曾经(在 JDK 1.3.1 之前)是虚拟机新生代垃圾收集的唯一选择。

特性:

这个收集器是一个单线程的收集器,但它的 “单线程” 的意义并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束,即会造成 “Stop The World” 现象。虽然在收集垃圾的过程中,需要暂停所有其它的工作线程,但是它简单高效的。对于限定单个 CPU 环境来说,没有线程交互的开销,可以获得最高的单线程垃圾收集效率。

优势:

简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Serial 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。

应用场景:

Serial 收集器是虚拟机运行在 Client 模式下的默认新生代收集器。

对应的 JVM 参数:

  • JVM 参数配置:-XX:UseSerialGC,启用 Serial 收集器
  • JVM 参数开启后:会使用 Serial (新生代用) + Serial Old (老年代用) 的收集器组合,也就是说,新生代和老年代都会使用串行垃圾收集器,而且新生代使用的是 “复制算法”,老年代使用的是 “标记 - 整理算法”

ParNew 收集器

jvm-collector-parNew

特性:

ParNew 收集器其实就是 Serial 收集器新生代的并行多线程版本,除了使用多条线程进行垃圾收集之外,其余行为包括 Serial 收集器可用的所有控制参数、收集算法、Stop The World、对象分配规则、回收策略等都与 Serial 收集器完全一样,在实现上,这两种收集器也共用了相当多的代码。

应用场景:

ParNew 收集器是许多运行在 Server 模式下的虚拟机中首选的新生代收集器。很重要的原因是:除了 Serial 收集器外,目前只有它能与老年代的 CMS 收集器配合工作。在 JDK 1.5 时期,HotSpot 推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器 - CMS 收集器,这款收集器是 HotSpot 虚拟机中第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程同时工作。不幸的是,CMS 作为老年代的收集器,却无法与 JDK 1.4.0 中已经存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDK 1.5 中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。

对应的 JVM 参数:

  • JVM 参数配置:-XX:+UseParNewGC,启用 ParNew 收集器
  • JVM 参数开启后:会使用 ParNew (新生代用) + Serial Old (老年代用) 的收集器组合,新生代使用的是 “复制算法”,老年代使用的是 “标记 - 整理算法”

ParNew 收集器 Vs Serial 收集器:

ParNew 收集器在单 CPU 的环境中绝对不会有比 Serial 收集器更好的效果,甚至由于存在线程交互的开销,该收集器在通过超线程技术实现的两个 CPU 的环境中都不能百分之百地保证可以超越 Serial 收集器。然而,随着可以使用的 CPU 的数量的增加,ParNew 收集器对于 GC 时系统资源的有效利用还是很有好处的。ParNew 收集器默认开启的收集线程数与 CPU 的数量相同,在 CPU 非常多的情况下,可使用 -XX:ParallerGCThreads JVM 参数指定执行 GC 的线程数。

已过时的收集器搭配组合,不推荐使用:

  • 从 Java 8 开始,ParNew + Serial Old 和 Serial + CMS 这样的收集器搭配组合,已经不推荐使用(如下图所示),且在使用时会出现警告信息
  • 从 Java 9 开始,ParNew 收集器被标记为已过时,并且在后续 JDK 版本中逐步被移除,在使用时会出现 Unrecognized VM option 'UseParNewGC' 的错误信息

Parallel Scavenge 收集器

特性:

Parallel Scavenge 收集器类似 ParNew 也是一个新生代收集器,使用复制算法,也是一个并行的多线程的收集器,俗称 “吞吐量优先收集器”。简而言之,目的是实现串行收集器在新生代的并行化。

应用场景:

停顿时间越短就越适合需要与用户交互的程序,良好的响应速度能提升用户体验,而高吞吐量则可以高效率地利用 CPU 的时间,尽快完成程序的运算任务,主要适合在后台运算而不需要太多交互的任务。

对应的 JVM 参数:

  • JVM 参数配置:-XX:+UseParallelGC 或者 -XX:+UseParallelOldGC(可以互相激活),启用 Parallel Scanvenge 收集器
  • JVM 参数开启后:会使用 Parallel Scavenge (新生代用) + Parallel Old (老年代用) 的收集器组合,新生代使用的是 “复制算法”,老年代使用的是 “标记 - 整理算法”

Parallel Scavenge 收集器 与 CMS 收集器:

Parallel Scavenge 收集器的特点是它的关注点与其他收集器不同,CMS 等收集器的关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而 Parallel Scavenge 收集器的目标是达到一个可控制的吞吐量(Throughput)。由于与吞吐量关系密切,这也是 Parallel Scavenge 收集器经常被称为 “吞吐量优先收集器” 的原因。值得注意的一点是,Parallel Scavenge 收集器无法与 CMS 收集器配合使用,所以在 JDK 1.6 推出 Parallel Old 收集器之前,如果新生代选择 Parallel Scavenge 收集器,那么老年代只有 Serial Old 收集器能与之配合使用。

Parallel Scavenge 收集器 Vs ParNew 收集器:

Parallel Scavenge 收集器与 ParNew 收集器的一个重要区别是,前者具有 GC 自适应调节策略特性。Parallel Scavenge 收集器除了会显而易见地提供可以精确控制吞吐量的参数,还提供了一个 JVM 参数 -XX:+UseAdaptiveSizePolicy,这是一个开关参数,打开参数后就不需要手工指定新生代的大小(-Xmn)、Eden 区和 Survivor 区的比例(-XX:SurvivorRatio)、晋升老年代的对象年龄(-XX:PretenureSizeThreshold)等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间(-XX:MaxGCPauseMills)或者最大的吞吐量,这种方式称为 GC 自适应调节策略(GC Ergonomics)。

GC 线程数控制

Parallel Scavenge 收集器也可以使用 -XX:ParallerGCThreads JVM 参数指定执行 GC 的线程数,默认的 GC 线程数设定规则如下:

  • 当 CPU 数量 > 8,GC 线程数等于 5/8
  • 当 CPU 数量 < 8,GC 线程数等于 CPU 数量

老年代的收集器

Serial Old 收集器

jvm-collector-serial-old

特性:

Serial Old 是 Serial 收集器的老年代版本,它同样是一个单线程收集器,使用的是 “标记 - 整理算法”。

应用场景:

  • Client 模式下,Serial Old 收集器的主要意义也是在于给 Client 模式下的虚拟机使用。
  • Server 模式下,主要有两大用途:一种用途是在 JDK 1.5 以及之前的版本中与 Parallel Scavenge 收集器搭配使用;另一种用途就是作为 CMS 收集器的担保机制,即在并发收集发生 “Concurrent Mode Failure” 错误时使用。

对应的 JVM 参数:

  • JVM 参数配置:-XX:+UseSerialOldlGC,启用 Serial Old 收集器

Serial Old 收集器已过时

从 JDK 9 开始,Serial Old 收集器被标记为已过时,并且在后续 JDK 版本中逐步被移除。这是因为出现了并发标记清除(CMS)和并行(Parallel)垃圾收集器等更先进的替代方案,Serial Old 收集器在性能和并发性方面逐渐被认为不再适用。

Parallel Old 收集器

jvm-collector-parallel-old

特性:

Parallel Old 是 Parallel Scavenge 收集器的老年代版本,使用多线程和 “标记 - 整理算法”,目的是在老年代同样提供吞吐量优先的垃圾收集器。

应用场景:

在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge 加 Parallel Old 收集器。

对应的 JVM 参数:

  • JVM 参数配置:-XX:+UseParallelOldGC 或者 -XX:+UseParallelGC(可以互相激活),启用 Parallel Old 收集器
  • JVM 参数开启后:会使用 Parallel Scavenge (新生代用) + Parallel Old (老年代用) 的收集器组合

不同版本 JDK 的收集器组合:

  • 在 JDK 1.6 之前,新生代使用 Parallel Scavenge 收集器时,只能搭配老年代的 Serial Old 收集器一起使用,这只能保证新生代的吞吐量优先,无法保证整体的吞吐量
  • 如果系统对吞吐量的要求比较高,在 JDK 1.8 后可以考虑 Parallel Scavenge (新生代收集器)和 Parallel Old (老年代收集器)的搭配组合

Parallel Old 收集器与 Parallel Scavenge 收集器:

Parallel Old 收集器是在 JDK 1.6 中才开始提供的,在此之前新生代的 Parallel Scavenge 收集器一直处于比较尴尬的状态。因为如果新生代选择了 Parallel Scavenge 收集器,老年代除了 Serial Old 收集器外别无选择(Parallel Scavenge 收集器无法与 CMS 收集器配合使用)。由于老年代 Serial Old 收集器在服务端应用性能上的 “拖累”,使用了 Parallel Scavenge 收集器也未必能在整体应用上获得吞吐量最大化的效果,由于单线程的老年代收集器(Serial Old)无法充分利用服务器多 CPU 的处理能力,在老年代很大而且硬件比较高级的环境中,这种收集器组合的吞吐量甚至还不一定有 ParNew 加 CMS 的组合 “给力”。直到 Parallel Old 收集器出现后,” 吞吐量优先” 收集器终于有了比较名副其实的应用组合,那就是 Parallel Scavenge + Parallel Old。

CMS 收集器

jvm-collector-cms

特性:

CMS(Concurrent Mark Sweep - 并发标记清除)收集器是一种以获取最短回收停顿时间为目标的收集器。CMS 非常适合堆内存大、CPU 核数多的服务器端应用,也是 G1 收集器出现之前大型应用的首选收集器。目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验,CMS 收集器就非常符合这类应用的需求。

工作流程:

CMS 收集器是基于 “标记 - 清除算法” 实现的,它的工作过程相对于前面几种收集器来说更复杂一些,整个过程分为以下 4 个步骤。由于在耗时最长的并发标记和并发清除过程中,垃圾收集线程可以和用户线程一起并发执行,所以从总体上来看,可以认为 CMS 收集器的内存回收线程和用户线程是一起并发执行的。

  • 初始标记(CMS initial mark):初始标记仅仅只是标记一下 GC Roots 能直接关联到的对象,速度很快,仍需要暂停所有用户线程(STW)。
  • 并发标记(CMS concurrent mark):执行 GC Roots 跟踪的过程,这阶段耗时较长,但可以和用户线程一起执行,不需要暂停用户线程。
  • 重新标记(CMS remark):重新标记阶段是为了修正并发标记期间因用户线程继续运作而导致标记产生变动的那一部分对象的标记记录,仍然需要暂停所有用户线程(STW),这个阶段的停顿时间一般会比初始标记阶段稍长一些,但远比并发标记的执行时间短。
  • 并发清除(CMS concurrent sweep):清除 GC Roots 不可达对象,和用户线程一起执行,不需要暂停用户线程。基于标记结果,直接清理对象。

优点:

  • CMS 是一款优秀的收集器,它的主要优点是并发收集、停顿时间短,因此 CMS 收集器也被称为 “并发低停顿收集器”(Concurrent Low Pause Collector)。

缺点:

  • CMS 收集器对 CPU 资源非常敏感,其实面向并发设计的程序都对 CPU 资源比较敏感。在并发阶段,它虽然不会导致用户线程停顿,但是会因为占用了一部分线程(或者说 CPU 资源)而导致应用程序变慢,总吞吐量会降低。CMS 默认启动的回收线程数是(CPU 数量 + 3)/ 4,也就是当 CPU 在 4 个以上时,并发回收时垃圾收集线程不少于 25% 的 CPU 资源,并且随着 CPU 数量的增加而下降。但是当 CPU 不足 4 个(例如 2 个)时,CMS 对用户线程的影响就可能变得很大。

  • CMS 收集器无法处理浮动垃圾,可能出现 “Concurrent Mode Failure” 失败而导致另一次 Full GC 的产生。由于 CMS 并发清理阶段用户线程还在运行着,伴随程序运行自然就还会有新的垃圾不断产生,这一部分垃圾出现在标记过程之后,CMS 无法在当次收集中处理掉它们,只好留待下一次 GC 时再清理掉,这一部分垃圾就称为 “浮动垃圾”。也是由于在垃圾收集阶段用户线程还需要运行,那也就还需要预留有足够的内存空间给用户线程使用,因此 CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,需要预留一部分空间提供给并发收集时的程序运作使用。要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次 “Concurrent Mode Failure” 失败,这时虚拟机将启动担保机制:临时启用 Serial Old 收集器来重新进行老年代的垃圾收集(Full GC),这样停顿时间就很长了,因为 Serial Old 是串行收集器,会导致 Stop-The-World 现象。

  • CMS 收集器会产生大量内存碎片。CMS 是一款基于 “标记 - 清除算法” 实现的收集器,而 “标记 - 清除算法” 无法整理内存碎片,这意味着垃圾收集结束时会有大量内存碎片产生。内存碎片过多时,将会给大对象分配带来很大麻烦,往往会出现老年代还有很大空间剩余,但是无法找到足够大的连续内存空间来分配当前对象,导致不得不提前触发一次 Full GC。也就是说,老年代的空间会随着应用的长时间运行被逐步耗尽,最后将不得不通过担保机制对堆内存进行整理(压缩)。CMS 也提供了参数 -XX:CMSFullGCSBeForeCompaction(默认 0,即每次都进行内存整理)来指定多少次 CMS 收集之后,进行一次压缩的 Full GC。

对应的 JVM 参数:

  • JVM 参数配置:-XX:+UseConcMarkSweepGC,启用 CMS 收集器
  • JVM 参数开启后:会自动启用 -XX:+UseParNewGC 参数,使用 ParNew (新生代用) + CMS (老年代用) + Serial Old (老年代用) 的收集器组合,Serial Old 收集器将作为 CMS 出错后的备用收集器

G1 收集器

jvm-collector-g1

G1(Garbage-First)收集器是当今收集器技术发展最前沿的成果之一,HotSpot 开发团队赋予它的使命是(在比较长期的)未来可以替换掉 JDK 1.5 中发布的 CMS 收集器。G1 是一款面向服务端应用的收集器,应用在多 CPU 和大内存服务器环境中,在实现高吞吐量的同时,尽可能缩短 Stop-The-World 停顿的时间。

以前收集器的共同特点

  • 新生代和老年代是各自独立且连续的内存块
  • 新生代收集使用单 Eden + S0 + S1 执行复制算法
  • 老年代收集必须扫描整个老年代区域
  • 都是以尽可能少而快速地执行 GC 为设计原则

G1 收集器的特点

  • 像 CMS 收集器一样,能与应用程序并发执行
  • 整理空闲空间更快
  • 需要更多的时间来预测 GC 停顿时间
  • 不希望牺牲大量的吞吐量性能
  • 不需要更大的 Java Heap

G1 与 CMS 收集器对比

G1 收集器设计目标是取代 CMS 收集器,它跟 CMS 相比,在以下方面表现的更出色:

  • G1 是一个有整理内存过程的垃圾收集器,不会产生很多内存碎片。
  • G1 的 Stop The World(STW)更可控,G1 在停顿时间上添加了预测机制,用户可以指定期望停顿时间。

G1 收集器的发展历史和改变

G1 是在 2012 年才在 JDK 1.7 中可用,Oracle 官方计划在 JDK 9 中将 G1 变成默认的垃圾收集器,逐步替换 JDK 1.8 以前的 CMS 收集器。G1 的主要改变是:Eden、Survivor 和 Tenured (Old) 等内存区域不再是连续的,而是变成一个个大小一样的 Region,每个 Region 从 1M 到 32M 不等。一个 Region 有可能属于 Eden、Survivor 或者 Tenured (Old) 内存区域。


特性:

  • 并行与并发:G1 能充分利用多 CPU、多核环境下的硬件优势,使用多个 CPU 来缩短 Stop-The-World 停顿的时间,部分其他收集器需要停顿 Java 线程来执行的 GC 动作,而 G1 收集器仍然可以通过并发的方式让 Java 程序继续执行。

  • 分代收集:与其他收集器一样,分代概念在 G1 中依然得以保留。虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但它能够采用不同的方式去处理新创建的对象和已经存活了一段时间、熬过多次 GC 的旧对象以获取更好的收集效果。

  • 空间整合:与 CMS 的 “标记 - 清除算法” 不同,G1 从整体来看是基于 “标记 - 整理算法” 实现的收集器,从局部(两个 Region 之间)上来看是基于 “复制” 算法实现的,但无论如何,这两种算法都意味着 G1 在运作期间不会产生内存碎片,垃圾收集后能提供规整的可用内存。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 Full GC。

  • 可预测的停顿:这是 G1 相对于 CMS 的另一大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。

横跨整个堆内存:

在 G1 之前的其他收集器进行收集的范围都是单独针对新生代或者老年代,而 G1 不再是这样。使用 G1 收集器时,Java 堆的内存布局就与其他收集器有很大差别,它将整个 Java 堆划分为多个大小相等的独立区域(Region),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合。

可预测时间模型:

G1 收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在整个 Java 堆中进行全区域的垃圾收集。G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的 Region(这也就是 Garbage-First 名称的来由)。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。

避免全堆扫描:

G1 把 Java 堆分为多个 Region,就是 “化整为零”。但是 Region 不可能是孤立的,一个对象分配在某个 Region 中,可以与整个 Java 堆任意的对象发生引用关系。在做可达性分析确定对象是否存活的时候,需要扫描整个 Java 堆才能保证准确性,这显然是对 GC 效率的极大伤害。为了避免全堆扫描的发生,虚拟机为 G1 中每个 Region 维护了一个与之对应的 Remembered Set。虚拟机发现程序在对 Reference 类型的数据进行写操作时,会产生一个 Write Barrier 暂时中断写操作,检查 Reference 引用的对象是否处于不同的 Region 之中(在分代的例子中就是检查是否老年代中的对象引用了新生代中的对象),如果是就通过 CardTable 把相关引用信息记录到被引用对象所属的 Region 的 Remembered Set 之中。当进行内存回收时,在 GC 根节点的枚举范围中加入 Remembered Set 即可保证不对全堆扫描也不会有遗漏。

区域化垃圾收集:

  • G1 将新生代、老年代的物理空间划分取消了,如上图所示。G1 将堆划分为若干个区域(Reign),它仍然属于分代收集器,这些 Region 的一部分包含新生代,而且新生代的垃圾收集依然采用暂停所有用户线程的方式(STW),将存活对象拷贝到 Survivor 空间或者老年代。另一部分的 Region 包含老年代,G1 通过将对象从一个区域复制到另外一个区域,完成了清理工作。这就意味着,在正常的处理过程中,G1 完成了堆的压缩(至少是部分堆的压缩),这样也就不会有 CMS 的内存碎片问题存在了。

  • 在 G1 中,还有一种特殊的区域,叫做 Humongous(巨大的)区域,如果一个对象占用了空间超过了分区容量 50% 以上,G1 收集器就认为这是一个巨型对象,这些巨型对象默认直接分配在老年代,但是如果它是一个短期存在的巨型对象,就会对垃圾收集器造成负面影响,为了解决这个问题,G1 划分了一个 Humongous 区,它用来专门存放巨型对象。如果一个 Humongous 区装不下一个巨型对象,那么 G1 会寻找连续的 Humongous 区来存储,为了能找到连续的 H 区,有时候不得不启动 Full GC。

底层原理:

  • G1 是 Region 区域化垃圾收集器,化整为零,打破了原来新生代和老年代的壁垒,避免了全内存扫描,只需要按照区域来进行扫描即可。区域化内存划片 Region,整体遍为了一些列不连续的内存区域,避免了全内存区的 GC 操作。核心思想是将整个堆内存区域分成大小相同的子区域(Region),在 JVM 启动时会自动设置子区域大小。

  • 在堆的使用上,G1 并不要求对象的存储一定是物理上连续的,只要逻辑上连续即可,每个分区(堆区)也不会固定地为某个年代服务,可以按需在新生代和老年代之间切换。应用程序在启动时可以通过 JVM 参数 -XX:G1HeapRegionSize 可指定每个堆区的大小(范围是 1MB ~ 32MB,并且值必须是 2 的幂),默认将整堆划分为 2048 个分区。分区大小范围在 1MB ~ 32MB,最多能设置 2048 个分区,即能够支持的最大内存为:32MB * 2048 = 64G 内存。通常来说,调整 -XX:G1HeapRegionSize 这个 JVM 参数可以优化 G1 的性能和吞吐量。例如,增大 G1HeapRegionSize 可以减少堆区的数量,提高回收效率,但可能会增加回收时的停顿时间;而减小 G1HeapRegionSize 可以增加堆区的数量,降低单个回收的停顿时间,但可能会增加回收的总体开销。

回收步骤:

Eden 区耗尽后会被触发垃圾收集,主要是小区域垃圾收集 + 形成连续的内存块,避免内碎片的产生。垃圾回收的步骤如下:

  • 将 Eden 区的数据移动到 Survivor 区,如果 Survivor 区的空间不够,Eden 区的对象会晋升到 Old 区
  • Survivor 区的对象移动到新的 Survivor 区,部分对象晋升到 Old 区
  • 等 Eden 区收拾干净了,GC 执行结束,用户的线程会继续执行

最后在 GC 完成后,就会形成连续的内存空间,这样就解决了内存碎片的问题

工作流程:

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

常用配置参数:

参数说明
-XX:+UseG1GC启用 G1 收集器
-XX:G1HeapRegionSize设置每个堆区的大小,范围是 1MB ~ 32MB,且值必须是 2 的幂。目的是根据最小的堆大小,划分出 2048 个分区。
-XX:MaxGCPauseMillis设置最大 GC 停顿时间,这是一个软目标,JVM 会尽可能(但不保证)停顿小于这个时间。
-XX:InitiatingHeapOccupancyPercent设置堆空间的占用率达到多少时,G1 会启动混合收集(mixed collections),默认值是 45%。特别注意,这个堆空间占用率表示的是:老年代已使用空间 / 整个堆空间。
-XX:G1NewSizePercent新生代大小占整个堆大小的最小百分比,默认值是 5%。
-XX:G1MaxNewSizePercent新生代大小占整个堆大小的最大百分比,默认值是 60%。
-XX:ConcGCThreads并发 GC 使用的线程数。
-XX:G1ReservePercent设置作为空闲空间的预留内存百分比,用于降低目标空间溢出的风险,默认值是 10%。

对于 G1 收集器,开发人员日常使用最频繁的三个 JVM 参数: -XX:+UseG1GC、-Xmx32G、-XX:MaxGCPauseMillis=100

什么是混合收集(mixed collections)

G1 在老年代占用达到一定比例后,会开始执行混合收集。混合收集是指同时收集部分新生代和老年代的垃圾,以确保系统在垃圾收集时的停顿时间更短。通过调整 -XX:InitiatingHeapOccupancyPercent 参数,可以影响 G1 垃圾收集器在什么时候开始执行混合收集,从而影响垃圾收集的性能表现。例如,将 -XX:InitiatingHeapOccupancyPercent 设置为 45%,则表示当堆的占用率达到 45% 时,G1 垃圾收集器将会启动混合收集。

如何选择垃圾收集器

为什么新生代采用 复制算法,而老年代采用 标记 - 整理算法 呢?

  • 新生代使用复制算法:因为新生代对象的生存时间比较短,80% 都是要回收的对象,采用 "复制算法" 可以更灵活高效,且便于整理内存空间;采用 "标记 - 清除算法",则会造成内存碎片化比较严重的问题;采用 "标记 - 整理算法",则会造成执行效率较低的问题。
  • 老年代采用标记 - 整理算法:一是为了解决 "标记 - 清除算法" 的内存碎片问题;二是为了解决 "复制算法" 的内存空间折半问题,由于老年代的空间比较大,因此不可能采用 "复制算法",否则特别占用内存空间。

垃圾收集器组合使用的选择标准

  • 单 CPU、小内存,如单机程序

    • -XX:+UseSerialGC
    • 上述 JVM 参数启用后,新生代会使用 Serial 垃圾收集器,老年代会使用 Serial Old 垃圾收集器
  • 多 CPU、追求高吞吐量,如科学计算、大数据处理等弱交互场景

    • -XX:+UseParallelGC:新生代垃圾收集器
    • -XX:+UseParallelOldGC:老年代垃圾收集器
    • 上述两个 JVM 参数会互相激活
  • 多 CPU、追求低停顿时间、要求快速响应,如大型互联网应用

    • -XX:+ParNewGC:新生代垃圾收集器
    • -XX:+UseConcMarkSweepGC:老年代垃圾收集器
  • 多 CPU、大内存、追求高吞吐量、要求可控停顿时间,如大型服务端应用程序

    • -XX:+UseG1GC:新生代和老年代的垃圾收集器

不同垃圾收集器的组合搭配如下: