Skip to content

垃圾回收 ​

“Java 有 GC,所以不会出现内存泄漏”并不准确。GC 只回收不再可达的对象。如果对象仍被 GC Roots 直接或间接引用,它就会继续存活;例如静态集合持续增长,或线程池中的 ThreadLocal 长期保留对象。Java 中的内存泄漏通常表现为可达但已无业务用途的对象持续累积。

1. 为什么需要 GC ​

C/C++ 手动管理内存(malloc / free),开发者负责分配和释放。常见问题:

  • 内存泄漏:分配了忘记释放
  • 野指针:释放后继续使用
  • Double Free:释放两次导致崩溃

Java 的垃圾回收器(GC)自动识别和回收不再使用的对象。代价是 GC 需要消耗 CPU 时间和内存带宽,也可能暂停应用线程。Stop-The-World(STW) 指应用线程在某个阶段全部暂停;并发收集器则会把部分标记、清理或对象转移工作安排在应用运行期间执行。不同收集器的 STW 阶段、并发阶段和停顿特征并不相同。

排查内存问题时,关键不是确认 JVM 有没有 GC,而是弄清哪些对象仍然可达、哪些回收阶段消耗了时间,以及堆为什么持续增长。

2. 如何判定对象存活 ​

2.1 引用计数法(Java 不采用) ​

每个对象维护一个引用计数器,被引用时 +1,引用断开时 -1,计数为 0 则回收。

问题:无法解决循环引用。

java
a.ref = b;
b.ref = a;
// a 和 b 互相引用,引用计数永远不为 0
// 但实际上两者都可以被回收(没有外部引用指向它们)

2.2 可达性分析(Java 采用) ​

从一组 GC Roots 出发,沿着引用链遍历,能到达的对象是存活的,不能到达的是垃圾。

txt
GC Roots
  ├─→ Object A → Object B → Object C(存活)
  │                    └──→ Object D(存活)
  └─→ Object E(存活)

Object F → Object G(垃圾,没有 GC Root 能到达)

GC Roots 包括:

  • 虚拟机栈中引用的对象(运行时实际需要保留的局部变量)
  • 静态字段引用的对象
  • 常量引用的对象
  • JNI 引用的对象

对象是否仍然可达,要看运行时实际保留的引用关系。JIT 编译器可能根据变量的实际活跃范围优化代码,某个局部变量即使还没离开源码作用域,也不代表它指向的对象在后续执行中一定需要保持可达;反过来,对象也可能通过其他引用链存活。被 synchronized 持有的对象也可能因运行时的监视器或线程状态而保持可达,具体根类型和处理方式取决于 JVM 实现。

3. 四种引用类型 ​

引用类型回收时机典型用途
强引用只要可达就保留普通 new
软引用空间压力下可回收,具体时机取决于 GC 策略可重建的缓存
弱引用GC 判定对象仅弱可达时可清除WeakHashMap、某些缓存
虚引用get() 始终返回 null;referent 达到虚可达状态后,可通过引用队列感知处理时机与 ReferenceQueue 配合安排清理;Cleaner 的实现机制之一
java
SoftReference<byte[]> soft = new SoftReference<>(new byte[1024 * 1024]);
// 软引用尚未被清除时,get() 返回 referent;被清除后返回 null。
// 清除时机由 GC 根据内存需求决定,不要依赖"内存够就一定保留"。
byte[] data = soft.get();

WeakReference<User> weak = new WeakReference<>(new User("Tom"));
System.gc();            // 仅是请求;JVM 可以忽略
User user = weak.get(); // 可能仍非 null,不能用单次调用测试回收时序

软引用允许 GC 根据内存需求清除其 referent,但具体清除时机由 GC 决定,不存在"内存够就绝不回收"的保证。弱引用不会阻止 referent 被判定为弱可达;当 GC 处理弱可达对象时,会清除指向它的弱引用,若注册了 ReferenceQueue 还可能在之后入队。清除、入队和底层内存实际被回收不是同一个时间点,因此不能用一次 System.gc() 调用断言对象已经回收。

WeakHashMap 是弱引用的典型应用——当 key 不再被其他地方引用时,GC 会自动清除对应的 entry。常用于实现不影响 GC 的缓存。

ThreadLocal 的键使用弱引用,但这不会保证关联值随下一次 GC 一起回收。键失效后,value 可能通过 entry 间接保持可达,直到 ThreadLocalMap 在读取、写入或清理时移除陈旧条目。线程池中的线程长期存活,陈旧条目也可能长期存在,因此应按作用域调用 remove(),避免保留不再需要的对象。

