1. 什么是垃圾?

内存中没用的对象

2. 为什么需要 GC?

  1. 如果不进行垃圾回收,内存迟早会用完

  2. 除了释放内存中没用的对象,GC 也可以清除内存里的记录碎片。便于 JVM 将整理出来的内存分配给新的对象

  3. 应用程序的业务越来越庞大、复杂,没有 GC 就不能保证程序的正常运行

经常造成 STW 的 GC 又跟不上实际的需求,所以才会不断的尝试对 GC 进行优化

3. 垃圾标记阶段算法

在堆里存放着几乎所有的 Java 对象实例,在 GC 进行垃圾回收前,需要区分出内存中哪些是存活对象,哪些是死亡对象,该过程称为 垃圾标记阶段

3.1 引用计数算法

对每个对象保存一个整型的引用计数器属性,用于记录对象被引用的情况。

对于一个对象 A, 只要有任何一个对象引用了 A, 则 A 的引用计数器就加 1;当引用失效时,引用计数器就减 1。

只要对象 A 的引用计数器的值为 0,即表示对象 A 不可能再被使用,可进行回收。

优点:

  1. 实现简单,垃圾对象便于辨识
  2. 判定效率高,回收没有延迟性

缺点:

  1. 它需要单独的字段存储计数器,这样的做法增加了存储空间的开销
  2. 每次赋值都需要更新计数器,伴随着加法和减法操作,这增加了时间开销
  3. 引用计数器有一个严重的问题,即无法处理 循环引用 的情况。这是一条致命缺陷

循环引用

image-20260707124357854

导致在 Java 的垃圾回收器中没有使用这类算法

3.2 可达性分析算法

相对于 引用计数算法 而言,可达性分析算法 不仅同样具备 实现简单执行高效 等特点,更重要的是该算法可以有效地解决在引用计数算法中 循环引用 的问题,防止 内存泄漏 的发生。

所谓 “GC Roots” 根集合就是一组必须活跃的引用。
基本思路:

  • 以根对象集合(GC Roots)为起始点,按照从上至下的方式 搜索被根对象集合所连接的目标对象是否可达
  • 使用可达性分析算法后,内存中的 存活对象都会被根对象集合直接或间接连接着,搜索所走过的路径称为 引用链(Reference Chain)
  • 如果目标对象没有任何引用链相连,则是不可达的,就意味着该对象己经死亡,可以标记为 垃圾对象

在可达性分析算法中,只有能够被根对象集合直接或者间接连接的对象才是存活对象。

image-20260707125123963

在 Java 语言中,GC Roots 包括以下几类元素:

  1. 虚拟机栈中引用的对象

    比如:各个线程被调用的 方法中使用到的参数、局部变量 等。

  2. 本地方法栈内 JNI(通常说的本地方法)引用的对象

  3. 方法区中类静态属性引用的对象

    比如:Java 类的 引用类型静态变量

  4. 方法区中常量引用的对象

    比如:字符串常量池(String Table)里的引用

  5. 所有被同步锁 synchronized 持有的对象

  6. Java 虚拟机内部的引用

    比如:基本数据类型对应的 class 对象,一些 常驻的异常对象(如:NullPointerException、OutofMemoryError), 系统类加载器

  7. 反映 java 虚拟机内部情况的 JMXBean、JVMTI 中注册的回调、本地代码缓存等。


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

根本原因:Java 堆中各区域并非孤立,对象之间可以跨区域引用。局部回收时,被回收区域之外的其他区域 中存活的对象,可能会引用本区域内的对象。这些外部对象虽然不属于固定的 GC Roots,但它们是本区域内对象的“活证据”,必须纳入本次回收的根集合。

具体做法

JVM 会在进行局部回收时,将其他区域中指向本区域的所有对象引用 临时加入 GC Roots 集合。例如:

  • 在新生代 Minor GC 时,会把老年代中所有 指向新生代对象 的引用也当作根。
  • 在老年代回收(如 CMS 并发标记阶段)时,也会把新生代中指向老年代的引用纳入根。

如果要使用可达性分析算法来判断内存是否可回收,那么分析工作必须在一个能 保障一致性的快照中 进行。这点不满足的话分析结果的准确性就无法保证。这点也是导致 GC 进行时必须 “Stop The World“ 的一个重要原因。即使是号称(几乎)不会发生停顿的 CMS 收集器中,枚举根节点时也是必须要停顿的。

4. 对象的 finalization 机制

Java 语言提供了对象终止(finalization)机制来允许开发人员提供对象被销毁之前的自定义处理逻辑。

当垃圾回收器发现没有引用指向一个对象,即:垃圾回收此对象之前,总会先调用这个对象的 finalize()方法。

fina1ize()方法允许在子类中被重写,用于在对象被回收时进行资源释放。通常在这个方法中进行一些资源释放和清理的工作,比如关闭文件、套接字
和数据库连接等。

永远不要主动调用某个对象的 finalize()方法,应该交给垃圾回收机制调用。

理由包括下面三点:

  1. 在 finalize()时可能会导致对象复活。
  2. finalize()方法的执行时间是没有保障的,它完全由 Gc 线程决定,极端情况下,若不发生 Gc, 则 finalize()方法将没有执行机会。
  3. 一个糟糕的 finalize()会严重影响 Gc 的性能。

从功能上来说,finalize()方法与 c++中的析构函数比较相似,但是 Java 采用的是基于垃圾回收器的自动内存管理机制,所以 final1ze()方法在本质上不同于 c++中的析构函数。

由于 finalize()方法的存在,虚拟机中的对象一般处于三种可能的状态。

如果从所有的根节点都无法访问到某个对象,说明对象己经不再使用了。一般来说,此对象需要被回收。但事实上,也并非是“非死不可”的,这时候它们暂时处于“缓刑”阶段。一个无法触及的对象有可能在某一个条件下“复活”自己,如果这样,那么对它的回收就是不合理的,为此,定义虚拟机中的对象可能的三种状态。如下:

  1. 可触及的:从根节点开始,可以到达这个对象。
  2. 可复活的:对象的所有引用都被释放,但是对象有可能在 finalize()中复活。
  3. 不可触及的:对象的 finalize()被调用,并且没有复活,那么就会进入不可触及状态。不可触及的对象不可能被复活,因为 finalize()只会被调用一次。

只有在对象不可触及时才可以被回收。

判定一个对象 objA 是否可回收,至少要经历两次标记过程:

  1. 如果对象 objA 到 GC Roots 没有引用链,则进行第一次标记。
  2. 进行筛选,判断此对象是否有必要执行 finalize()方法
  • 如果对象 objA 没有重写 finalize()方法,或者 finalize()方法已经被虚拟机调用过,则虚拟机视为“没有必要执行”,objA 被判定为不可触及的。
  • 如果对象 objA 重写了 finalize()法,且还未执行过,那么 objA 会被插入到 F-Queue 队列中,由一个虚拟机自动创建的、低优先级的 Finalizers 线程触发其 finalize()方法执行。
  • finalize()方法是对象逃脱死亡的最后机会,稍后 Gc 会对 F-Queuel 队列中的对象进行第二次标记。如果 objA 在 fina1ize()方法中与引用链上的任何一个对象建立了联系,那么在第二次标记时,objA 会被移出“即将回收”集合。之后,对象会再次出现没有引用存在的情况。在这个情况下 fina1ize 方法不会被再次调用,对象会直接变成不可触及的状态,也就是说,一个对象的 fina1ize 方法只会被调用一次。

工具

MAT 是 Memory Analyzeri 的简称,它是一款功能强大的 Java 堆内存分析器。用于查找内存泄漏以及查看内存消耗情况。
MAT 是基于 Eclipse 开发的,是一款免费的性能分析工具。下载

image-20260707150309347

JProfiler

JProfiler 是德国 ej-technologies 出品的 商业级 Java 性能分析工具(Profiler),把 CPU / 内存 / 线程 / 数据库消息队列探针打包在一个 GUI 里,Agent + UI 分离可远程 attach,比 VisualVM/MAT 精细、比 JFR+JMC 顺手,但 $549/license 起,个人学 JVM 用免费栈就够,生产复杂排查再上它。

image-20260707162547754

5. 垃圾回收算法

5.1 标记-清除(Mark-Swap)算法

背景:
标记-清除算法(Mark-Sweep)是一种非常基础和常见的垃圾收集算法该算法被 J.McCarthy 等人在 196o 年提出并并应用于 Lisp 语言。

