前言

当需要排查各种内存溢出问题、当垃圾收集成为系统达到更高并发的瓶颈时,我们就需要对这些“自动化”的技术实施必要的监控和调节。

如果没有特殊说明,都是针对的是 HotSpot 虚拟机。

常见面试题:

  • 如何判断对象是否死亡(两种方法)。
  • 简单的介绍一下强引用、软引用、弱引用、虚引用(虚引用与软引用和弱引用的区别、使用软引用能带来的好处)。
  • 如何判断一个常量是废弃常量
  • 如何判断一个类是无用的类
  • 垃圾收集有哪些算法,各自的特点?
  • HotSpot 为什么要分为新生代和老年代?
  • 常见的垃圾回收器有哪些?
  • 介绍一下 CMS,G1 收集器。
  • Minor Gc 和 Full GC 有什么不同呢

堆空间的基本结构

Java 堆是垃圾收集器管理的主要区域,因此也被称作 GC 堆(Garbage Collected Heap)

Java 的自动内存管理主要是针对对象内存的回收和对象内存的分配。

实际上,Java 堆划分不同区域,本质是为了适配 “分代垃圾收集算法”, 不同区域的对象有不同的 “生命周期”,比如新生代对象朝生夕死,老年代对象存活久,针对不同区域用不同的 GC 算法,能最大化回收效率、降低内存开销。

如果堆是一个 “大仓库”,不分区域的话,GC 要扫描整个仓库找垃圾,效率低;划分区域后,只针对 “垃圾多的区域”(比如新生代)高频清理,“垃圾少的区域”(比如老年代)低频清理,性能大幅提升。

在 JDK 7 版本及 JDK 7 版本之前,堆内存被通常分为下面三部分:

  1. 新生代内存(Young Generation)
  2. 老生代(Old Generation)
  3. 永久代(Permanent Generation)

下图所示的 Eden 区、两个 Survivor 区 S0 和 S1 都属于新生代,中间一层属于老年代,最下面一层属于永久代。

JDK 8 版本之后 PermGen(永久) 已被 Metaspace(元空间) 取代,元空间使用的是直接内存

image-20260123111714073

堆这部分在 理解Java虚拟机JVM part1—内存结构 这篇中说过了

内存分配和回收原则

对象优先在 Eden 区分配

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

我们看一下如果对象不超出 Eden 区大小,那么这个堆中内存的分配情况是怎么样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class GCTest {
public static void main(String[] args) {
// 打印JVM堆配置信息(辅助理解)
printHeapInfo();

// 创建一个30MB的字节数组(超过Eden区≈26.6MB的大小)
byte[] allocation1 = new byte[30 * 1024 * 1024]; // 30MB
System.out.println("分配30MB对象后,程序执行完成");
}

// 打印堆内存初始配置
private static void printHeapInfo() {
// 获取新生代总大小(字节)
long youngGen = Runtime.getRuntime().totalMemory() / 3; // 按1/3比例估算
long eden = youngGen * 8 / 10; // Eden占80%
long survivor = youngGen * 1 / 10; // S0/S1各占10%

System.out.println("===== 堆内存初始配置 =====");
System.out.println("新生代总大小:" + youngGen / 1024 / 1024 + "MB");
System.out.println("Eden区大小:" + eden / 1024 / 1024 + "MB");
System.out.println("S0/S1区大小:" + survivor / 1024 / 1024 + "MB");
System.out.println("========================\n");
}
}

添加的参数:-XX:+PrintGCDetails,这个参数

image-20260123112248018

运行示例前,先明确默认的堆分区比例

  • 新生代占堆总大小的 1/3,老年代占 2/3;
  • 新生代内部:Eden 区占 80%,S0/S1 各占 10%。

那么这是运行日志

image-20260123112711840

可以看到,Eden 区用了 38%,S0(from space)和 S1(to space)都用了 0%,现在是没有发生 GC,所以没有对象被复制到 Survivor 区。

然后再看,如果对象超出 Eden 区大小,那么这个堆中内存的GC情况是怎么样的

一般情况下,要看到效果,要进行如下调整

1
java -Xms30m -Xmx30m -XX:NewSize=25m -XX:MaxNewSize=25m -XX:SurvivorRatio=10 -Xlog:gc*=debug:file=gc.log:tags,time,uptime -XX:+UseSerialGC -XX:-UseAdaptiveSizePolicy GCTestMinorGC
image-20260123113915690

可以看到,第一次新生代GC,Eden 区 GC 后大幅下降,这是新生代 GC 的核心行为,第三次新生代 GC,发生了堆的扩容,而且可以看到新生代 GC 非常快,而且日志中短时间内触发了 GC (0)、GC (2) 两次新生代 GC,符合 “新生代对象朝生夕死” 的短生命周期特点。