4. 垃圾回收算法 ​

GC 算法主要面临的问题是:回收时,到底优先保吞吐、保空间利用率,还是保内存连续性? 没有一种算法能同时做到“回收快、零碎片、零额外空间、零移动成本”。这就是为什么 HotSpot 不会只押注一种算法,而是根据对象的存活特征组合使用不同算法。

算法核心动作主要优势主要代价更适合
标记-清除标记存活对象后清理垃圾不需要复制全部存活对象,也不需要对等的备用空间产生碎片,后续分配可能受连续空间限制可容忍碎片的区域
标记-复制将存活对象复制到新空间存活对象搬迁后空间连续,分配简单需要目标空间,存活对象越多复制成本越高新生代
标记-整理标记后把存活对象压缩到一端通过压缩降低碎片,不必长期保留对等备用空间移动对象、修正引用可能增加回收成本老年代

4.1 标记-清除(Mark-Sweep) ​

当存活对象比例较高时,把所有存活对象整体复制一遍并不划算;先找出垃圾,再把垃圾清掉,是成本最低的第一步。

优点:不需要额外的大块备用空间,思路直接。

缺点:会留下碎片。即使空闲空间总量足够,分配器也可能找不到满足请求的连续空间。后续是否触发 Full GC、是否进行压缩,以及是否最终出现分配失败,取决于所用收集器和当时的堆状态。

CMS 的核心回收阶段就是典型的标记-清除思路。

4.2 标记-复制(Copying) ​

如果一个区域里的对象大多“朝生夕灭”,那么与其清理一地碎片,不如只把少数还活着的对象搬走,剩下整块空间一次性清空。

优点:回收后内存连续,分配只需“指针碰撞”,非常快。

代价:需要预留额外空间作为 To Space;如果存活对象很多,复制成本会迅速升高。

因此它特别适合新生代。注意,“浪费一半空间”是算法层面的直观理解;HotSpot 新生代通过 Eden + Survivor + Survivor 的布局降低了这个代价,并不是机械地空着一半堆不用。

4.3 标记-整理(Mark-Compact) ​

老年代里的对象存活率通常更高,既不能接受大量碎片,也不适合像复制算法那样长期预留一整块对等空间,于是需要“边清理、边压缩”。

优点:回收后没有碎片,空间利用率高。

代价:移动对象并修正引用需要额外开销,停顿通常比单纯的标记-清除更重。

很多 Full GC 或老年代压缩都带有明显的标记-整理特征。

新生代更偏爱标记-复制,因为“死得快”;老年代更常依赖标记-清除或标记-整理,因为“活得久”。下一节的分代收集,本质上就是把这三种算法的取舍放到不同区域里分别发挥。

5. 分代收集思想 ​

分代思想的核心,不是把堆机械地切成几块,而是承认不同对象的寿命完全不同。 如果所有对象都用同一种算法、同一种频率回收,短命对象会拖累长寿对象,长寿对象也会放大回收成本。

HotSpot 的经验假设是:绝大多数对象朝生夕灭,只有少数对象会长期存活。 对象朝生夕灭是分代假说,不是所有应用都满足的固定比例;大量缓存、对象池或长期驻留数据的系统,完全可能有不同的存活分布。既然对象的存活特征不同,就应该把它们分开管理、分别回收——这就是分代收集思想。

5.1 什么是分代思想 ​

在传统分代收集器中,年轻代主要处理新分配、存活率较低的对象;老年代则容纳经过多轮回收仍然存活的对象。两代可以采用不同的回收频率和算法,但区域划分与具体晋升规则取决于收集器实现。

txt
短命对象放在新生代   → 用标记-复制,回收快
长寿对象放在老年代   → 用标记-清除 / 标记-整理,减少空间浪费

因此,JVM 通常将堆分为两代:

代区域特点更常使用的算法
新生代Eden + S0 + S1对象生命周期短,死亡率高标记-复制
老年代Old对象生命周期长,存活率高标记-清除 / 标记-整理
  • Young GC(Minor GC):回收新生代,频率高但速度快
  • Old GC / Full GC:回收老年代(或整个堆),频率低但速度慢,停顿通常比 Young GC 更明显

5.2 对象如何进入老年代 ​

对象并不是一创建就直接进入老年代。默认情况下,它会先在新生代里经历几轮回收,只有“活得够久”才会晋升。