执行过程:
当堆中的 有效内存空间(available memory) 被耗尽的时候,就会 停止整个程序(也被称为 stop the world
然后进行两项工作,第一项则是标记,第二项则是清除。

标记:Collector 从引用 根节点开始遍历,标记所有被引用的对象。一般是在对象的 Header 中记录为可达对象。
清除:Collector 对堆内存从头到尾进行 线性的遍历,如果发现某个对象在其 Header 中没有标记为可达对象,则将其回收。

缺点
➤ 效率不算高
➤ 在进行 GC 的时候,需要停止整个应用程序,导致用户体验差
➤ 这种方式清理出来的空闲内存是不连续的,产生内存碎片。需要维护一个空闲列表
注意:何为清除?

​ ➤ 这里所谓的清除并不是真的置空,而是把需要清除的对象地址保存在空闲的地址列表里。下次有新对象需要加载时,判断垃圾的位置空间是否够,如果够,就存放。

image-20260707170304811

5.2 复制(Copying)算法

背景
为了解决 标记-清除算法 在垃圾收集效率方面的缺陷

核心思想:
将活着的内存空间分为两块,每次只使用其中一块,在垃圾回收时将正在使用的内存中的存活对象复制到未被使用的内存块中,之后清除正在使用的内存块中的所有对象,交换两个内存的角色,最后完成垃圾回收

image-20260707170547406

优点:

  1. 没有标记和清除过程,实现简单,运行高效
  2. 复制过去以后保证空间的连续性,不会出现“碎片”问题

缺点:

  1. 需要两倍的内存空间。
  2. 对于 G1 这种分拆成为大量 region 的 GC,复制而不是移动,意味着 GC 需要维护 region 之间对象引用关系,不管是内存占用或者时间开销也不小。

特别的:
如果系统中的垃圾对象很多,复制算法不会很理想。复制算法需要复制的存活对象数量并不会太大,或者说非常低才行。

5.3 标记-压缩(或标记-整理、Mark-Compact)算法

背景:
复制算法的高效性是建立在 存活对象少、垃圾对象多 的前提下的。这种情况在新生代经常发生,但是在老年代,更常见的情况是 大部分对象都是存活对象。如果依然使用复制算法由于存活对象较多,复制的成本也将很高。因此,基于老年代垃圾回收的特性,需要使用其他的算法。

标记一清除算法的确可以应用在老年代中,但是该算法不仅 执行效率低下,而且在执行完内存回收后还会 产生内存碎片,所以 JVM 的设计者需要在此基础之上进行改进。标记:压缩(Mark-Compact)算法 由此诞生。

197o 年前后,G..L.Steele、C.J.Chene 和 D.S.Wise 等研究者发布标记-压缩算法。在许多现代的垃圾收集器中,人们都使用了标记-压缩算法或其改进版本。

执行过程:
第一阶段和标记-清除算法一样,从根节点开始标记所有被引用对象

第二阶段将所有的存活对象压缩到内存的一端,按顺序排放。之后,清理边界外所有的空间。

image-20260707171624295

优点:

  1. 消除了标记-清除算法当中,内存区域分散的缺点,我们需要给新对象分配内存时,JVM 只需要持有一个内存的起始地址即可。
  2. 消除了复制算法当中,内存减半的高额代价。

缺点:

  1. 从效率上来说,标记-整理算法要低于复制算法。
  2. 移动对象的同时,如果对象被其他对象引用,则还需要调整引用的地址。
  3. 移动过程中,需要全程暂停用户应用程序。即:STW

三种算法对比:

Copying Mark-Sweep Mark-Compact
速度 最快 中等 最慢
空间开销 通常需要存活对象的 2 被大小(不堆积碎片) 少(但会堆积碎片) 少(不堆积碎片)
移动对象

5.4 分代收集算法

背景:

前面所有这些算法中,并没有一种算法可以完全替代其他算法,它们都具有自已独特的优势和特点。分代收集算法应运而生。

分代收集算法,是基于这样一个事实:不同的对象的生命周期是不一样的。因此,不同生命周期的对象可以采取不同的收集方式,以便提高回收效率。

一般把 Java 堆分为 新生代老年代,这样就可以根据各个年代的特点使用不同的回收算法,以提高垃圾回收的效率。

在 Java 程序运行的过程中,会产生大量的对象,其中有些对象是 与业务信息相关,比如 Http 请求中的 Session 对象、线程、Socket 连接,这类对象跟业务直接挂钩,因此生命周期比较长。

但是还有一些对象,主要是程序运行过程中生成的 临时变量,这些对象生命周期会比较短,比如:String 对象,由于其不变类的特性,系统会产生大量的这些对象,有些对象甚至只用一次即可回收。

目前几乎所有的 GC 都是采用分代收集(Generational Collecting)算法执行垃圾回收的。

在 HotSpot 中,基于分代的概念,GC 所使用的内存回收算法必须结合年轻代和老年代各自的特点。

年轻代(Young Gen)

年轻代特点:区域相对老年代较小,对象生命周期短、存活率低,回收频繁。

这种情况使用复制算法,速度是最快的。复制算法的效率只和当前存活对象大小有关,因此很适用于年轻代的回收。而复制算法内存利用率不高的问题,通过 hotspot 中的两个 survivor区 的设计得到缓解。

老年代(Tenured Gen)

老年代特点:区域较大,对象生命周期长、存活率高,回收没有那么频繁。

这种情况存在大量存活率高的对象,复制算法明显变得不合适。一般是由标记一清除或者是标记-清除与标记-整理的混合实现。

  1. Mark 阶段的开销与存活对象的数量成正比。
  2. Sweep 阶段的开销与所管理区域的大小成正相关。
  3. Compact 阶段的开销与存活对象的数据成正比。

以 HotSpot 中的 CMS 回收器为例,CMS 是基于 Mark-Sweep 实现的,对于对象的回收效率很高。而对于碎片问题,CMS 采用基于 Mark-Compact 算法的 serial old 回收器作为补偿措施:当内存回收不佳(碎片导致的 concurrent Mode Failure 时),将采用 Serial old 执行 Full GC 以达到对老年代内存的整理。
分代的思想被现有的虚拟机广泛使用。几乎所有的垃圾回收器都区分新生代和老年代。

5.5 增量收集算法

背景

上述现有的算法,在垃圾回收过程中,应用软件将处于一种 Stop the World 的状态。在 Stop the World 状态下,应用程序所有的线程都会挂起,暂停一切正常的工作,等待垃圾回收的完成。如果垃圾回收时间过长,应用程序会被挂起很久,将严重影响用户体验或者系统的稳定性。为了解决这个问题,即对实时垃圾收集算法的研究直接导致了增量收集(Incremental Collecting)算法的诞生。

基本思想

如果一次性将所有的垃圾进行处理,需要造成系统长时间的停顿,那么就可以 让垃圾收集线程和应用程序线程交替执行。每次,垃圾收集线程只收集一小片区域的内存空间,接着切换到应用程序线程。依次反复,直到垃圾收集完成。
总的来说,增量收集算法的基础仍是传统的 标记-清除和复制算法。增量收集算法通过对线程间冲突的妥善处理,允许垃圾收集线程以分阶段的方式完成标记、清理或复制工作。

缺点:
使用这种方式,由于在垃圾回收过程中,间断性地还执行了应用程序代码,所以能减少系统的停顿时间。但是,因为线程切换和上下文转换的消耗,会使得垃圾回收的总体成本上升,造成系统吞吐量的下降。

5.6 分区算法

一般来说,在相同条件下,堆空间越大,一次 GC 时所需要的时间就越长,有关 GC 产生的停顿也越长。为了更好地控制 GC 产生的停顿时间,将一块大的内存区域分割成多个小块,根据目标的停顿时间,每次合理地回收若干个小区间,而不是整个堆空间,从而减少一次 GC 所产生的停顿。

分代算法将按照对象的生命周期长短划分成两个部分,分区算法将整个堆空间划分成连续的不同小区间 region

每一个小区间都独立使用,独立回收。这种算法的好处是可以控制一次回收多少个小区间 region

image-20260707174645317

注意,这些只是基本的算法思路,实际GC实现过程要复杂的多,目前还在发展中的前沿GC都是复合算法,并且并行和并发兼备。

6. 垃圾回收相关概念

6.1 System.gc()的理解

在默认情况下,通过 System.gc()或者 Runtime.getRuntime().gc()的调用,会 显式触发 Full GC,同时对老年代和新生代进行回收,尝试释放被丢弃对象占用的内存。

然而 System.gc()调用附带一个免责声明,无法保证对垃圾收集器的调用。

JVM 实现者可以通过 System.gc()调用来决定 JVM 的 GC 行为。而一般情况下,垃圾回收应该是自动进行的,无须手动触发,否则就太过于麻烦了。在一些特殊情况下,如我们正在编写一个性能基准,我们可以在运行之间调用 System.gc()。

1
2
3
4
5
6
public static void main(String[] args)
new SystemGCTest();
// 底层就是 Runtime.getRuntime().gc()
System.gc();//提醒 jvm 的垃圾回收器执行 gc,但是不确定是否马上执行 gc
System.runFinalization(); //强制调用使用引用的对象的 finalize 方法
}

6.2 内存溢出

javadoc 中对 OutOfMemoryError 的解释是: 没有空闲内存,并且垃圾收集器也无法提供更多内存。

由于 GC 一直在发展,所有一般情况下,除非应用程序占用的内存增长速度非常快,造成垃圾回收已经跟不上内存消耗的速度,否则不太容易出现 OOM 的情况。

情况一:没有空闲内存

原因有二:

  1. Java 虚拟机的堆内存设置不够。

​ 比如:可能存在内存泄漏问题,也很有可能就是堆的大小不合理,比如我们要处理比较可观的数据量,但是没有显式指定 JVM 堆大小或者指定数值 偏小。我们可以通过参数-Xms、-Xmx 来调整。

  1. 代码中创建了大量大对象,并且长时间不能被垃圾收集器收集(存在被引用)

    对于老版本的 Oracle JDK,因为永久代的大小是有限的,并且 JVM 对永久代垃圾回收(如,常量池回收、卸载不再需要的类型)非常不积极,所以当我们不断添加新类型的时候,永久代出现 OutOfMemoryError 也非常多见,尤其是在运行时存在大量动态类型生成的场合:类似 intern 字符串缓存占用太多空间,也会导致 OOM 问题。对应的异常信息,会标记出来和永久代相关:”java.lang.OutOfMemoryError:PermGenspace “。

    随着 JDK8 元数据区的引入,方法区内存已经不再那么窘迫,所以相应的 OOM 有所改观,出现 OOM,异常信息变成了了:”java.lang.OutofMemoryError:Metaspace“。直接内存不足,也会导致 OOM。

情况二:垃圾收集器也无法提供更多内存

在抛出 OutOfMemoryError 之前,通常垃圾收集器会被触发,尽其所能去清理出空间。

  • 例如:在引用机制分析中,涉及到 JVM 会去尝试回收软引用指向的对象等
  • 在 java.nio.BIts.reserveMemory()方法中,我们能清楚的看到,System.gc()会被调用,以清理空间。

当然,也不是在任何情况下垃圾收集器都会被触发的

  • 比如,我们去分配一个超大对象,类似一个超大数组超过堆的最大值,JVM 可以判断出垃圾收集并不能解决这个问题,所以直接抛出 OutOfMemoryError。

6.3 内存泄漏(Memory Leak)

严格来说,只有对象不会再被程序用到了,但是 GC 又不能回收他们的情况,才叫内存泄漏。

但实际情况很多时候一些不太好的实践(或蔬忽)会导致对象的生命周期变得很长甚至导致 OOM,也可以叫做 宽泛意义上的“内存泄漏”

尽管内存泄漏并不会立刻引起程序崩溃,但是一旦发生内存泄漏,程序中的可用内存就会被逐步蚕食,直至耗尽所有内存,最终出现 outofMemory 异常,导致程序崩溃。

注意,这里的存储空间并不是指物理内存,而是指虚拟内存大小,这个虚拟内存大小取决于磁盘交换区设定的大小。

image-20260707182205027

举例:

  1. 单例模式

单例的生命周期和应用程序是一样长的,所以在单例程序中,如果持有对外部对象的引用,那么这个外部对象是不能被回收的,则会导致内存泄漏的产生。

  1. 一些提供 close 的资源未关闭导致内存泄漏

数据库连接(dataSourse.getConnection()),网络连接(socket)和 io 连接必须手动 close,否则是不能被回收的。