所以总结,当 Eden 区没有足够空间进行分配时,虚拟机将发起一次 Minor GC,而GC 期间虚拟机又发现 allocation1 太大了无法存入 Survivor 空间,所以只好通过 分配担保机制 把新生代的对象提前转移到老年代中去,老年代中能把allocation1allocation2都放下,所以不会出现 Full GC,然后通过这次 Minor GC,使得 Eden 区的内存占用被释放,后面分配的对象如果能够存在 Eden 区的话,还是会在 Eden 区分配内存,可以继续添加数组验证

大对象直接进入老年代

大对象就是需要大量连续内存空间的对象

大对象直接进入老年代的行为是由虚拟机动态决定的,它与具体使用的垃圾回收器和相关参数有关。大对象直接进入老年代是一种优化策略,旨在避免将大对象放入新生代,从而减少新生代的垃圾回收频率和成本。

G1 垃圾回收器会根据 -XX:G1HeapRegionSize 参数设置的堆区域大小和 -XX:G1MixedGCLiveThresholdPercent 参数设置的进入的阈值,来决定哪些对象会直接进入老年代。

而且很多垃圾回收器中,并没有一个固定的阈值,一般是动态调整来决定何时直接在老年代分配大对象。由虚拟机根据当前的堆内存情况和历史数据动态决定

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

既然虚拟机采用了分代收集的思想来管理内存,那么内存回收时就必须能识别哪些对象应放在新生代,哪些对象应放在老年代中。

为了做到这一点,虚拟机给每个对象一个对象年龄(Age)计数器。

大部分情况,对象都会首先在 Eden 区域分配。如果对象在 Eden 出生并经过第一次 Minor GC 后仍然能够存活,并且能被 Survivor 容纳的话,将被移动到 Survivor 空间(s0 或者 s1)中,并将对象年龄设为 1(Eden 区->Survivor 区后对象的初始年龄变为 1)

此后,对象在 Survivor 中每熬过一次 MinorGC,年龄就增加 1 岁,当它的年龄增加到一定程度(默认为 15 岁),就会被晋升到老年代中。

对象晋升到老年代的年龄阈值,可以通过参数 -XX:MaxTenuringThreshold 来设置。

关于前面说的默认对象在 Survivor 中每熬过一次 MinorGC的晋升年龄是 15,这个说法的来源大部分都是《深入理解 Java 虚拟机》这本书。

如果你去 Oracle 的官网阅读相关的虚拟机参数,你会发现-XX:MaxTenuringThreshold=threshold这里有个说明

默认晋升年龄并不都是 15,这个是要区分垃圾收集器的,例如CMS 就是 6

但是我继续补充一下,就是这个晋升年龄肯定不能大于15,是1-15,否则会报错(因为这与对象头的内存长度有关系)

HotSpot 也是设计了动态年龄计算,提前晋升部分对象,保证 Survivor 区有足够空间

按照前面说的就是

Hotspot 遍历所有对象时,按照年龄从小到大对其所占用的大小进行累积,当累积的某个年龄大小超过了 survivor 区的 50% 时(默认值是 50%,可以通过 -XX:TargetSurvivorRatio=percent 来设置),取这个年龄和 MaxTenuringThreshold 中更小的一个值,作为新的晋升年龄阈值。

主要进行GC的区域

R 大的回答

针对 HotSpot VM 的实现,HotSpot 的 GC 本质是按 “是否收集整个堆” 划分的

部分收集 (Partial GC):

  • 新生代收集(Minor GC / Young GC):
    • 只对新生代(Eden + S0/S1 Survivor 区)进行垃圾收集;
    • 一般在新生代的 Eden 区分配满时触发,Young GC 后,部分存活对象会从新生代晋升到老年代,因此老年代的占用率可能会上升。
  • 老年代收集(Major GC / Old GC):
    • 只对老年代进行垃圾收集。需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
    • 仅 CMS 收集器支持这种模式,这是极少数 “只扫老年代” 的 GC 类型,其他收集器(如 Serial/Parallel/G1)没有单独的 Old GC。
    • 混合收集(Mixed GC):
      • 对整个新生代和部分老年代进行垃圾收集。注意,不收集元空间
      • 仅 G1 收集器支持这种模式;它平衡 GC 停顿时间与回收效率。

整堆收集 (Full GC):收集整个 Java 堆和方法区,覆盖所有内存区域

  • 新生代(Eden+Survivor) + 老年代 + 元空间
  • 除 CMS 外的其他收集器,若要回收老年代,会直接触发 Full GC,因为这些收集器没有单独的 Old GC;而且Full GC 会停顿所有用户线程(STW),且收集范围大,耗时通常比 Partial GC 长得多,应尽量避免。

空间分配担保

空间分配担保会在如下情况下发生:新生代存活对象太多,Survivor 区装不下,需要晋升到老年代,但老年代没有足够空间