txt
new → Eden
       ↓ Minor GC 存活
      Survivor(S0 或 S1)
       ↓ 每次 Minor GC 存活,年龄 +1
       ↓ 年龄达到阈值(默认 15)
      Old(老年代)

这套设计的意义很直接:让大量短命对象在新生代快速回收,不要过早进入老年代制造更重的回收负担。

大对象是否直接进入老年代取决于收集器策略。例如,Serial 使用的 DefNew 可以通过 -XX:PretenureSizeThreshold 设置直接进入老年代的对象大小阈值;G1 则把覆盖多个 Region 的大对象作为 Humongous 对象管理。因此,“大对象直接进老年代”不是所有收集器都相同的默认行为。

在采用传统 Eden、Survivor 和老年代布局的 HotSpot 收集器中,对象是否晋升不只取决于固定年龄阈值。HotSpot 还可以根据 Survivor 中不同年龄对象的累计占用量,动态调整本次使用的晋升阈值。

例如,假设 Survivor 容量为 100MB,年龄 1、2、3 的对象分别占用 10MB、15MB、20MB。累计到年龄 3 时占用量为 45MB,没有超过空间的一半;再计入年龄 4 的 18MB 后,累计占用量达到 63MB,超过 50MB,因此本例的动态年龄阈值为 4,年龄达到或超过 4 的对象会被纳入晋升范围。

这是一种根据存活对象分布调整晋升阈值的机制,不是所有收集器共同遵循的规则。实际晋升结果还受收集器实现、对象年龄分布和 Survivor 空间状况影响。

5.3 跨代引用问题 ​

分代思想解决了“不同对象该怎么分别回收”的问题,但也带来了新的工程问题:如果老年代对象引用了新生代对象,Minor GC 时怎么知道这个新生代对象还活着?

最笨的办法,是每次 Minor GC 都把整个老年代扫描一遍。但老年代通常很大,这会让本来应该很快的新生代回收变得越来越慢。

解决方案是维护跨区域引用信息。卡表是 HotSpot 中常见的区域级跟踪结构,G1 则进一步维护 Region 级的 Remembered Set。

卡表将堆划分为固定大小的卡片,并标记可能需要重新检查的区域;它记录的是区域级线索,不是每条对象引用的精确清单。写屏障在引用字段发生写入时更新这些信息,这是 JVM 自动完成的。

G1 将堆划分为 Region,并为 Region 维护 Remembered Set(RSet),以便回收某个 Region 时找到其他 Region 中可能指向它的引用。RSet 的维护依赖写屏障和相关的卡表信息;卡表与 RSet 职责不同,前者提供区域级待检查信息,后者帮助定位跨 Region 的引用来源。RSet 会占用额外内存,其大小与对象引用分布、Region 数量和运行负载有关,不要把"约占堆空间 5%~10%"当成固定比例。

6. HotSpot 垃圾收集器演进 ​

前面已经讲了算法和分代。收集器之间的差异,来自吞吐量、停顿时间、内存开销和堆规模的不同取舍:有的业务更怕停顿,有的业务更怕吞吐下降,有的业务堆很小,有的业务堆大到几十 GB 甚至 TB 级。这一章沿着这条线索,把收集器的演进和选择讲清楚。

6.1 为什么会有这么多 GC ​

一次回收同时面对几个相互拉扯的目标,没有一种收集器能同时把每个目标都做到最好。

目标含义典型诉求
吞吐量单位时间内应用真正干活的时间占比批处理、后台计算,能接受停顿但求总量
延迟单次停顿时间的长短Web 服务、实时交易,停顿越短越好
堆大小需要管理的堆规模小堆几十 MB,大堆几十 GB 甚至 TB 级

追求吞吐的收集器,停顿往往较长;追求低延迟的收集器,往往要牺牲一部分吞吐和额外开销;堆越大,回收越难做得既快又无感。

这三个目标也对应了收集器演进的主线,先解决能不能把垃圾高效回收掉,再解决回收时能不能别停太久,最后解决堆很大时能不能仍然几乎不停。后面几节按这条主线展开。

6.2 经典分代收集器时代 ​

在 G1 之前,HotSpot 的主流思路不是用一个收集器管整堆,而是给新生代选一个收集器,再给老年代选一个收集器,拼成一套方案。

为什么需要这个组合,本页第 5 节已经讲了分代思想,新生代对象死得快,适合复制算法;老年代对象活得久,适合标记-清除或标记-整理。既然两代对象的特征和最优算法不同,收集器自然也要分开选。

txt
新生代:对象多、死得快
  → 关注回收速度和分配速度
  → 更适合复制算法