6.4 Sotp The Wrold

Sotp The Wrold,简称 STW,指的是 GC 过程中,会产生应用程序的停顿。停顿产生时整个应用程序线程都会被暂停,没有任何响应有点像卡死的感觉,这个停顿称为 STW。

可达性分析算法中枚举根节点(GC Roots)会导致所有 Java 执行线程停顿

  • 分析工作必须在一个能确保一致性的快照中进行
  • 一致性指整个分析期间整个执行系统着起来像被冻结在某个时间点上
  • 如果出现分析过程中对象引用关系还在不断变化,则分析结果的准确性无法保证

被 STW 中断的应用程序线程会在完成 GC 之后恢复,频繁中断会让用户感觉像是网速不快造成电影卡带一样,所以我们需要减少 STW 的发生。

STW 事件和采用哪款 GC 无关,所有的 GC 都有这个事件

哪怕是 G1 也不能完全避免 Sotp The Wrold,只能说垃圾回收器越来越优秀,回收效率越来越高,尽可能地缩短了暂停时间。

STW 是 JVM 在后台自动发起和自动完成的。在用户不可见的情况下,把用户正常的工作线程全部停掉。

开发中不要用 System.gc(); 会导致 Sotp The Wrold 的发生。

6.5 并发与并行

并发

在操作系统中,是 指一个时间段 中有几个程序在 同一个处理器上交替运行

并发不是真正意义上的 “同时进行”,只是 CPU 把一个时间段划分成几个时间片段(时间区间),然后在这几个时间区间之间来回切换,由于 CPU 处理的速度非常快,只要时间间隔处理得当,即可让用户感觉是多个应用程序同时在进行。

image-20260707183625905

并行

当系统有两个及以上 CPU,当一个 CPU 执行一个进程时,另一个 CPU 可以执行另一个进程,两个进程互不抢占 CPU 资源,可以同时进行,我们称之为并行(Parallel)。

决定并行的因素不是 CPU 的数量,而是 CPU 的核心数量,一个 CPU 多个核也可以并行。

适合科学计算,后台处理等弱交互场景

image-20260707183926552

二者对比:

并发,指的是多个事情,在 同一时间段内 同时发生了
并行,指的是多个事情,在 同一时间点上 同时发生了

并发的多个任务之间是 互相抢占资源 的。
并行的多个任务之间是 不互相抢占资源 的。

只有在多 CPU 或者一个 CPU 多核的情况中,才会发生并行。否则,看似同时发生的事情,其实都是并发执行的。

垃圾回收的并发与并行

并发和并行,在谈论垃圾收集器的上下文语境中,可以解释如下:

并行(Paraller):指多条垃圾收集线程并行工作,但此时用户线程仍处于等待状态。

​ 如 ParNew、Parallel Scavenge、Parallel old

串行(Serial)

  • 相较于并行的概念,单线程执行。
  • 如果内存不够,则程序暂停,启动 JVM 垃圾回收器进行垃圾回收。回收完,再启动程序的线程。

image-20260707184513355

并发(concurrent):指 用户线程与垃圾收集线程同时执行( 但不一定是并行的,可能会交替执行),垃圾回收线程在执行时不会停顿用户程序的运行。

  • 用户程序在继续运行,而垃圾收集程序线程运行于另一个 CPU 上
  • 如:CMS、G1

image-20260707184759392

6.6 安全点和安全区域

安全点

程序执行时并非在所有地方都能停顿下来开始 GC,只有在特定的位置才能停顿下来开始 Gc,这些位置称为 安全点(Safe Point)

Safe Point 的选择很重要,如果太少可能导致 GC 等待的时间太长,如果太频繁可能导致运行时的性能问题。大部分指令的执行时间都非常短暂,通常会根据“是否具有让程序长时间执行的特征”为标准。比如:选择一些执行时间较长的指令作为 Safe Point,如 方法调用、循环跳转和异常跳转 等。

如何在 GC 发生时,检查所有线程都跑到最近的安全点停顿下来呢?

  1. 抢先式中断:(目前没有虚拟机采用)
    首先中断所有线程。如果还有线程不在安全点,就恢复线程,让线程跑到安全点。
  2. 主动式中断:
    设置一个中断标志,各个线程运行到 Safe Point 的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。

安全区域

Safe Point 机制保证了程序执行时,在不太长的时间内就会遇到可进入 GC 的 Safe Point。但是,程序“不执行”的时候呢?例如线程处于 Sleep 状态或 Blocked 状态,这时候线程无法响应 JVM 的中断请求,“走”到安全点去中断挂起,JVM 也不太可能等待线程被唤醒。对于这种情况,就需要安全区域(Safe Region)来解决。

安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始 GC 都是安全的。 我们也可以把 Safe Region 看做是被扩展了的 Safe Point。

6.7 四种引用

背景

我们希望能描述这样一类对象:当内存空间足够时,则能保留在内存中;如果内存空间在进行垃圾收集后还是很紧张,则可以抛弃这些对象。

在 JDK1.2 版之后,Java 对引用的概念进行了扩充,将引用分为 强引用(Strong Reference)软引用(Soft Reference)弱引用(Weak Reference)虚引用(Phantom Reference)4 种,这 4 种引用强度依次逐渐减弱

除强引用外,其他 3 种引用均可以在 java.lang.ref 包中找到它们的身影。如下图,显示了这 3 种引用类型对应的类,开发人员可以在应用程序中直接使用它们。

IDEA 快捷键 Ctrl + H

image-20260707193205279

1
2
3
public abstract sealed class Reference<T>
permits PhantomReference, SoftReference, WeakReference, FinalReference {
}

permits 是 Java 17 引入的密封类(sealed class) 的关键字,用于显式声明哪些类可以继承或实现当前类/接口。

sealed 修饰的类,必须在 permits 子句中明确写出 允许继承它的子类名单,名单之外的类一律不允许继承。

Reference(引用对象)是 Java 弱引用/软引用/虚引用体系的基类,JDK 不希望开发者自己实现新的引用类型,只允许预定义的四种引用存在,以保证 JVM 对引用处理的统一性和安全性。

Reference 子类中只有终结器引用是包内可见的,其他 3 种引用类型均为 public,可以在应用程序中直接使用

强引用(Strong Reference):最传统的“引用”的定义,是指在程序代码之中普遍存在的引用赋值,即类似“Object obj = new Object()这种引用关系。无论任何情况下,只要强引用关系还存在,垃圾收集器就永远不会回收掉被引用的对象。

软引用(Soft Reference):在系统将要发生内存溢出之前,将会把这些对象列入回收范围之中进行第二次回收。如果这次回收后还没有足够的内存,才会抛出内存溢出异常。

弱引用(Weak Reference):被弱引用关联的对象只能生存到下一次垃圾收集之前。当垃圾收集器工作时,无论内存空间是否足够,都会回收掉被弱引用关联的对象。

虚引用(Phantom Reference):一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,也无法通过虚引用来获得一个对象的实例。为一个对象设置虚引用关联的 唯一目的就是能在这个对象被收集器回收时收到一个系统通知。


强引用(不回收)

在 Java 程序中,最常见的引用类型是强引用(普通系统 99%以上都是强引用),也就是我们最常见的普通对象引用,也是默认的引用类型。

当在 Java 语言中使用 new 操作符创建一个新的对象,并将其赋值给一个变量的时候,这个变量就成为指向该对象的一个强引用。

强引用的对象是 可触及的,垃圾收集器就永远不会回收掉被引用的对象。

对于一个普通的对象,如果没有其他的引用关系,只要超过了引用的作用域或者显式地将强引用赋值为 nu11,就是可以当做垃圾被收集了,当然具体回收时机还是要着垃圾收集策略。

相对的,软引用、弱引用和虚引用的对象是软可触及、弱可触及和虚可触及的,在一定条件下,都是可以被回收的。所以,强引用是造成 Java 内存泄漏的主要原因之一。

image-20260708105648057

软引用(内存不足即回收)

软引用 是用来描述一些 还有用,但非必需的对象只被软引用关联着的对象在系统将要发生内存溢出异常前会把这些对象列进回收范围之中进行第二次回收,如果这次回收还没有足够的内存,才会抛出内存溢出异常。

“第二次回收” 是指 JVM 在进行了一次正常的垃圾回收(Minor GC 或 Full GC)之后,发现内存仍然不足,即将抛出 OutOfMemoryError 之前,专门针对只被软引用关联的对象再进行一次额外的回收

软引用通常用来实现内存敏感的缓存。比如:高速缓存 就有用到软引用。如果还有空闲内存,就可以暂时保留缓存,当内存不足时清理掉,这样就保证了使用缓存的同时,不会耗尽内存。

垃圾回收器在某个时刻决定回收软可达的对象的时候,会清理软引用,并可选地把引用存放到一个引用队列(ReferenceQueue)。

类似弱引用,只不过 Java 虚拟机会尽量让软引用的存活时间长一些,迫不得已才清理。

sun.misc.Cleaner(Java 9+ 改为 java.lang.ref.Cleaner)利用软引用来管理直接内存(DirectBuffer)的释放。
java.util.LinkedHashMap 配合软引用可以实现 LRU 缓存(需自行扩展)。

软引用基本用法

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
import java.lang.ref.SoftReference;

public class SoftReferenceDemo {
public static void main(String[] args) {
// 创建一个强引用对象
String strongRef = new String("Hello, SoftReference!");

// 用软引用包装它
SoftReference<String> softRef = new SoftReference<>(strongRef);

// 此时 strongRef 仍然存在,对象不会被回收
System.out.println(softRef.get()); // 输出: Hello, SoftReference!

// 去掉强引用,只剩下软引用
strongRef = null;

// 此时对象只有软引用关联,但内存充足时不会回收
System.gc(); // 显式调用 GC(只是建议,不一定立刻执行)
System.out.println(softRef.get()); // 大概率仍然能获取到对象

// 模拟内存紧张(实际开发中不需要手动做这一步)
// 当 JVM 检测到内存不足时,会自动回收软引用对象
// 回收后 softRef.get() 返回 null
}
}

弱引用(发现即回收)

弱引用 也是用来描述那些非必需对象,被弱引用关联的对象只能生存到下一次垃圾收集发生为止只要垃圾回收器发现了只被弱引用指向的对象,无论内存是否充足,都会在下一次 GC 时将其回收

下一次 GC 的理解

1
2
3
4
5
... 执行到 System.gc();   →  这是“现在”

JVM 收到请求,准备执行 GC