所以说,空间分配担保的作用就是:在 Minor GC 前提前检查老年代的 “容纳能力”,避免 Minor GC 后老年代空间不足导致的 OOM 或 Full GC

空间分配担保的逻辑在 JDK 6 Update 24 前后有明显变化

  • JDK 6 Update 24 之前的规则就是比较保守的
    • Minor GC 前会经历3 层检查,三层关系是从上到下的if:
      • 先检查老年代当前最大的连续空闲空间,是否能装下 “新生代所有对象”。因为万一Minor GC 后新生代对象全部存活,如果成立,Minor GC 就是安全的,允许起飞
      • 然后检查是否允许担保失败,跟-XX:HandlePromotionFailure有关,默认开,如果不允许,放弃 Minor GC,直接执行 Full GC;如果允许 → 进入步骤 3。
      • 最后,检查 老年代最大连续空闲空间,是否大于 “之前每次 Minor GC 后晋升到老年代的对象平均大小”;
  • JDK 6 Update 24 之后的规则就比较宽松了,因为我们一想,这个逻辑可以被简化
    • 只要满足以下两个条件之一,就执行 Minor GC;否则执行 Full GC:
      1. 老年代最大连续空间 ≥ 新生代总对象空间;
      2. 老年代最大连续空间 ≥ 历次晋升到老年代的平均大小。

其中,如果发生了在检查 老年代最大连续空间 ≥ 历次晋升到老年代的平均大小 情况的 Minor GC,那么这次 GC 会被标注为有风险,因为可能触发 Full GC

深入理解 Java 虚拟机》第三章对于空间分配担保的描述如下:

JDK 6 Update 24 之前,在发生 Minor GC 之前,虚拟机必须先检查老年代最大可用的连续空间是否大于新生代所有对象总空间,如果这个条件成立,那这一次 Minor GC 可以确保是安全的。如果不成立,则虚拟机会先查看 -XX:HandlePromotionFailure 参数的设置值是否允许担保失败(Handle Promotion Failure);如果允许,那会继续检查老年代最大可用的连续空间是否大于历次晋升到老年代对象的平均大小,如果大于,将尝试进行一次 Minor GC,尽管这次 Minor GC 是有风险的;如果小于,或者 -XX: HandlePromotionFailure 设置不允许冒险,那这时就要改为进行一次 Full GC。

JDK 6 Update 24 之后的规则变为只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小,就会进行 Minor GC,否则将进行 Full GC

死亡对象判断方法

堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡,这些对象不能也不会再被任何用途使用

引用计数法

这是最简明直白的,实现简单,效率高,但是,目前主流的虚拟机中并没有选择这个算法来管理内存,因为它对对象之间的循环引用和递归几乎无从下手

给对象中添加一个引用计数器:

  • 每当有一个地方引用它,计数器就加 1;
  • 当引用失效,计数器就减 1;
  • 任何时候计数器为 0 的对象就是不可能再被使用的。

所谓对象之间的相互引用问题,如下面代码所示:除了对象 objAobjB 相互引用着对方之外,这两个对象之间再无任何引用。但是他们因为互相引用对方,导致它们的引用计数器都不为 0,于是引用计数算法无法通知 GC 回收器回收他们。

1
2
3
4
5
6
7
8
9
10
11
public class ReferenceCountingGc {
Object instance = null;
public static void main(String[] args) {
ReferenceCountingGc objA = new ReferenceCountingGc();
ReferenceCountingGc objB = new ReferenceCountingGc();
objA.instance = objB;
objB.instance = objA;
objA = null;
objB = null;
}
}

可达性分析算法

首先,JVM 管理的所有对象可以抽象成一张有向图,这是理解整个算法的基础,也是 JVM 组织对象的基础

  • 节点:JVM 堆中的每个对象(Object 1~Object 10);
  • :对象之间的引用关系(比如 Object 1 引用 Object 2,就是一条从 Object 1 指向 Object 2 的有向边);
  • GC Roots:一系列图的「起始节点集」的对象
  • 算法目标:从GC Roots向下搜素遍历这张图,找出所有「从 GC Roots 出发不可达的节点」,这些就是待回收的死亡对象。

可达性分析本质是图的深度优先搜索(DFS)广度优先搜索(BFS),因为这两种算法能完整遍历所有可达节点,且时间复杂度为O(N)

下图中的 Object 6 ~ Object 10 之间虽有引用关系,但它们到 GC Roots 不可达,因此为需要被回收的对象。

graph TD
    A[GC Roots] --> B[Object1]
    A --> C[Object2]
    B --> D[Object3]
    C --> E[Object4]
    E --> F[Object5]
    G[Object6] --> H[Object7]
    H --> I[Object8]
    I --> J[Object9]
    J --> K[Object10]

可看到,从从 GC Roots 出发,Object1-5 都能被访问到,但是 Object6-10就遍历不到,判定为死亡(不可达)