老年代:对象少、活得久
  → 关注空间利用率和碎片控制
  → 更适合标记-清除 / 标记-整理

于是 HotSpot 的思路变成:
  为新生代选一个收集器
  + 为老年代选一个收集器
  = 拼成一套完整 GC 方案

这就是为什么你会同时看到 Serial、ParNew、Parallel Scavenge、CMS、Parallel Old 这些名字。它们很多不是彼此完全替代,而是分工不同、可以搭配。

在传统分代收集器中,常见的回收事件包括年轻代回收和老年代相关回收;Full GC 则通常表示范围更广、成本更高的回收过程。但这些名称并不是所有收集器都共享的一套严格分类,具体含义要结合收集器和 GC 日志判断。

  • Young GC:只回收新生代,频率高、速度快
  • Old GC:主要处理老年代压力,比 Young GC 更重
  • Full GC:对整个堆做一次更彻底的回收,停顿最长,最不希望频繁出现

例如,G1 会执行 Young GC 和 Mixed GC;ZGC、Shenandoah 的回收流程也不能直接套入经典的 Eden、Survivor、Old 三段式模型。频繁 Full GC 通常意味着系统已经进入危险区,Young GC 本身不可怕。

Major GC 在不同资料和日志里的用法并不总一致,有的等同于老年代回收,有的又和 Full GC 混着说。为避免歧义,本文统一用 Young GC / Old GC / Full GC。

6.3 三条路线的演进 ​

经典分代时代,沿着简单、吞吐、低延迟三条压力线,各自演化出一条路线。

路线目标新生代老年代核心特点
Serial简单稳定Serial(单线程复制)Serial Old(单线程标记-整理)全程 STW,实现最直接
Parallel高吞吐Parallel Scavenge(多线程复制)Parallel Old(多线程标记-整理)并行回收,总吞吐最高
CMS低延迟ParNew(多线程复制)CMS(并发标记-清除)老年代尽量并发,缩短长停顿

6.3.1 Serial,简单 ​

Serial + Serial Old 是最朴素的一套,单线程、全程 STW,实现最直接,调优复杂度最低,典型可追溯到 JDK 1.3.x 时代。

它的优点不是快,而是简单、稳定、额外开销低。如果堆不大、CPU 也不多,多线程 GC 未必划算。适合小堆、单核、客户端模式。

6.3.2 Parallel,吞吐量 ​

Parallel Scavenge + Parallel Old 不追求每次停顿都短,而是追求整体吞吐量高。多线程并行 GC,核心目标是让单位时间内完成更多工作。Parallel Scavenge 在 JDK 1.4.2 一代进入主流,Parallel Old 到 JDK 6 才补全这条路线。

适合批处理、后台计算、数据分析这类吞吐优先的任务。如果业务可以接受明显停顿,但更在意总处理能力,这条路线比低延迟路线更合适。

6.3.3 CMS,低延迟 ​

ParNew + CMS 第一次大规模把问题从回收效率转向停顿体验。ParNew 负责把年轻代快速收掉,CMS 负责在老年代尽量并发标记、并发清除。这条路线代表 JDK 1.4.2 开始进入主流视野的低停顿方向。

代价也很明显:

  • CMS 会产生碎片
  • 并发阶段会吃 CPU
  • 可能出现 Concurrent Mode Failure,退化成一次更重的 STW 回收

所以 CMS 解决了停顿太长的一部分问题,但没有彻底解决大堆、碎片、低延迟这组矛盾。CMS 在 JDK 9 被弃用,并在 JDK 14 从 JDK 中移除。它仍然值得作为低停顿收集器的历史路线学习:CMS 将部分标记和清理工作与应用线程并发执行,但会产生碎片,也可能因并发回收跟不上分配速度而退化为更重的停顿。新项目不应再把 CMS 当作可选方案。

6.4 G1,从收集器组合到统一管理整堆 ​

G1 出现的背景是,CMS 虽然降低了停顿,但碎片问题始终在;堆一大,回收和压缩的代价又会上来。HotSpot 需要一种更适合中大堆、停顿时间更可预测的方案。G1 收集器在 JDK 7u4 开始可用,JDK 9 成为 HotSpot 默认收集器。

G1 的关键变化不是取消分代,而是把整堆切成很多大小相等的 Region,再按 Region 组织年轻代、老年代和大对象区域。