实际开始 GC 事件 → 这是“下一次 GC”(相对于刚才的“现在”)

System.gc() 本身当成“这一次”,那后面发生的 GC 自然就是“下一次”了。很多资料为了强调“弱引用在下一次 GC 时被回收”,习惯用“下一次”这个词,避免让人误以为调用 System.gc() 的同时就回收了对象(实际上回收是在 GC 过程中发生的)。

但是,由于垃圾回收器的线程通常优先级很低,因此,并不一定能很快地发现持有弱引用的对象。在这种情况下,弱引用对象可以存在较长的时间。

弱引用和软引用一样,在构造弱引用时,也可以指定一个引用队列,当弱引用对象被回收时,就会加入指定的引用队列,通过这个队列可以跟踪对象的回收情况。

软引用、弱引用都非常适合来保存那些可有可无的缓存数据。 如果这么做,当系统内存不足时,这些缓存数据会被回收,不会导致内存溢出。而当内存资源充足时,这些缓存数据又可以存在相当长的时间,从而起到加速系统的作用。

弱到用对象与软引用对象的最大不同就在于,当 GC 在进行回收时,需要通过算法检查是否回收软引用对象,而对于弱引用对象,GC 总是进行回收。弱引用对象更容易、更快被 GC 回收。

弱引用基本用法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import java.lang.ref.WeakReference;

public class WeakReferenceDemo {
public static void main(String[] args) {
// 创建一个强引用对象
String strongRef = new String("Hello, WeakReference!");

// 用弱引用包装它
WeakReference<String> weakRef = new WeakReference<>(strongRef);

// 此时强引用还在,对象不会被回收
System.out.println(weakRef.get()); // 输出: Hello, WeakReference!

// 去掉强引用,只剩下弱引用
strongRef = null;

// 显式建议 GC(实际 JVM 可能会执行)
System.gc();

// 弱引用对象已经被回收
System.out.println(weakRef.get()); // 输出: null
}
}

经典应用: WeakHashMap

java.util.WeakHashMap 是弱引用最典型的应用。它的键(Key)使用弱引用包装,当某个键不再被外部强引用时,该键值对会自动被移除。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
import java.util.WeakHashMap;

public class WeakHashMapExample {
public static void main(String[] args) {
WeakHashMap<Object, String> map = new WeakHashMap<>();

Object key = new Object(); // 强引用
map.put(key, "value");

System.out.println(map.size()); // 1

key = null; // 去掉强引用,只剩下 WeakHashMap 内部的弱引用

System.gc(); // 建议 GC

// 等待一段时间或再次调用 GC 后,map 中的条目会被自动清除
System.out.println(map.size()); // 0(大概率)
}
}

适用场景:缓存那些生命周期由外部控制的元数据,比如类的附加信息、监听器列表等,避免内存泄漏。

另一个重要应用: ThreadLocal

ThreadLocal 只是个 “钥匙”,真正存数据的是每个 Thread 自己里面的 ThreadLocalMap

关系链

1
2
3
4
5
6
7
Thread 对象
└── threadLocals (ThreadLocalMap)
└── table: Entry[] (底层是数组,不是链表)
[0] → Entry(key=WeakReference<ThreadLocal@A>, value=小明)
[1] → Entry(key=WeakReference<ThreadLocal@B>, value=会计)
[2] → null
...

ThreadLocal 的内部实现中,每个线程维护一个 ThreadLocalMap,其 Entry 的 key 是一个 弱引用 指向 ThreadLocal 对象本身。这样,当外部的 ThreadLocal 变量不再被使用时,即使线程还在运行,这个 ThreadLocal 也能被 GC 回收,防止内存泄漏。

ThreadLocalvalue 是强引用,需要配合 ThreadLocalMap 的清理机制(在 getsetremove 时会清理无效的 Entry)

1
2
3
4
5
6
7
8
// 简化理解:ThreadLocalMap.Entry 大致如下
static class Entry extends WeakReference<ThreadLocal<?>> {
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k); // key 是弱引用
value = v;
}
}

keyWeakReference<ThreadLocal<?>>—— 弱引用

value:普通 Object—— 强引用

底层是 Entry[] table初始容量 16,必须是 2 的幂

哈希冲突用 线性探测法(不是 HashMap 的拉链法),nextIndex(i, len) 往后找空槽

负载因子 2/3,超过就扩容(容量翻倍)

set 流程

1
2
3
4
5
6
7
8
public void set(T value) {
Thread t = Thread.currentThread();
ThreadLocalMap map = getMap(t);
if (map != null)
map.set(this, value);
else
createMap(t, value); // 第一次才懒创建
}

ThreadLocalMap.set()内部:

算哈希槽 i = key.threadLocalHashCode & (len - 1)

从 i 开始线性探测:

  • 遇到相同 key → 覆盖 value
  • 遇到 key == null(陈旧条目) → 触发 replaceStaleEntry()清理 + 复用
  • 遇到空槽 → 新建 Entry 塞进去

get 流程

类似,以当前 ThreadLocal 为 key 找 Entry,找不到就调用 setInitialValue()返回初始值(默认 null,可重写 initialValue())。

remove 流程

找到 Entry 后,把 entry.value = null、entry.clear()(清弱引用),槽位置 null。

弱引用只能保 ThreadLocal 对象不泄漏,保不了 value。JDK 的做法是:在 get()/set()/remove() 过程中,**顺带清理 key == null 的陈旧 Entry **(叫 expungeStaleEntry(),探测式清理),把 value 也置 null。

状态 数组元素 Entry 对象 key value 危害
空槽位 null 不存在
陈旧 Entry 非 null 存在 null(已回收) 存在(强引用) 内存泄漏(value 无法回收)

典型应用场景

1. 线程安全的日期格式化(避免创建大量对象)

1
2
3
4
5
6
7
8
public class DateUtils {
private static final ThreadLocal<SimpleDateFormat> FORMATTER =
ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"));

public static String format(Date date) {
return FORMATTER.get().format(date);
}
}

SimpleDateFormat 非线程安全,用 ThreadLocal 让每个线程拥有自己的实例,既安全又高效。

2. 传递请求上下文(Web 应用)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public class RequestContextHolder {
private static final ThreadLocal<RequestContext> CONTEXT_HOLDER =
new ThreadLocal<>();

public static void set(RequestContext ctx) {
CONTEXT_HOLDER.set(ctx);
}

public static RequestContext get() {
return CONTEXT_HOLDER.get();
}

public static void clear() {
CONTEXT_HOLDER.remove();
}
}

在拦截器中设置,业务代码中直接取用,无需层层传参。

虚引用(对象回收跟踪)PhantomReference

也称为“幽灵引用”或者“幻引用”,是所有引用类型中最弱的一个。

一个对象是否有虚引用的存在,完全不会决定对象的生命周期。如果一个对象仅持有虚引用,那么它和没有引用几乎是一样的,随时都可能被垃圾回收器回收。

它不能单独使用,也无法通过虚引用来获取被引用的对象。当试图通过虚引用的 get()方法取得对象时,总是 null。

为一个对象设置虚引用关联的唯一目的在于跟踪垃圾回收过程。比如:能在这个对象被收集器回收时收到一个系统通知。

虚引用必须和引用队列一起使用。虚引用在创建时必须提供一个引用队列作为参数。当垃圾回收器准备回收一个对象时,如果发现它还有虚引用就会在回收对象后,将这个虚引用加入引用队列,以通知应用程序对象的回收情况。

由于虚引用可以跟踪对象的回收时间,因此,也可以将一些资源释放操作放置在虚引用中执行和记录。

在 JDK1.2 版之后提供了 PhantomReference 类来实现虚引用。

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
31
32
33
34
35
import java.lang.ref.PhantomReference;
import java.lang.ref.ReferenceQueue;

public class PhantomReferenceDemo {
public static void main(String[] args) throws InterruptedException {
// 1. 创建引用队列
ReferenceQueue<Object> queue = new ReferenceQueue<>();

// 2. 创建一个普通对象
Object obj = new Object();

// 3. 创建虚引用(必须传入队列)
PhantomReference<Object> phantomRef = new PhantomReference<>(obj, queue);

// 4. 虚引用无法通过 get() 获得对象
System.out.println(phantomRef.get()); // 始终输出 null

// 5. 去掉强引用,使对象只被虚引用关联
obj = null;

// 6. 建议 GC
System.gc();

// 7. 等待一段时间让 GC 完成
Thread.sleep(1000);

// 8. 检查队列:对象被回收后,虚引用会被放入队列
PhantomReference<?> refFromQueue = (PhantomReference<?>) queue.poll();
if (refFromQueue != null) {
System.out.println("对象已被回收,虚引用已入队");
} else {
System.out.println("对象尚未被回收(可能 GC 未执行)");
}
}
}

输出示例(取决于 GC 是否执行):

1
2
null
对象已被回收,虚引用已入队

终结器引用(FinalReference)

它用以实现对象的 finalize()方法,也可以称为终结器引用。

无需手动编码,其内部配合引用队列使用。

在 GC 时,终结器引用入队。由 Finalizer 线程通过终结器引用找到被引用对象并调用它的 finalize()方法,第二次 GC 时才能回收被引用对象。

7. 垃圾回收器

7.1 GC 分类与性能指标

按线程数分,可以分为串行垃圾回收器和并行垃圾回收器

image-20260708121620053

串行回收指的是在同一时间段内只允许有一个 CPU 用于执行垃圾回收操作此时工作线程被暂停,直至垃圾收集工作结束。

  • 单 CPU 处理器或者较小的应用内存等硬件平台不是特别优越的场合,串行回收器的性能表现可以超过并行回收器和并发回收器。所以,串行回收默认被应用在客户端的 client 模式下的 JVM 中

  • 在并发能力比较强的 CPU 上,并行回收器产生的停顿时间要短于串行回收器。

并行收集可以运用多个 CPU 同时执行垃圾回收,因此提升了应用的吞吐量,不过并行回收仍然与串行回收一样,采用独占式,使用了“stop-the-world”机制。

按照工作模式分,可以分为并发式垃圾回收器和独占式垃圾回收器。

  • 并发式垃圾回收器与应用程序线程交替工作,以尽可能减少应用程序的停顿时间。
  • 独占式垃圾回收器(stoptheworld)一旦运行,就停止应用程序中的所有用户线程,直到垃圾回收过程完全结束。

image-20260708122125155

按碎片处理方式分,可分为压缩式垃圾回收器和非压缩式垃圾回收器。

  • 压缩式垃圾回收器 会在回收完成后,对存活对象进行压缩整理,消除回收后的碎片。
  • 非压缩式的垃圾回收器 不进行这步操作。