而且 GC Roots 不是也不能是堆内对象,而是「JVM 内存模型中不会被 GC 回收的区域」中的引用,本质是操作系统 “内存分段管理” 思想在 JVM 中的体现:

JVM 的内存分为 GC 管辖区域(堆)和 非 GC 管辖区域(栈、方法区、本地方法栈),GC Roots 必须来自非 GC 管辖区域, 因为这些区域的内存由操作系统 / JVM 直接管理,而非 GC 算法管理,因此这些引用是 可靠的根。

那么哪些对象可以作为 GC Roots 呢?

GC Roots 类型 底层存储位置(操作系统 / JVM 视角) 为什么能作为根?
虚拟机栈局部变量表引用的对象 线程栈空间(操作系统为每个线程分配的栈内存,属于进程地址空间的栈段) 线程栈由操作系统管理,栈帧的局部变量是方法执行的临时数据,GC 不会回收栈内存,因此这些引用是 “活的”
本地方法栈 Native 方法引用的对象 本地方法栈(JNI 调用的 C/C++ 代码的栈空间,属于操作系统的本地栈) 本地栈由操作系统内核管理,不受 JVM GC 控制,引用的对象必然是存活的
方法区类静态属性引用的对象 方法区(元空间,JDK8 后为操作系统直接内存) 静态属性属于类级别的数据,类未被卸载时,静态引用不会失效,是稳定的根
方法区常量引用的对象 方法区(常量池,如字符串常量池) 常量是只读的,生命周期与类 / 虚拟机一致,不会被随意回收
同步锁(synchronized)持有的对象 JVM 的锁管理器(存储在方法区 / 元空间) 被锁持有的对象正在被线程使用,必然存活
JNI 引用的对象 JNI 的全局引用表(存储在本地方法区) JNI 全局引用不会被自动释放,是显式管理的根

而操作系统中,进程的地址空间分为栈段、堆段、数据段,其中堆段由 GC 管理,栈段和数据段由操作系统 / JVM 静态管理

那么,对象可以被回收,就代表一定会被回收吗

即使在可达性分析法中不可达的对象,也并非是一定要去死的,这时候它们暂时处于提保候审阶段,要真正宣告一个对象被处刑,至少要经历两次标记过程

  • 第一次标记就是可达性分析
    • 对标记后的对象进行第一次筛选:判断此对象有必要执行finalize()方法;
      • 无需执行的情况:
        • 对象未覆盖finalize()
        • finalize()已被 JVM 调用过(该方法只能执行一次);
      • 无需执行的对象:直接判定为 “待回收”,跳过第二次标记;
      • 需要执行的对象:放入F-Queue队列,等待第二次标记。
  • 第二次标记就是来自F-Queue队列的拯救机会
    • JVM 启动低优先级的 Finalizer 线程,依次执行F-Queue队列中对象的finalize()方法;完成第二次标记的对象会被正式判定为 “死亡对象”,等待 GC 回收。
    • finalize()是对象的 “最后自救机会”,如果对象在该方法中重新与 GC Roots 建立引用(比如把自己赋值给静态变量),则会被移除 “待回收” 列表;
    • 但是,Object 类中的 finalize 方法一直被认为是一个糟糕的设计,成为了 Java 语言的负担,影响了 Java 语言的安全和 GC 的性能。JDK9 版本及后续版本中各个类中的 finalize 方法会被逐渐弃用移除。忘掉它的存在吧!JDK9 后用Cleaner/PhantomReference(虚引用)替代finalize(),基于引用队列实现资源释放,更符合 图的可达性 核心逻辑,且性能可控。

引用类型

无论是通过引用计数法判断对象引用数量,还是通过可达性分析法判断对象的引用链是否可达,判定对象的存活都与 引用 有关。

JDK1.2 前的 传统引用 只有 “有 / 无” 两种状态

  • 有引用 → 对象绝对还存活,GC 不会回收;
  • 无引用 → 对象可以被判断是死亡了,等待 GC 回收。

这种设计过于 极端,无法满足 内存敏感型场景 例如缓存的需求,我们需要一种 弹性引用:内存充足时对象存活,内存不足时对象可回收。因此 Java 扩充了引用类型,按引用强度从强到弱分为四类,让 GC 能根据内存状态灵活回收对象。

四种引用的核心差异是GC 回收的时机:强引用永不回收,软引用内存不足时回收,弱引用 GC 时回收,虚引用不影响对象被回收;

强引用 Strong Reference

这是 Java 中最常见的引用,也是你平时写代码时默认使用的引用。

如果一个对象被强引用指向,那么只要强引用存在,该对象永远不会被 GC 回收,宁愿爆 OOM 也不回收

因为强引用对应的对象在可达性分析中是 “可达节点”,属于 GC Roots 的直接 / 间接引用链。

断开强引用的唯一方式是置为null