txt
┌───┬───┬───┬───┬───┬───┐
│ E │ E │ S │ O │ O │ H │  E=Eden, S=Survivor, O=Old
├───┼───┼───┼───┼───┼───┤  H=Humongous(大对象)
│ O │ E │   │ O │ S │   │
├───┼───┼───┼───┼───┼───┤
│   │ O │ E │   │ O │   │
└───┴───┴───┴───┴───┴───┘
  • 使用场景:中大堆、服务端在线系统,希望在吞吐和停顿之间取平衡,并要求停顿时间可预测
  • 核心算法路线:按 Region 回收,年轻代回收偏复制,老年代回收带整理特征
  • 关键能力:通过并发标记 + 按收益回收 Region,把整堆一次性重扫的压力拆小

G1 里最值得记住的是两类回收:

  • Young GC:主要回收年轻代 Region
  • Mixed GC:在回收年轻代的同时,顺带回收一部分垃圾收益最高的老年代 Region

也就是说,G1 不再像早期组合那样把年轻代收集器和老年代收集器死板地配成一对,而是用一套统一框架同时协调两边。

6.5 ZGC / Shenandoah,进一步追求超低延迟 ​

当堆继续变大,哪怕 G1 的停顿已经可预测,对某些业务来说仍然不够。大内存缓存、实时交易、搜索推荐这类系统,可能连几十毫秒的停顿都很敏感。

这时候问题已经变成,能不能不仅并发标记,还把对象转移、整理也尽量并发化。

6.5.1 ZGC ​

  • 发布时期:JDK 11 引入(实验特性),JDK 15 起进入生产可用阶段
  • 使用场景:超大堆、极度关注延迟、能接受一定吞吐和实现复杂度代价的在线服务
  • 核心算法路线:并发整理;借助染色指针、读屏障、并发转移,把 STW 压到极短
  • 核心特点:停顿时间几乎不随堆大小线性放大,适合 TB 级堆

ZGC 的关键不是完全没有 STW,而是把必须 STW 的阶段压缩到极短,把最重的对象移动放到并发阶段。这也是它和传统分代组合、G1 的根本差异。

ZGC 的版本演进需要分清收集器本身和分代模式:JDK 11 以实验特性引入,JDK 15 成为生产特性,JDK 21 引入分代 ZGC(-XX:+UseZGC -XX:+ZGenerational),JDK 23 起分代模式成为 ZGC 的默认模式,JDK 24 移除了非分代 ZGC 模式。这里的"默认模式"指已经选择 ZGC 后默认采用分代还是非分代方式,不是说 ZGC 从 JDK 23 起成为整个 HotSpot 的默认收集器。

6.5.2 Shenandoah ​

  • 发布时期:JDK 12 作为实验特性引入,JDK 15 起成为生产特性
  • 使用场景:同样面向大堆和低延迟场景,尤其适合使用对应 OpenJDK / Red Hat 发行版的服务
  • 核心算法路线:并发整理;通过 Brooks Pointer 和读 / 写屏障完成并发转移
  • 核心特点:目标与 ZGC 相似,但工程实现路径不同;实际是否可用,还要看所用 JDK 发行版是否包含该收集器及其构建配置

Shenandoah 和 ZGC 要解决的是同一类问题,大堆下仍要把停顿压得很低。差异主要不在目标,而在实现机制。

维度ZGCShenandoah
目标超低延迟超低延迟
关键机制染色指针 + 读屏障转发指针 + 读/写屏障
整理方式并发转移并发转移

6.6 现在到底怎么理解和选择 GC ​

回到最初的三个目标,可以得出一张选择图:

txt
堆很小、单核、客户端           → Serial
吞吐优先、能接受停顿           → Parallel Scavenge + Parallel Old
低延迟、中小堆                 → G1(JDK 9+ 默认,多数场景够用)
超低延迟、超大堆               → ZGC / Shenandoah

没有明确的 GC 性能问题时,先使用目标 JDK 的默认收集器,并通过压测和 GC 日志确认它是否满足业务要求。吞吐优先的批处理任务可以评估 Parallel;停顿目标较严格或堆规模较大时,可以对比 G1、ZGC 和 Shenandoah。选择依据应是实际负载下的吞吐、停顿分布和内存开销,而不是只看堆大小或收集器名称。

具体选择还要结合业务目标、JDK 版本、堆大小和压测结果。GC 日志、常用参数和现场采集流程见 JVM 线上诊断。

本章覆盖了 GC 的核心理论。下一步进入JIT 编译,理解热点代码为何越跑越快;随后再看与引用清理紧密相关的堆外内存。线上 GC 问题从JVM 线上诊断进入。

Released under the MIT License.