按工作的内存区间分,又可分为年轻代垃圾回收器和老年代垃圾回收器。

评价 GC 的性能指标

吞吐量:运行用户代码的时间占总运行时间的比例

​ $吞吐量=\frac{程序的运行时间}{程序的运行时间+内存回收时间}$

​ $总运行时间=程序的运行时间+内存回收时间$

垃圾收集开销:吞吐量的补数,垃圾收集所用时间与总运行时间的比例。

暂停时间:执行垃圾收集时,程序的工作线程被暂停的时间。

收集频率:相对于应用程序的执行,收集操作发生的频率。

内存占用:Java 堆区所占的内存大小。

快速:一个对象从诞生到被回收所经历的时间。

简单来说,主要抓住两点:

  1. 吞吐量
  2. 暂停时间

高吞吐量较好因为这会让应用程序的最终用户感觉只有应用程序线程在做“生产性”工作。直觉上,吞吐量越高程序运行越快。

低暂停时间(低延迟)较好因为从最终用户的角度来看不管是 GC 还是其他原因导致一个应用被挂起始终是不好的。

这取决于应用程序的类型,有时候甚至短暂的 200 毫秒暂停都可能打断终端用户体验。因此,具有低的较大暂停时间是非常重要的,特别是对于一个交互式应用程序。

不幸的是”高吞吐量”和”低暂停时间”是一对相互竞争的自标(矛盾)。

  • 因为如果选择以吞吐量优先,那么必然需要降低内存回收的执行频率,但是这样会导致 GC 需要更长的暂停时间来执行内存回收。
  • 相反的,如果选择以低延迟优先为原则,那么为了降低每次执行内存回收时的暂停时间,也只能频繁地执行内存回收,但这文引起了年轻代内存的缩减和导致程序吞吐量的下降。

在设计(或使用)GC 算法时,我们必须确定我们的目标:

一个 GC 算法只可能针对两个目标之一(即只专注于较大吞吐量或最小暂停时间),或尝试找到一个二者的折裹。

现在标准:在最大吞吐量优先的情况下,降低停顿时间。

7.2 垃圾回收器发展史

有了虚拟机,就一定需要收集垃圾的机制,这就是 GarbageCollection,对应的产品我们称为 GarbageCollector。

年份 JDK 版本 事件 / 新 GC 说明
1999 JDK 1.3.1 Serial GC 发布 第一款商用 GC,串行单线程,适用于单核或小型应用
2002 JDK 1.4.2 Parallel GCCMS 随 JDK 发布 Parallel 注重吞吐量,CMS 注重低延迟(首个并发 GC)
2006 JDK 6 Parallel GC 成为 HotSpot 默认 GC 服务端默认,吞吐量优先
2012 JDK 1.7u4 G1 GC 可用(实验性) 面向大堆、分区式设计,目标替换 CMS
2017 JDK 9 G1 成为默认 GC,替代 CMS CMS 被标记为废弃,G1 正式上位
2018.3 JDK 10 G1 并行 Full GC 改进 并行化 Full GC,改善最坏情况延迟
2018.9 JDK 11 Epsilon GC(No-Op)和 ZGC(实验性)发布 Epsilon 不回收,用于测试;ZGC 亚毫秒级延迟
2019.3 JDK 12 G1 自动归还未用堆内存;Shenandoah GC(实验性) G1 可释放内存给 OS;Shenandoah 低停顿
2019.9 JDK 13 ZGC 自动归还未用堆内存 增强 ZGC 内存管理
2020.3 JDK 14 删除 CMS;ZGC 扩展至 macOS 和 Windows CMS 正式退役;ZGC 跨平台
2020.9 JDK 15 ZGC 转为正式功能(非实验性) 可用于生产环境
2021.3 JDK 16 Shenandoah 转为正式功能(非实验性) 同样可用于生产
2021.9 JDK 17 (LTS) ZGC 优化并发处理,统一日志;Shenandoah 稳定 LTS 版本,两大低延迟 GC 成熟
2022.3 JDK 18 默认 GC 仍为 G1,ZGC 持续改进 无重大新 GC
2023.9 JDK 21 (LTS) ZGC 分代模式(Generational ZGC) 大幅降低 CPU 开销,延迟保持亚毫秒
2024 JDK 22~23 Generational ZGC 成为默认选项之一;Shenandoah 支持分代 分代 GC 进一步普及
2025~2026 JDK 24~25 G1 减少停顿;ZGC 和 Shenandoah 持续优化 当前主流:G1(默认)、ZGC(超低延迟)、Shenandoah(中等堆)

当前默认 GC(2026 年):仍是 G1(自 JDK 9 起)。

生产推荐:JDK 21 LTS + G1(通用)/ ZGC(< 10ms 延迟)/ Shenandoah(几十 GB 堆)。

CMS 被删原因:碎片化、浮动垃圾、并发模式失败,已被 G1/ZGC 全面超越。

ZGC 分代的意义:传统 ZGC 非分代,扫描全堆代价高;分代后年轻对象和老对象分开处理,GC 频率和 CPU 占用显著下降。

7 款经典的垃圾收集器

串行回收器:Serial、Serial Old

并行回收器:ParNew、Parallel Scavenge、Parallel Old

并发回收器:CMS、G1

image-20260708125332422

7 款经典收集器与垃圾分代之间的关系

image-20260708125516315

新生代收集器:Serial、ParNew、Parallel Scavenge

老年代收集器:Serial old、Parallel Old、CMS

整堆收集器:G1

垃圾收集器的组合关系(到 jdk14)

image-20260708125658556

全周期废弃 / 移除时间线汇总

变更内容 触发 JDK 版本 对应 JEP 当前 2026 状态
Serial+CMS、ParNew+SerialOld 标记废弃 JDK8 JEP173 早已彻底移除,完全不可用
Serial+CMS、ParNew+SerialOld 彻底删除 JDK9 JEP214 永久失效,无兼容方案
CMS 收集器整体删除 JDK14 JEP363 永久删除,无恢复可能
Parallel Scavenge+SerialOld 标记弃用 JDK14 JEP366 JDK25 版本已彻底移除该组合
ZGC 正式商用 JDK15 JEP377 2026 主流低延迟 GC(分代版)
G1 成为默认 GC JDK9 - JDK21 及以下 LTS 默认,逐步被 ZGC 替代
分代 ZGC 商用 JDK21 JEP426 2026 互联网高并发业务首选
Parallel Scavenge+SerialOld 完全移除 JDK25 延续 JEP366 2026 主流新版本不再支持

业务选型建议(2026 现状)

  1. 老旧系统(JDK8)

    禁止使用 CMS、Serial+CMS、ParNew+SerialOld,优先切换 Parallel GC,升级 JDK21 分代 ZGC;

  2. JDK17 / JDK21 主流生产

    • 吞吐量优先:Parallel Scavenge + Parallel Old

    • 平衡延迟 & 吞吐量:G1

    • 微秒级低延迟、大堆业务:分代 ZGC

  3. JDK25 + 新版本

    仅保留 Serial 全套、Parallel 全套两款传统配对,新项目直接使用 ZGC/Shenandoah 一体化 GC,不再使用老式分代配对;

  4. 完全杜绝 CMS 相关参数、旧废弃组合启动参数,无任何兼容兜底。

为什么要有很多收集器,一个不够吗?

因为 Java 的使用场景很多,移动端,服务器等。所以就需要针对不同的场景,提供不同的垃圾收集器,提高垃圾收集的性能。

虽然我们会对各个收集器进行比较,但并非为了挑选一个最好的收集器出来。

没有一种放之四海皆准、任何场景下都适用的完美收集器存在,更加没有万能的收集器。所以 我们选择的只是对具体应用最合适的收集器。

查看 JVM 默认的垃圾收集器有多种方法,以下是常用的几种,按推荐程度排序:

方法一:使用 -XX:+PrintCommandLineFlags(启动时输出)

在启动 Java 应用时加上此参数,JVM 启动后会打印所有由命令行显式设置或 JVM 自动选择的参数,其中包含垃圾收集器相关的标志(如 -XX:+UseG1GC-XX:+UseParallelGC 等)。

1
java -XX:+PrintCommandLineFlags -jar yourApp.jar

输出示例:

1
-XX:InitialHeapSize=268435456 -XX:MaxHeapSize=4294967296 -XX:+PrintCommandLineFlags -XX:+UseG1GC

最后一行 -XX:+UseG1GC 即表示当前使用的垃圾收集器。

方法二:使用 jinfo -flag(运行时查看)

对于已经在运行的 Java 进程,可以使用 jinfo 命令查看指定垃圾收集器相关参数的值。

1
2
3
4
5
6
7
8
# 查看是否启用了 G1
jinfo -flag UseG1GC <pid>

# 查看是否启用了 Parallel GC
jinfo -flag UseParallelGC <pid>

# 查看是否启用了 Serial GC
jinfo -flag UseSerialGC <pid>

输出示例:

1
2
-XX:+UseG1GC    # 表示启用
-XX:-UseG1GC # 表示未启用

通过逐一检查各个 GC 标志,可以确定当前生效的收集器。通常只会有一个为 +(正号)。

Serial 回收器:串行回收

Serial 收集器是最基本、历史最悠久的垃圾收集器了。JDK1.3 之前回收新生代唯一的选择。

Serial 收集器作为 HotSpot 中 Client 模式下的默认新生代垃圾收集器

Serial 收集器采用复制算法、串行回收和 “Stop-the-World” 机制的方式执行内存回收。


除了年轻代之外,Serial 收集器还提供用于执行老年代垃圾收集的 Serial Old 收集器。

Serial old 收集器同样也采用了串行回收和 “ stoptheWorld 机制,只不过内存回收算法使用的是 标记-压缩算法

Serial Old 是运行在 Client 模式下默认的老年代的垃圾回收器

Serial Old 在 Server 模式下主要有两个用途:

  1. 与新生代的 Parallel Scavenge 配合使用
  2. 作为老年代 CMS 收集器的后备垃圾收集方案

serialnew

这个收集器是一个单线程的收集器,但它的“单线程”的意义并不仅仅说明它只会使用一个 CPU 或一条收集线程去完成垃圾收集工作,更重要的是在它进行垃圾收集时,必须暂停其他所有的工作线程,直到它收集结束(Stop The World)。