所以分析此时的 GC 行为

  • 只要obj != null,即使 JVM 内存不足抛出OutOfMemoryError,GC 也不会回收该对象;
  • 只有当强引用被断开(obj = null),且对象到 GC Roots 不可达时,才会被 GC 回收。

软引用 Soft Reference

软引用是为内存敏感型缓存设计的引用,核心是 “内存充足则存活,内存不足则回收”。

如果一个对象只被软引用指向,那么:

  • 当 JVM内存充足时,对象不会被 GC 回收;
  • 当 JVM内存不足,即将 OOM 时,对象会被 GC 回收。
  • 底层逻辑:软引用对象会被 GC 记录,当触发GC_FOR_MALLOC(内存分配失败)时,优先回收软引用指向的对象。
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
import java.lang.ref.SoftReference;
import java.lang.ref.ReferenceQueue;

public class ReferenceTest {
public static void main(String[] args) {
// 1. 创建被引用的对象(缓存数据)
Object cacheData = new Object();
// 2. 创建引用队列(回收时会将软引用放入队列)
ReferenceQueue<Object> queue = new ReferenceQueue<>();
// 3. 创建软引用,关联缓存数据和引用队列
SoftReference<Object> softRef = new SoftReference<>(cacheData, queue);

// 4. 断开强引用(仅保留软引用)
cacheData = null;

// 5. 模拟内存充足:GC不会回收软引用对象
System.gc();
System.out.println("内存充足时:" + softRef.get()); // 输出:java.lang.Object@xxx

// 6. 模拟内存不足(需手动配置JVM参数:-Xms5m -Xmx5m,堆仅5MB)
// 创建大对象耗尽内存,触发GC回收软引用
byte[] bigObj = new byte[4 * 1024 * 1024]; // 4MB
System.gc();
System.out.println("内存不足时:" + softRef.get()); // 输出:null
}
}

弱引用 Weak Reference

弱引用的强度比软引用更弱,核心是 “只要发生 GC,无论内存是否充足,都会回收弱引用指向的对象”。

弱引用对应的对象在可达性分析中其实也是 “可达节点”,但 GC 会优先标记并回收弱引用对象(即使内存充足)。

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

public class ReferenceTest {
public static void main(String[] args) {
// 1. 创建被引用对象
Object tempData = new Object();
// 2. 创建弱引用
WeakReference<Object> weakRef = new WeakReference<>(tempData);

// 3. 断开强引用
tempData = null;

// 4. 触发GC前:弱引用还能获取对象
System.out.println("GC前:" + weakRef.get()); // 输出:java.lang.Object@xxx

// 5. 触发GC:弱引用对象被回收
System.gc();
System.out.println("GC后:" + weakRef.get()); // 输出:null
}
}

WeakHashMap的 key 是弱引用类型,当 key 被 GC 回收后,对应的键值对会自动从 Map 中移除,避免内存泄漏。

image-20260123122405375

虚引用 Phantom Reference

虚引用是最弱的引用,甚至不能通过虚引用获取对象(无法通过虚引用的get()方法获取对象)

虚引用完全不影响对象的存活,即使有虚引用指向对象,只要没有其他强 / 软 / 弱引用,对象会被立即回收;

必须和ReferenceQueue配合使用,当对象被 GC 回收时,虚引用会被放入队列,用于通知程序 对象已回收。

image-20260123122520780

虚引用的唯一作用就是跟踪对象的回收时机,用于在对象回收后执行一些清理操作

NIO 的DirectByteBuffer(堆外内存)就是通过虚引用实现堆外内存的自动释放。

如何判断一个常量是废弃常量

废弃常量是指运行时常量池中,没有任何对象引用的常量

99% 都是字符串常量,而且别忘了,JDK1.8+后,字符串常量池位置在堆内存,而运行时常量池的其他内容仍在方法区 / 元空间,但废弃常量的判定逻辑是统一的

废弃常量的判定条件仅需满足 1 个条件:

  • 以字符串 “abc” 为例:当前没有任何 String 对象引用该字符串常量。就会成为废弃常量

GC 时(若有必要)会被清理出常量池。

这个若有必要比较灵性,因为废弃常量的回收通常是 “可选的”:只有当内存不足时,GC 才会清理废弃常量;内存充足时,即使是废弃常量也可能留在常量池中。

如何判断一个类是无用的类

类的回收比常量严格得多,因为类是 “模板”,涉及 ClassLoader、反射等逻辑,需要同时满足3 个条件才能被判定为 “无用类”。缺一不可

  • 该类的所有实例都已被回收
    • 堆中不存在该类的任何对象实例(包括子类实例);
    • 举例:若User类的所有User对象都被 GC 回收,满足此条件。
  • 加载该类的 ClassLoader 已被回收
    • 类的生命周期与加载它的 ClassLoader 绑定:若 ClassLoader 还存活,类的 Class 对象也无法被回收;
    • 典型场景:自定义 ClassLoader(比如热部署的 ClassLoader)被回收后,其加载的类才可能被判定为无用类。
  • 该类的 Class 对象没有任何引用
    • 堆中对应的java.lang.Class对象(每个类在堆中都有一个 Class 对象)没有被任何地方引用;
    • 包括:没有通过反射使用该 Class 对象(比如Class.forName("com.User"))、没有将 Class 对象作为静态变量 / 集合元素等。