优势:简单而高效(与其他收集器的单线程比),对于限定单个 CPU 的环境来说,Seria1 收集器由于没有线程交互的开销,专心做垃圾收集自然可以获得最高的单线程收集效率。运行在 client 模式下的虚拟机是个不错的选择。

在用户的桌面应用场景中,可用内存一般不大(几十 MB 至一两百 MB),可以在较短时间内完成垃圾收集(几十 ms 至一百多 ms),只要不频繁发生,使用串行回收器是可以接受的。

在 HotSpot 虚拟机中,使用 -XX:+UseSerialGC 参数可以指定年轻代和老年代都使用串行收集器。等价于新生代用 SerialGC,且老年代用 Serial OldGC

ParNew 回收器:并行回收

ParNew 收集器则是 Serial 收集器的多线程版本。

Par 是 Parallel 的缩写,New:只能处理的是新生代

ParNew 收集器除了采用并行回收的方式执行内存回收外,两款垃圾收集器之间几乎没有任何区别。

ParNew 收集器在年轻代中同样也是采用复制算法、”Stop-the-World” 机制。

ParNew 是很多 JVM 运行在 Server 模式下新生代的默认垃圾收集器

image-20260708134227619

对于新生代,回收次数频繁,使用并行方式高效

对于老年代,回收次数少,使用串行方式节省资源。(CPU 并行需要切换线程,串行可以省去切换线程的资源)

在单个 CPU 的环境下,ParNew GC 收集器不比 Serial GC 收集器更高效。虽然 Serial DC 收集器是基于串行回收,但是由于 CPU 不需要频繁地做任务切换,因此可以有效避免多线程交互过程中产生的一些额外开销。

因为除 Serial 外,目前只有 ParNewGC 能与 CMS 收集器配合工作

在程序中,开发人员可以通过选项 “-XX:+UseParNewGC” 手动指定使用 ParNew 收集器执行内存回收任务。它表示年轻代使用并行收集器,不影响老年代。

-XX: ParallelGCThreads 限制线程数量,默认开启和 CPU 数据相同的线程数。

Parallel Scavenge 回收器:吞吐量优先

HotSpot 的年轻代中除了拥有 ParNew 收集器是基于并行回收的以外,Parallel Scavenge 收集器同样也采用了 复制算法、并行回收和 STW 机制。

那么 Parallel 收集器的出现是否多此一举?

  • 和 ParNew 收集器不同,Parallel Scavenge 收集器的目标则是达到一个可控制的吞吐量(Throughput),它也被称为吞吐量优先的垃圾收集器。
  • 自适应调节策略也是 Parallel Scavenge 与 ParNew 一个重要区别。

高吞吐量则可以高效率地利用 CPU 时间,尽快完成程序的运算任务。主要 适合在后台运算而不需要太多交互的任务。因此,常见在服务器环境中使用。例如,那些执行批量处理、订单处理、工资支付、科学计算的应用程序。

Parallel 收集器在 JDK1.6 时提供了用于执行老年代垃圾收集的 Parallel Old 收集器,用来代替老年代的 Serial Old 收集器

Parallel Old 收集器采用了 标记-压缩算法,但 同样也是基于并行回收和 “STW” 机制。

image-20260708135741800

在程序吞吐量优先的应用场景中,Parallel 收集器和 Parallel Old 收集器的组合,在 Server 模式下的内存回收性能很不错。

在 Java8 中,默认是此垃圾收集器。

参数配置:

  1. -XX:+UseParallelGC 手动指定年轻代使用 Parallel 并行收集器执行内存回收任务。

  2. -XX:+UseParallelOldGC 手动指定老年代都是使用并行回收收集器

  • 分别适用于新生代和老年代。默认 dk8 是开启的。
  • 上面两个参数,默认开启一个,另一个也会被开启。(互相激活)
  1. -XX: ParallelGCThreads 设置年轻代并行收集器的线程数。一般地最好与 CPU 数量相等,以避免过多的线程数影响垃圾收集性能。
  • 在默认情况下,当 CPU 数量小于 8 个,ParallelGCThreads 的值等于 CPU 数量
  • 当 CPU 数量大于 8 个,ParallelGcThreads 的值等于 $3+(5*CPU_Count)/8$
  1. -XX: MaxGCPauseMillis 设置垃圾收集器最大停顿时间(即 STW 的时间),单位是毫秒。
  • 为了尽可能地把停顿时间控制在 MaxGCPauseMillis 以内,收集器在工作时会调整 Java 堆大小或者其他一些参数。
  • 对于用户来讲,停顿时间越短体验越好。但是在服务器端,我们注重高并发,整体的吞吐量。所以服务器端适合 Parallel,进行控制。
  • 该参数使用需谨慎。
  1. -XX:GCTimeRatio 垃圾收集时间占总时间的比例($1/(N+1)$)。用于衡量吞吐量的大小。
  • 取值范围(0,100)。默认值 99,也就是垃圾回收时间不超过 1%。
  • 与前一个-XX: MaxGCPauseMillis 参数有一定矛盾性。暂停时间越长,Radio 参数就容易超过设定的比例。
  1. -XX:+UseAdaptiveSizePolicy 设置 Parallel Scavenge 收集器具有自适应调节策略
  • 在这种模式下,年轻代的大小、Eden 和 Survivor 的比例、晋升老年代的对象年龄等参数会被自动调整,已达到在堆大小、吞吐量和停顿时间之间的平衡点。
  • 在手动调优比较困难的场合,可以直接使用这种自适应的方式,仅指定虚拟机的最大堆、目标的吞吐量(GCTimeRatio)和停顿时间(MaxGCPauseMills),让虚拟机自己完成调优工作。

CMS 回收器:低延迟

在 JDK1.5 时期,HotSpot 推出了一款在强交互应用中几乎可认为有划时代意义的垃圾收集器:CMS(concurrent-Mark-Sweep)收集器,这款收集器是 HotSpot 虚拟机中第一款 真正意义上的并发收集器,它第一次 实现了让垃圾收集线程与用户线程同时工作。

CMS 收集器的关注点是尽可能 缩短垃圾收集时用户线程的停顿时间。

目前很大一部分的 Java 应用集中在互联网站或者 B/S 系统的服务端上,这类应用尤其重视服务的响应速度,希望系统停顿时间最短,以给用户带来较好的体验 CMS 收集器就非常符合这类应用的需求。

CMS 的垃圾收集算法采用 标记-清除算法,并且也会 “Stop-The-World

不幸的是,CMS 作为老年代的收集器,却无法与 JDK1.4.0 中已经存在的新生代收集器 Parallel Scavenge 配合工作,所以在 JDk1.5 中使用 CMS 来收集老年代的时候,新生代只能选择 ParNew 或者 Serial 收集器中的一个。

在 G1 出现之前,CMS 使用还是非常广泛的。一直到今天,仍然有很多系统使用 CMS

CMS 工作原理

image-20260708150321123

CMS 整个过程分为 4 个主要阶段,即 初始标记阶段、并发标记阶段、重新标记阶段和并发清除阶段。

初始标记(Initial-Mark)阶段:在这个阶段中,程序中所有的工作线程都将会因为“stop-the-World”机制而出现短暂的暂停,这个阶段的主要任务仅仅只是标记出 GC Roots 能直接关联到的对象。一旦标记完成之后就会恢复之前被暂停的所有应用线程。由于直接关联对象比较小,所以这里的速度非常快。

并发标记(Concurrent-Mark)阶段:从 GC Roots 的直接关联对象开始遍历整个对象图的过程,这个过程耗时较长但是不需要停顿用户线程,可以与垃圾收集线程一起并发运行。

重新标记(Remark)阶段:由于在并发标记阶段中,程序的工作线程会和垃圾收集线程同时运行或者交叉运行,重新标记(Remark)是 CMS 的一个 Stop-The-World 阶段,发生在并发标记之后。它的目的是 修正并发标记期间因应用程序运行而导致的对象引用变化,确保最终标记结果的准确性。这个阶段的停顿时间通常会比初始标记阶段稍长一些,但也远比并发标记阶段的时间短。

并发清除(Concurrent-Sweep)阶段:此阶段 清理删除掉标记阶段判断的已经死亡的对象,释放内存空间。 由于不需要移动存活对象,所以这个阶段也是可以与用户线程同时并发的。

目前所有的垃圾收集器都做不到完全不需要“stop-the-world”,只是尽可能地缩短暂停时间。

由于最耗费时间的并发标记与并发清除阶段者不需要暂停工作,所以整体的回收是低停顿的。

另外,由于在垃圾收集阶段用户线程没有中断,所以在 CMS 回收过程中,还应该 确保应用程序用户线程有足够的内存可用。因此,CMS 收集器不能像其他收集器那样等到老年代几乎完全被填满了再进行收集,而是 当堆内存使用率达到某一值时,便开始进行回收,以确保应用程序在 CMS 工作过程中依然有足够的空间支持应用程序运行。要是 CMS 运行期间预留的内存无法满足程序需要,就会出现一次“Concurrent Mode Failure 失败,这时虚拟机将启动后备预案:临时启用 Serial Old 收集器 来重新进行老年代的垃圾收集,这样停顿时间就很长了。

既然 Mark Sweep 会造成内存碎片,那么为什么不把算法换成 Mark Compact 呢?

因为当并发清除的时候,如果用 Compact 整理内存,原来的用户线程使用的内存还怎么用呢?要保证用户线程能继续执行,前提的它运行的资源不受影响嘛。Mark Compact 更适合“stop the World” 这种场景下使用。

CMS 的优点:

  • 并发收集
  • 低延迟

CMS 的弊端:

  1. 会产生内存碎片,导致并发清除后,用户线程可用的空间不足。在无法分配大对象的情况下,不得不提前触发 Full GC。
  2. CMS收集器对CPU资源非常敏感。 在并发阶段,它虽然不会导致用户停顿,但是会因为占用了一部分线程而导致应用程序变慢,总吞吐量会降低。
  3. CMS收集器无法处理浮动垃圾。可能出现“Concurrent Mode Failure “ 失败而导致另一次 Full GC 的产生。在并发标记阶段由于程序的工作线程和垃圾收集线程是同时运行或者交运行的,那么 在并发标记阶段如果产生新的垃圾对象,CMS 将无法对这些垃圾对象进行标记,最终会导致这些新产生的垃圾对象没有被及时回收,从而只能在下一次执行 GC 时释放这些之前未被回收的内存空间。

浮动垃圾 是指在 CMS 并发标记阶段,应用程序线程继续运行过程中 新产生的垃圾对象