即使满足上述 3 个条件,类的回收也只是 “可以回收”,而非 “必须回收”:

  • 虚拟机默认不会主动回收类,除非配置了强制回收参数;
  • 类回收主要用于动态加载类的场景(比如 动态代理、OSGi、热部署),避免方法区 / 元空间内存溢出。

垃圾收集算法

标记-清除算法

标记-清除(Mark-and-Sweep)算法分为“标记(Mark)”和“清除(Sweep)”阶段

“标记” 和 “清除” 两个独立阶段执行,逻辑简单直接,是所有 GC 算法的雏形,后续的算法都是对其不足进行改进得到,所以它有两个明显问题:

  • 效率问题:标记和清除两个过程效率都不高。
  • 空间问题:标记清除后会产生大量不连续的内存碎片。

标记什么呢?清除什么呢?回收堆中 “不可达的垃圾对象“

标记-清除算法

以标记不可回收对象(可达对象)为准,不同虚拟机的实现不同

  • 对象创建时,默认标记位为 0(false),表示未标记
  • 以 GC Roots 为起点,执行图的 DFS/BFS 遍历,这部分就是可达性分析了
  • 所有遍历到的 “可达对象”,也就是存活对象,将其标记位设为 1(true),未被遍历到的对象还是 0
  • 然后在清楚阶段,遍历整个堆内存,扫描所有对象的标记位,直接释放其标记位为 0 的对象占用的内存块,同时需要记录释放的空闲内存块信息,供后续分配对象时使用
flowchart LR
    A[对象创建,标记位=0] --> B[启动GC,进入标记阶段]
    B --> C[GC Roots遍历可达对象,标记位=1]
    C --> D[进入清除阶段,扫描堆内存]
    D --> E{标记位=1?}
    E -->|是| F[保留对象,重置标记位=0]
    E -->|否| G[清除对象,释放内存]
    F --> H[GC结束]
    G --> H

很明显,会产生很多不连续的碎片,不利于后续的内存分配,后续分配大对象时,可能因找不到足够大的连续内存块,触发提前 GC

复制算法

为了解决标记-清除算法的效率和内存碎片问题,复制(Copying)收集算法出现了。、

它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收

复制算法

虽然改进了标记-清除算法,但依然存在下面这些问题:

  • 可用内存变小:可用内存缩小为原来的一半。
  • 不适合老年代:如果存活对象数量比较大,复制性能会变得很差

标记-整理算法

标记 - 整理算法是专门针对老年代特性设计的 GC 算法,解决了标记 - 清除的内存碎片问题

标记过程仍然与“标记-清除”算法一样,然后清理阶段改为,先整理存活对象,再批量清除边界外内存,也就是移动存活对象,腾出连续空闲空间

img

由于多了整理这一步,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景

分代收集算法

分代收集算法是当前所有商用 JVM(包括 HotSpot)的核心 GC 策略,它本身不是 新的算法,而是 算法组合策略,根据对象存活周期的差异,将堆内存划分不同区域,适配不同的 GC 算法

这也是利用了操作系统中不同特性内容特性化计算的特性

一般将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。

然后新生代适配复制算法,因为每次收集都会有大量对象死去,只需要付出少量对象的复制成本就可以完成每次垃圾收集

老年代适配标记 - 清除算法,因为老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以不太需要整理

HotSpot 为什么要分为新生代和老年代?

这是分代收集算法的核心前提,本质是 “为了适配不同 GC 算法,最大化 GC 效率”

垃圾收集器

如果说收集算法是内存回收的方法论,那么垃圾收集器就是内存回收的具体实现。

我们能做的就是根据具体应用场景选择适合自己的垃圾收集器

如果有一种四海之内、任何场景下都适用的完美收集器存在,那么我们的 HotSpot 虚拟机就不会实现那么多不同的垃圾收集器了。

JDK 默认垃圾收集器(使用 java -XX:+PrintCommandLineFlags -version 命令查看):

  • JDK 8: Parallel Scavenge(新生代)+ Parallel Old(老年代)
  • JDK 9 ~ JDK22: G1

Serial 收集器

Serial(串行)收集器是最基本、历史最悠久的垃圾收集器了。

而且这是一个单线程的垃圾收集器,意味着它只会使用一条垃圾收集线程去完成垃圾收集工作,更重要的是它在进行垃圾收集工作的时候必须暂停其他所有的工作线程,直到收集结束

Serial 收集器对新生代采用标记-复制算法,老年代采用标记-整理算法。

Serial 收集器

但是 Serial 收集器有没有优于其他垃圾收集器的地方呢?当然有,它简单而高效

Serial 收集器由于没有线程交互的开销,自然可以获得很高的单线程收集效率。Serial 收集器对于运行在 Client 模式(如桌面程序)下的虚拟机来说是个不错的选择

ParNew 垃圾收集器

ParNew 是 JVM 中新生代的垃圾收集器,而且 ParNew 本质上是 Serial 收集器的 “并行化升级款”

新生代采用标记-复制算法,老年代采用标记-整理算法。但是实际上,ParNew 几乎不收集老年代,老年代一般搭配CMS收集,除了 Serial 收集器外,只有它能与 CMS 收集器配合使用,CMS 是老年代的并发收集器,但它本身不支持新生代收集

ParNew 收集器

实际上,它这个并行也不是完全并发操作,新生代分为 Eden 区和两个 Survivor 区(From/To),ParNew 会先标记 Eden + From 区的存活对象,再把这些对象复制到 To 区,最后清空 Eden + From 区。这个过程中用户线程完全暂停(Stop The World,STW),只是用多线程加速了复制过程。

那么,再细说一下并行和并发

  • 并行:多人一起干活,其他人等着
    • 这里就指,多条垃圾收集线程并行工作,但此时用户线程仍然处于等待状态。
  • 并发:你继续干你的,别人跟着你一起干
    • GC 线程和用户线程同时执行(不一定是物理并行,可能是 CPU 时间片交替),用户线程无需全程暂停,仅在少数关键步骤短暂 STW。

它是许多运行在 Server 模式(如 Tomcat、Spring Boot 等服务端应用)下的虚拟机的首要选择,多核下多线程收集能显著缩短新生代 GC 的 STW 时间。

不适合单核 CPU,单核心下多线程的线程切换开销会抵消并行优势,反而比 Serial 慢。

Parallel Scavenge 收集器

Parallel Scavenge 收集器也是使用标记-复制算法的多线程收集器,它看上去几乎和 ParNew 都一样。

首先,两者设计目的一样,Parallel Scavenge 收集器提高了 CPU 吞吐量,使得更高效率的进行垃圾回收,而 ParNew 只是通过并行缩短因 GC 而停顿的用户线程

1
吞吐量 = 运行用户代码的时间 / (运行用户代码的时间 + 垃圾收集的时间)

Parallel Scavenge 的设计初衷就是最大化这个比值,所以它的实时性可能不如 ParNew,但是整体效率要求高,Parallel Scavenge 收集器会更适合一些

新生代采用标记-复制算法,老年代采用标记-整理算法。

Parallel Old收集器运行示意图

Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,使用 Parallel Scavenge 收集器配合自适应调节策略,把内存管理优化交给虚拟机去完成也是一个不错的选择。

参数 作用 示例
-XX:MaxGCPauseMillis 设置最大 GC 停顿时间(毫秒,虚拟机尽量满足,但不一定能达到) -XX:MaxGCPauseMillis=100(要求单次 GC 停顿不超过 100ms)
-XX:GCTimeRatio 设置吞吐量比例(取值 0-100,公式:1/(1+n)) -XX:GCTimeRatio=99(表示 GC 时间占比 ≤ 1%,吞吐量 ≥ 99%)
-XX:+UseAdaptiveSizePolicy 启用自适应调节策略(虚拟机自动调整新生代大小、Eden/Survivor 比例等) 搭配前两个参数使用,无需手动调内存比例,虚拟机会根据你的吞吐量 / 停顿目标来自动调整

它也和老年代 GC 收集器搭配使用,-XX:+UseParallelGC,JDK 1.8 中指定这个参数时,默认会自动启用 UseParallelOldGC(即新生代 Parallel Scavenge + 老年代 Parallel Old);

  • Parallel Old 针对老年代,采用标记 - 整理算法,多线程并行收集,和 Parallel Scavenge 配合实现 “新生代 + 老年代全并行收集”,最大化吞吐量。

Serial Old 收集器

前面提到的,可以和其他新生代垃圾收集器配合使用的 Serial 收集器的老年代版本

它主要有两大用途:一种用途是在 JDK1.5 以及以前的版本中与 Parallel Scavenge 收集器搭配使用,另一种用途是作为 CMS 收集器的后备方案。

老年代使用 标记-整理 算法

img

Parallel Old 收集器

Parallel Scavenge 收集器的老年代版本

使用多线程和“标记-整理”算法。在注重吞吐量以及 CPU 资源的场合,都可以优先考虑 Parallel Scavenge 收集器和 Parallel Old 收集器

Parallel Old收集器运行示意图

CMS 收集器

CMS(Concurrent Mark Sweep)收集器是一种以获取最短回收停顿时间为目标的收集器。

它非常符合在注重用户体验的应用上使用。