CMS 参数设置

-XX:+UseConcMarkSweepGC 手动指定使用 CMS 收集器执行内存回收任务。

  • 开启该参数后会自动将-XX:+UseParNewGC 打开。即:ParNew(Young 区用)+CMS(Old 区用)+Serial Old 的组合。

-XX: CMSInitiatingOccupancyFraction = 设置堆内存使用率的阈值,一旦达到该阈值,便开始进行回收。

  • JDK5 及以前版本的默认值为 68,即当老年代的空间使用率达到 68%时,会执行一次 CMS 回收。JDK6 及以上版本默认值为 92%

  • 如果内存增长缓慢,则可以设置一个稍大的值,大的域值可以有效降低 CMS 的触发频率,减少老年代回收的次数可以较为明显地改善应用程序性能。反之,如果应用程序内存使用率增长很快,则应该降低这个值,以避免频繁触发老年代串行收集器。因此通过该选项便可以有效降低 Full GC 的执行次数

-XX:+UseCMSCompactAtFullcollection 用于指定在执行完 Full GC 后对内存空间进行压缩整理,以此避免内存碎片的产生。不过由于内存压缩整理过程无法并发执行,所带来的问题就是停顿时间变得更长了。

-XX: CMSFullGCsBeforeCompaction 设置在执行多少次 Full GC 后对内存空间进行压缩整理。

-XX: ParallelCMSThreads 设置 CMS 的线程数量。

CMS 默认启动的线程数是 $(ParallelGCThreads+3)/4$

ParallelGCThreads 是年轻代并行收集器的线程数。当 CPU 资源比较紧张时,受到 CMS 收集器线程的影响,应用程序的性能在垃圾回收阶段可能会非常糟糕。

总结

如果想要 最小化地使用内存和并行开销,请选 Serial GC;

如果想要 最大化应用程序的吞吐量,请选 Parallel GC;

如果想要 最小化 GC 的中断或停顿时间,请选 CMS GC。

新特性

JDK9 新特性:CMS 被标记为 Deprecate 了(JEP291)

如果对 JDK9 及以上版本的 HotSpot 虚拟机使用参数-XX:+UseConcMarkSweepGC 来开启 CMS 收集器的话,用户会收到一个警告信息,提示 CMS 未来将会被废弃。

JDK14 新特性:删除 CMS 垃圾回收器(JEP363)
移除了 CMS 垃圾收集器,如果在 JDK14 中使用-XX:+UseConcMarkSweepGC 的话,JVM 不会报错,只是给出一个 warning 信息,但是不会 exit。JVM 会自动回退以默认 GC 方式启动 JVM

1
2
OpenJDK 64-Bit Server VM warning: Ignoring option UseConcMarkSweepGC; support was removed in 14.0
and the VM will continue execution using the default collector

G1 回收器:区域分代化

提问:既然我们已经有了前面几个强大的 GC,为什么还要发布 Garbage First(G1)?

原因就在于应用程序所应对的业务越来越庞大、复杂,用户越来越多,没有 GC 就不能保证应用程序正常进行,而经常造成 STW 的 GC 又跟不上实际的需求,所以才会不断地尝试对 GC 进行优化。

G1(Garbage-First)垃圾回收器是在 Java7 update 4 之后引入的一个新的垃圾回收器,是当今收集器技术发展的最前沿成果之一。

与此同时,为了适应现在 不断扩大的内存和不断增加的处理器数量,进一步降低暂停时间(pause time),同时兼顾良好的吞吐量。

官方给 G1 设定的目标是在延迟可控的情况下获得尽可能高的吞吐量,所以才担当起“全功能收集器”的重任与期望。

为什么名字叫做 Garbage First(G1)呢?

因为 G1 是一个并行回收器,它把堆内存分割为很多不相关的区域(Region)(物理上不连续的)。使用不同的 Region 来表示 Eden、幸存者 0 区,幸存者 1 区,老年代等。

G1 GC 有计划地避免在整个 Java 堆中进行全区域的垃圾收集G1 跟踪各个Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。

由于这种方式的 侧重点在于回收垃圾最大量的区间(Region),所以我们给 G1 一个名字:垃圾优先(Garbage First)。


G1(Garbage-First)是一款 面向服务端应用的垃圾收集器,主要针对配备多核 CPU 及大容量内存的机器,以极高概率满足 GC 停顿时间的同时,还兼具高春吐量的性能特征。

在 JDKl.7 版本正式启用,移除了 Experimental 的标识,是 JDK9 以后的默认垃圾回收器,取代了 CMS 回收器以及 Parallel+Parallel Old 组合。被 Oracle 官方称为“全功能的垃圾收集器”。

与此同时,CMS 已经在 JDK9 中被标记为废弃(deprecated)。在 jdk8 中还不是默认的垃圾回收器,需要使用-XX:+UseG1GC 来启用。

G1 的特点

G1 使用了全新的分区算法,其特点如下所示:

并行与并发

并行性:G1 在回收期间,可以有多个 GC 线程同时工作,有效利用多核计算能力。此时用户线程STW

并发性:G1 拥有与应用程序交替执行的能力,部分工作可以和应用程序同时执行,因此,一般来说,不会在整个回收阶段发生完全阻塞应用程序的情况

分代收集

  • 从分代上看,G1依然属于分代型垃圾回收器,它会区分年轻代和老年代,年轻代依然有 Eden 区和 Survivor 区。但从堆的结构上看,它不要求整个 Eden 区、年轻代或者老年代都是连续的,也不再坚持固定大小和固定数量。
  • 将堆空间分为若干个区域(Region),这些区域中包含了逻辑上的年轻代和老年代。
  • 同时兼顾年轻代和老年代。对比其他回收器,或者工作在年轻代,或者工作在老年代。

空间整合

  • CMS:“标记-清除”算法、内存碎片、若干次 GC 后进行一次碎片整理
  • G1 将内存划分为一个个的 region。内存的回收是以 region 作为基本单位的。Region之间是复制算法,但整体上实际可看作是 标记-压缩(Mark-Compact)算法,两种算法都可以 避免内存碎片。这种特性有利于程序长时间运行,分配大对象时不会因为无法找到连续内存空间而提前触发下一次 GC。尤其是当 Java 堆非常大的时候,G1 的优势更加明显。

可预测的停顿时间模型(即:软实时 soft real-time)

  • 这是 G1 相对于 CMS 的另一大优势,G1 除了追求 低停顿 外,还能 建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。
  • 由于分区的原因,G1 可以 只选取部分区域进行内存回收,这样缩小了回的范围,因此对于全局停顿情况的发生也能得到较好的控制。
    G1 跟踪各个 Region 里面的垃圾堆积的价值大小(回收所获得的空间大小以及回收所需时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region。 保证了 G1 收集器在有限的时间内可以获取尽可能高的收集效率。
  • 相比于 CMS GC,G1 未必能做到 CMS 在最好情况下的延时停顿,但是最差情况要好很多。

相较于 CMS,G1 还不具备全方位、压倒性优势。比如在用户程序运行过程中, G1 无论是为了垃圾收集产生的内存占用(Footprint)还是程序运行时的额外执行负载(Overload)都要比 CMS 要高。
从经验上来说,在小内存应用上 CMS 的表现大概率会优于 G1,而 G1 在大内存应用上则发挥其优势。平衡点在 6-8GB 之间。

G1 参数设置

  1. -XX:+UseG1GC
  • 启用 G1 垃圾收集器(JDK 9+ 默认已是 G1,但显式指定可确保兼容旧版本)。
  1. -XX:G1HeapRegionSize=n
  • G1 将堆划分为多个大小相等的 Region,每个 Region 的大小由该参数指定。
  • 必须是 2 的幂(1MB、2MB、4MB、8MB、16MB、32MB)。
  • 若不设置,JVM 会根据最小堆大小自动选择一个值,使得 Region 数量接近 2048 个。
  • 示例:-XX:G1HeapRegionSize=4m
  1. -XX:MaxGCPauseMillis=n
  • 设置 G1 的停顿目标,单位毫秒。G1 会尝试通过调整年轻代大小、Region 数量等来满足该目标。
  • 默认 200ms。设得太小可能导致 GC 过于频繁,吞吐量下降。
  1. -XX:ParallelGCThreads=n
  • 设置 STW 阶段(如 Young GC、Mixed GC 的暂停阶段)使用的并行线程数。
  • 默认值通常等于 CPU 核心数,但上限为 8(某些版本可超过 8,需看具体实现)。
  • 示例:-XX:ParallelGCThreads=4
  1. -XX:ConcGCThreads=n
  • 设置并发标记阶段(与应用程序并发执行的阶段)的线程数。
  • 默认值为 ParallelGCThreads 的 1/4 左右(向上取整)。
  • 示例:若 ParallelGCThreads=8,则 ConcGCThreads 默认约为 2。
  1. -XX:InitiatingHeapOccupancyPercent=n
  • 触发并发标记周期的堆占用率阈值(百分比)。
  • 默认 45%,即整个堆使用率达到 45% 时,G1 会启动并发标记周期(为后续的 Mixed GC 做准备)。
  • 增大该值可推迟并发标记,减少 GC 频率,但可能增加 Full GC 风险;减小则相反。
  • 示例:-XX:InitiatingHeapOccupancyPercent=60

G1 收集器的设计目标与适用场景

  • 核心定位:面向服务端应用,专为具有 大内存、多处理器 的机器设计。(注:在普通大小的堆里表现并不惊喜)。
  • 主要目标:为需要 低 GC 延迟 且具备 大堆 的应用程序提供解决方案。
  • 性能指标:在堆大小约 6GB 或更大 时,可实现可预测的停顿时间 低于 0.5 秒
  • 实现原理:通过 增量式清理 来保证每次 GC 停顿时间不会过长,即每次只清理一部分而不是全部的 Region。

G1 与 CMS 的替代关系及优势场景

G1 的出现主要是用来替换掉 JDK 1.5 中的 CMS 收集器。在以下三种情况时,使用 G1 可能比 CMS 更好:

  1. 对象存活率高:超过 50% 的 Java 堆被活动数据(存活对象)占用。
  2. 分配速率波动大:对象分配频率或年代提升频率变化很大。
  3. 停顿时间过长:当前的 GC 停顿时间过长(长于 0.5 至 1 秒)。

G1 独特的线程协作机制

HotSpot 垃圾收集器中,除了 G1 以外,其他收集器(如 Parallel Scavenge, CMS)均使用内置的 JVM 线程执行 GC 的多线程操作。

G1 GC 采用了一种混合模式

  • 它可以采用 应用程序线程 承担后台运行的 GC 工作。
  • 即当 JVM 的 GC 线程处理速度慢时,系统会调用应用程序线程帮助加速垃圾回收过程。

分区 Region

使用 G1 收集器时,它将整个 Java 堆划分成约 2048 个大小相同的独立 Region 块,每个 Region 块大小根据堆空间的实际大小而定,整体被控制在 1MB 到 32MB 之间,且为 2 的 N 次幂,即 1MB,2MB,4MB,8MB,16MB,32MB。可以通过 -XX:G1HeapRegionSize 设定。所有的 Region 大小相同,且在 JVM 生命周期内不会被改变

虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分 Region(不需要连续)的集合。通过 Region 的动态分配方式实现逻辑上的连续。

image-20260708161334388

一个 Region 有可能属于 Eden,Survivor 或者 Old/Tenured 内存区域。但是一个 Region 只可能属于一个角色。图中的 E 表示该 Region 属于 Eden 内存区域,S 表示属于 Survivor 内存区域,O 表示属于 Old 内存区域。图中空白的表示未使用的内存空间。

G1 垃圾收集器还增加了一种新的内存区域,叫做 Humongous 内存区域,如图中的 H 块。主要用于存储大对象,如果超过 1.5 个 Region,就放到 H。

Humongous adj.巨大无比的,极大的

设置 H 的原因:

对于堆中的大对象,默认直接会被分配到老年代,但是如果它是一个短期存在的大对象,就会对垃圾收集器造成负面影响。为了解决这个问题,G1 划分了一个 Humongous 区,它用来专门存放大对象。如果一个 H 区装不下一个大对象,那么 G1 会寻找连续的 H 区来存储。为了能找到连续的 H 区,有时候不得不启动 Full GC。G1 的大多数行为都把 H 区作为老年代的一部分来看待。

G1 GC 的垃圾回收过程

  1. 年轻代 GC(Young Gc)
  2. 老年代并发标记过程(Concurrent Marking)
  3. 混合回收(Mixed GC)
  4. (如果需要,单线程、独占式、高强度的 Full GC 还是继续存在的。它针对 GC 的评估失败提供了一种失败保护机制,即强力回收。)

image-20260708162925409

顺时针,young gc -> young gc + concurrent mark-> Mixed GC 顺序, 进行垃圾回收。

过程描述

应用程序分配内存,当年轻代的Eden区用尽时开始年轻代回收过程;G1 的年轻代收集阶段是一个 并行独占式 收集器。在年轻代回收期,G1 GC 暂停所有应用程序线程,启动多线程执行年轻代回收。然后从年轻代区间移动存活对象到Survivor区间或者老年区间也有可能是两个区间都会涉及

当堆内存使用达到一定值(默认 45%)时,开始老年代并发标记过程。

标记完成马上开始混合回收过程。对于一个混合回收期,G1 GC 从老年区间移动存活对象到空闲区间,这些空闲区间也就成为了老年代的一部分。和年轻代不同,老年代的 G1 回收器和其他 GC 不同,G1的老年代回收器不需要整个老年代被回收,一次只需要扫描/回收一小部分老年代的Region就可以了。同时,这个老年代Region是和年轻代一起被回收的。

“Eden 满了”的意思是:所有被标记为 Eden 的 Region 都已填满,没有空闲的 Eden Region 可供分配新对象了

“Eden 满了”是触发 Young GC 的条件,但回收多少个 Eden Region 由 MaxGCPauseMillis 控制。G1 通过“只回收一部分、剩下的下次再说”的方式,把一次大停顿拆成多次小停顿,从而实现可预测的延迟。

举个例子:一个 Web 服务器,Java 进程最大堆内存为 4G,每分钟响应 1500 个请求,每 45 秒钟会新分配大约 2G 的内存。G1 会每 45 秒钟进行一次年轻代回收,每 31 个小时整个堆的使用率会达到 45%,会开始老年代并发标记过程,标记完成后开始四到五次的混合回收。

1
2
3
4
5
6
7
8
9
正常运行时:
Young GC (多次) → 堆使用率上升 → 触发并发标记周期
→ 初始标记 (STW, 很短)
→ 并发标记 (与应用并发)
→ 最终标记 (STW)
→ 筛选回收 (STW, 选出待回收 Region)
→ Mixed GC (多次, 每次 STW)
→ 回收部分老年代 Region + 年轻代
→ 堆使用率下降 → 回到 Young GC 循环

G1 的“低延迟”体现在哪里?

既然 Young GC 还是 STW,那 G1 号称的低延迟是怎么做到的?答案是:G1 把 STW 的时间分摊到多次、每次只做一点点,而不是像 Parallel GC 那样一次做完一大块。

收集器 Young GC 方式 停顿特点
Parallel Scavenge 一次回收整个年轻代(Eden + Survivor) 年轻代越大,停顿越长
G1 每次只回收一部分 Eden Region(受 MaxGCPauseMillis 控制) 停顿时间可预测,通常 100~200ms

为什么 Young GC 必须 STW?

因为年轻代回收使用的是 复制算法),需要把存活对象从一个地方搬到另一个地方。这个过程中:

  1. 对象地址会变:存活对象从 Eden + Survivor 复制到新的 Survivor 或老年代,所有指向这些对象的引用都必须更新为新地址。
  2. 引用关系必须冻结:如果在搬移过程中,应用线程还在修改引用(比如把某个对象的字段指向被搬移的对象),就会出现“搬了一半,引用指向了旧地址”的混乱局面。
  3. 原子性要求:搬移操作必须是一个原子快照,要么全部搬完并更新引用,要么不搬。不能边搬边改。

RememberedSet

一个对象被不同区域引用的问题

一个 Region 不可能是孤立的,一个 Region 中的对象可能被其他任意 Region 中对象引用,判断对象存活时,是否需要扫描整个 Java 堆才能保证准确?

在其他的分代收集器,也存在这样的问题(而 G1 更突出)回收新生代也不得不同时扫描老年代?

这样的话会降低 MinorGc 的效率

解决方法:

  • 无论 G1 还是其他分代收集器,JVM 都是使用 RememberedSet 来避免全局扫描
  • 每个 Region 都有一个对应的 RememberedSet;
  • 每次 Reference 类型数据写操作时,都会产生一个 Write Barrier 暂时中断操作;

写屏障(Write Barrier) 是 JVM 在对象引用赋值操作前后插入的一段代码,用于 拦截并记录引用关系的变化,从而辅助垃圾收集器维护跨代引用、并发标记等信息。它是实现 并发标记分代回收 的关键基础设施。

  • 然后检查将要写入的引用指向的对象是否和该 Reference 类型数据在不同的 Region(其他收集器:检查老年代对象是否引用了新生代对象);
  • 如果不同,通过 CardTable 把相关引用信息记录到引用指向对象的所在 Region 对应的 Remembered Set 中
  • 当进行垃圾收集时,在 GC 根节点的枚举范围加入 RememberedSet:就可以保证不进行全局扫描,也不会有遗漏。

G1 优化建议

年轻代大小

  • 避免使用-Xmn 或-XX:NewRatio 等相关选项显式设置年轻代大小
  • 固定年轻代的大小会覆盖暂停时间目标

暂停时间自标不要太过严苛

  • G1 GC 的吞吐量自标是 90%的应用程序时间和 10%的垃圾回收时间
  • 评估 G1 GC 的吞吐量时,暂停时间自标不要太严苛。目标太过严苛表示你愿意承受更多的拉圾回收开销,而这些会直接影响到吞吐量。

7 种经典垃圾回收器总结

垃圾收集器 分类 作用位置 使用算法 特点 适用场景
Serial 串行运行 新生代 复制算法 响应速度优先,单线程 STW 单 CPU 客户端程序、嵌入式小内存场景
ParNew 并行运行 新生代 复制算法 响应速度优先,多线程 STW 多 CPU 服务端,历史上搭配 CMS 使用
Parallel Scavenge(Parallel) 并行运行 新生代 复制算法 吞吐量优先,可控最大吞吐量 后台批处理、离线计算、低交互服务
Serial Old 串行运行 老年代 标记 - 压缩算法 响应速度优先,单线程完整 STW 单 CPU 客户端;CMS 并发失败兜底 Full GC
Parallel Old 并行运行 老年代 标记 - 压缩算法 吞吐量优先,多线程 STW Parallel Scavenge 配套,吞吐量优先服务端
CMS 并发运行 老年代 标记 - 清除算法 低停顿并发回收,存在内存碎片 早期互联网 B/S 高并发业务,JDK14 已删除
G1 并发 + 并行运行 新生代 + 老年代(全堆 Region 分区) 复制算法、标记 - 压缩算法 可预测停顿,兼顾吞吐量与低延迟,自动整理碎片 通用服务端,大内存堆业务,JDK9 起默认 GC

Java 垃圾收集器的配置对于 JVM 优化来说是一个很重要的选择,选择合适的垃圾收集器可以让 JVM 的性能有一个很大的提升。

怎么选择垃圾收集器?

1:优先调整堆的大小让 JVM 自适应完成。

2. 如果内存小于 100M,使用串行收集器

3. 如果是单核、单机程序,并且没有停顿时间的要求,串行收集器

4. 如果是多 CPU、需要高吞吐量、允许停顿时间超过 1 秒,选择并行或者 JVM 自已选择
5.如果是多 CPU、追求低停顿时间,需快速响应(比如延迟不能超过 1 秒,如互联网应用),使用并发收集器
官方推荐 G1,性能高。现在互联网的项目,基本都是使用 G1。

8. GC 日志分析

通过阅读GC日志,我们可以了解Java虚拟机内存分配与回收策略。内存分配与垃圾回收的参数列表

-XX:+PrintGC 输出GC日志。类似:-verbose:gc

-XX:+PrintGCDetails 输出GC的详细日志

-XX:+PrintGCTimeStamps 输出GC的时间戳(以基准时间的形式)

-XX:+PrintGCDatestamps 输出GC的时间戳(以日期的形式,如2013-05-04T21:53:59.234+0800)

-XX:+PrintHeapAtGC 在进行GC的前后打印出堆的信息

-Xloggc:../logs/gc.log 日志文件的输出路径

image-20260708171537349

image-20260708171659833

可以用一些工具去分析这些gc日志。
常用的日志分析工具有:GCViewer、GCEasy(GCEasy)、GCHisto、GCLogViewer、 Hpjmeter、garbagecat等。