因为CMS(Concurrent Mark Sweep)收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。

从名字中的Mark Sweep这两个词可以看出,CMS 收集器是一种 “标记-清除” 算法实现的

它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:

  • 初始标记:短暂停顿,标记直接与 root 相连的对象
  • 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方
  • 重新标记: 重新标记阶段就是为了修正并发标记期间因为用户程序继续运行而导致标记产生变动的那一部分对象的标记记录,这个阶段的停顿时间一般会比初始标记阶段的时间稍长,远远比并发标记阶段时间短
  • 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。
CMS 收集器

虽然它能并发收集、低停顿。但是它有下面三个明显的缺点:

  • 对 CPU 资源敏感(毕竟并发)
  • 无法处理浮动垃圾;
  • 它使用的回收算法 “标记-清除” 算法会导致收集结束时会有大量空间碎片产生。

CMS 垃圾回收器在 Java 9 中已经被标记为过时(deprecated),并在 Java 14 中被移除

G1 收集器

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

G1 收集器的运作大致分为以下几个步骤:

  1. 初始标记:短暂停顿(Stop-The-World,STW),标记从 GC Roots 可直接引用的对象,即标记所有直接可达的活跃对象
  2. 并发标记:与应用并发运行,标记所有可达对象。 这一阶段可能持续较长时间,取决于堆的大小和对象的数量。
  3. 最终标记: 短暂停顿(STW),处理并发标记阶段结束后残留的少量未处理的引用变更。
  4. 筛选回收:根据标记结果,选择回收价值高的区域,复制存活对象到新区域,回收旧区域内存。这一阶段包含一个或多个停顿(STW),具体取决于回收的复杂度
G1 收集器

被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备以下特点:

  • 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
  • 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
  • 空间整合:与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
  • 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。

G1 收集器在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。也就是说,它的收集策略是根据你的参数动态的,但是尽量最优

从 JDK9 开始,G1 垃圾收集器成为了默认的垃圾收集器

ZGC 收集器

与 ParNew 和 G1 类似,ZGC 也采用标记-复制算法,不过 ZGC 对该算法做了重大改进。

ZGC 可以将暂停时间控制在几毫秒以内,且暂停时间不受堆内存大小增长而变长,出现 Stop The World 的情况会更少,但代价是牺牲了一些吞吐量。ZGC 最大支持 16TB 的堆内存。

ZGC 在 Java11 中引入,处于试验阶段。经过多个版本的迭代,不断的完善和修复问题,ZGC 在 Java15 已经可以正式使用了。

不过,默认的垃圾回收器依然是 G1。你可以通过下面的参数启用 ZGC:

1
java -XX:+UseZGC className

在 Java21 中,引入了分代 ZGC,暂停时间可以缩短到 1 毫秒以内。

你可以通过下面的参数启用分代 ZGC:

1
java -XX:+UseZGC -XX:+ZGenerational className
  • 原始 ZGC不分代,每次 GC 都会扫描整个堆(新生代 + 老年代),Java 21才开始分代

ZGC 用标记 - 复制算法,但做了重大改进,核心是把 需要 STW 的复制操作 改成 并发执行

graph TD
    A[初始标记:STW,标记GC Root直接关联对象(<1ms)] --> B[并发标记:遍历着色指针,标记可达对象(无STW)]
    B --> C[并发预备复制:确定要复制的Region(无STW)]
    C --> D[重新标记:STW,修正并发标记的少量错误(<1ms)]
    D --> E[并发复制:通过读屏障+着色指针,移动对象到新Region(无STW)]
    E --> F[并发重映射:更新所有指向旧地址的指针(无STW)]

这种特性涉及到两个核心技术

  • 着色指针

    • 传统指针:仅存储对象的内存地址

    • ZGC 着色指针:利用 64 位指针的高 4 位(未被使用的位) 存储 “标记信息”,包括:

      • 对象的标记状态(是否可达);
      • 对象的所属 Region(内存分区);
      • 对象是否被移动 / 复制中。

      这样再比较对象的时候,只需要修改指针的标记位即可,复制对象时,通过指针标记位就能知道对象是否已被处理

  • 读屏障

    • ZGC 在应用线程读取对象指针时,会插入一段读屏障代码,这是并发复制的保障
    • 当应用线程读取一个指针时,检查指针的 “着色标记”:
      • 如果指针指向的对象正在被复制,读屏障会直接返回 复制后的地址;
      • 如果指针标记为 “未处理”,读屏障会触发对象的并发复制,且不暂停应用线程。

这样,ZGC也就实现了停顿时间不随堆大小增长的特性,因为它的 STW 阶段只处理 “指针标记”,不处理堆内存本身。而ParNew/G1是堆越大,STW 停顿越长

ZGC 为了极致低停顿,在并发阶段会占用更多 CPU 资源,因为读屏障、着色指针的处理有开销