垃圾收集器

62 阅读37分钟

参考HotSpot 虚拟机垃圾回收调优指南

什么是垃圾呢?

An object is considered garbage and its memory can be reused by the VM when it can no longer be reached from any reference of any other live object in the running program.

当对象不再可以从运行程序中任何其他活对象的任何引用中访问时,该对象被视为垃圾,并且其内存可以被虚拟机重用。

⨳ Live object(存活对象) :只要一个对象能从 GC Roots(如栈帧中的局部变量、静态变量等)顺着引用链找过去,它就是活着的。

⨳ Garbage(垃圾) :如果整个程序运行期间,没有任何一个“活着的对象”能通过引用链条指向它,那它就成了孤岛,被判定为垃圾。

回收内存并不是为了把数据擦除,而是为了释放空间,让 JVM 能够把这块内存重新分配给新创建的对象,从而避免内存泄漏和 OOM。

与 Java 语言不同,C 语言本身不提供内置的自动垃圾回收(Garbage Collection, GC)机制。在标准的 C 语言中,内存管理完全由程序员负责,通过 malloc()、calloc() 等函数动态分配内存,并必须显式地使用 free() 函数来释放不再需要的内存。这极易引发两类严重的底层漏洞:

  • 悬空指针 / 野指针(Dangling Pointers) :内存被提前释放了,但指针还在,程序继续访问这块内存,导致程序崩溃或数据被破坏。
  • 重复释放(Double Free) :对同一块内存调用了两次 free(),直接导致程序崩溃。

Java 的 GC 机制接管了内存的释放权,程序员不需要(也不能)手动释放内存,让程序员专注写业务代码。Java 通过 GC Roots + 引用链条 进行可达性分析。只要对象“不可达”,无论程序员是否忘记处理它,GC 都会自动将其回收。这让“内存泄漏”在 Java 中变成了一个极小概率的事件。

那什么是垃圾收集器呢?

The garbage collector (GC) automatically manages the application's dynamic memory allocation requests.

垃圾回收器(GC)自动管理应用程序的动态内存分配请求。怎么管理呢?可以将 GC 的工作拆解为以下四个核心步骤:

  • 向操作系统申请内存:JVM 本身不直接拥有物理内存,当程序需要创建对象时,GC(或内存分配器)会向底层操作系统申请一块内存空间(通常是大块申请,以减少系统调用开销)。
  • 将内存分配给应用程序:当你的 Java 代码执行 new User() 时,GC 会从刚才向操作系统申请来的内存池中,切分出一小块空间分配给这个新对象。
  • 识别哪些内存仍在使用:GC 会从 GC Roots 出发,顺着引用链条遍历,找出所有“可达”的对象。这些被找到的对象就是“仍在使用”的内存。
  • 回收未使用的内存供重用:那些在遍历中“不可达”的对象,其占用的内存就会被标记为“未使用”。GC 会清理这些垃圾,并将释放出来的内存空间重新放回内存池,供后续 new 出来的新对象使用。

所以 GC 既是内存分配器(负责发内存),又是垃圾回收器(负责收内存),作为垃圾回收器最主要的工作就是“标记”和“清除”。

下面先简单认识一下垃圾收集器发展历史,混个脸熟,再介绍一下标记和清除算法。

  • 1998年12月8日,第二代Java平台的企业版J2EE正式对外发布。为了配合企业级应用落地,1999年4月27日,Java程序的舞台—Java HotSpot Virtual Machine(以下简称HotSpot)正式对外发布,并从这之后发布的JDK1.3版本开始,HotSpot成为Sun JDK的默认虚拟机。

  • 1999年随JDK1.3.1一起发布的是串行方式的Serial GC,它是第一款GC,并且这只是起点。Serial收集器是最基本、历史最悠久的垃圾收集器,它是一个单线程收集器。而之后的ParNew垃圾收集器是Serial收集器的多线程升级版本,除了Serial收集器外,也只有它能与CMS收集器配合工作。

  • 2002年2月26日,J2SE1.4发布。Parallel GC 和 Concurrent Mark Sweep(CMS)GC 跟随JDK1.4.2一起发布,并且Parallel GC在JDK6之后成为HotSpot默认GC。Parallel GC收集器看似与ParNew收集器在功能上类似,但是它们的侧重点不同,Parallel Scavenge收集器关注点是吞吐量(高效率地利用CPU),CMS等垃圾收集器的关注点更多的是用户线程的停顿时间(提高用户体验)​。

  • 2012年,在JDK1.7u4版本中,又有一种优秀的垃圾收集器被正式投入使用,它就是Garbage First(G1)。随着G1 GC的出现,GC从传统的连续堆内存布局设计,逐渐走向不连续内存块,这是通过引入Region概念实现,也就是说,由一堆不连续的Region组成了堆内存。

  • 到2017年JDK9中,G1变成了默认的垃圾收集器,替代了CMS。

  • 2018年3月发布的JDK10中,G1垃圾收集器已经可以并行完整垃圾回收了,G1实现并行性来改善最坏情况下的延迟。之后在JDK12,继续增强G1,自动返回未用堆内存给操作系统。

  • 2018年9月,JDK11发布,在该版本中提到了两个垃圾收集器,一个是Epsilon垃圾收集器,又被称为“No-Op(无操作)​”收集器。另一个是ZGC(The Z Garbage Collector),这是一款可伸缩的低延迟垃圾收集器,此时还是实验性的。

  • 2019年3月,JDK12发布,另一种实验性GC被引入,它就是Shenandoah GC,也是一种低停顿时间的GC。

  • ZGC在2019年9月发布的JDK13中继续得到增强,实现自动返回未用堆内存给操作系统。

  • 在2020年3月发布的JDK14中ZGC扩展了在macOS和Windows平台上的应用。经过了几个版本的迭代,ZGC在JDK15中成为正式特性,并且进行了进一步改进,将线程栈的处理从安全点移到了并发阶段,这样ZGC在扫描根时就不用Stop-The-World了。

垃圾标记算法

引用计数算法

引用计数算法(Reference Counting):为内存中的每一个对象分配一个独立的计数器。

  • 创建与引用:当一个对象被创建时,计数器初始化为 1。每当有其他变量或对象引用它时,计数器加 1。
  • 释放与销毁:当引用被销毁(例如局部变量离开作用域),或者指针指向了其他对象时,计数器减 1。
  • 即时回收:一旦某个对象的计数器降为 0,意味着它已经没有任何途径被访问,系统会立即回收该对象的内存。

算法逻辑非常直观,但有个最大的痛点——循环引用(Circular References)。如果两个或多个对象互相引用,形成一个封闭的环,即使它们已经脱离了程序的主运行流,它们的引用计数也永远无法归 0。

对象 A 引用了 B,B 又引用了 A。此时它们的计数至少为 1。即使外部没有任何变量再指向 A 和 B,它们依然会被认为是“存活”的,导致内存永远无法被回收,最终引发内存泄漏。

正是因为它无法自动检测和解决循环引用,Java 虚拟机(JVM)选择了更严谨的“可达性分析算法”。

可达性分析算法

可达性分析算法(Reachability Analysis) ,又被称为根搜索算法(Tracing) ,它是通过一系列被称为 GC Roots(根节点)  的起点向下遍历引用链条。如果从 GC Roots 出发,顺着引用链能够到达某个对象,就证明该对象是“可达的(Reachable)”,即存活的;反之,如果遍历结束后某个对象依然不可达,它就会被判定为垃圾。

在 JVM 中,以下 4 类对象会被强制认定为 GC Roots:

  • 虚拟机栈中引用的对象:各个线程当前正在执行的方法里定义的局部变量和方法参数。
  • 方法区中类静态属性引用的对象:被 static 关键字修饰的类变量。
  • 方法区中常量引用的对象:字符串常量池或常量表里引用的对象。
  • 本地方法栈中 JNI(Native 方法)引用的对象:Java 调用 C/C++ 等底层代码时,底层代码中持有的 Java 对象引用。

当 GC 触发时,可达性分析算法会从 GC Roots 出发,通过对象之间的相互引用(Reference)一步步向下延伸,最终连接到某个特定对象的完整路径。

JVM 内部通过指针来实现引用,因为在 Java 语言规范中,Java 并没有暴露底层的内存指针(Pointer)给开发者,所以这里称“引用”不称“指针”。

可达性分析只关心对象是否能从 GC Roots 连通。即使 A 和 B 互相引用,只要它们与 GC Roots 断开了联系,依然会被准确识别为垃圾。

可达性分析在回收时需要遍历整个对象图,存在 STW(Stop-The-World)的开销;在实际的 JVM 运行中,GC 线程与用户线程是并发执行的,比如 CMS 和 G1 都是 并发标记的。

问题是如果在并发标记期间,用户线程修改了对象的引用关系,可能会导致“漏标”(存活对象被误判为垃圾)或“多标”(垃圾被误判为存活)。

为了解决这个问题,JVM 引入了三色标记法,将对象分为三种颜色:

  • 白色(White) :尚未被 GC 访问过的对象。在标记结束时,白色对象即为垃圾。
  • 灰色(Gray) :已经被 GC 访问过,但其引用的子对象尚未被完全扫描的对象。
  • 黑色(Black) :已经被 GC 访问过,且其引用的所有子对象也都已被扫描的对象。

通过严格的颜色流转规则(例如:黑色对象不能直接引用白色对象,否则必须将黑色对象重新变灰),配合读写屏障(Read/Write Barrier) 技术,JVM 能够在并发标记的过程中,安全、高效且准确地找出所有存活对象。

举个例子,内存中的对象关系如下:

GC Roots (root变量)
   ↓ 
  [A] → [B] → [C] 
  
  [D] ← 没有任何引用指向它
  1. 初始状态,对象 A 可以从 GC Roots 直接可达,颜色为 灰色,至于对象 B、C、D,因为还没访问到,暂时为白色。

  2. 取出灰色对象 A,扫描它的引用,发现 A 指向 B,那 B 就从白色变成了灰色,当A 的所有引用都处理完了,A 也就从灰色变成了黑色。

  3. 取出灰色对象 B,扫描它的引用,发现 B 指向 C,那 C 就从白色变成了灰色,当 B 的所有引用都处理完了,B 也就从灰色变成了黑色。

  4. 取出灰色对象 C,扫描它的引用,发现 C 没有引用,那 C 直接从 灰色变成 黑色。

  5. 所有灰色对象都处理完了,那剩下的就是从 GC Roots 不可达的对象,对象 D 默认 白色 未被更改,判定为垃圾。

假设标记过程进行到一半,假设在2~3之间,用户线程突然修改了引用关系,A 断开了对 B 的引用,新增了一个指向 D 的引用:

GC Roots → A(黑) → D(白)

B(灰) → C(白)

对象 D 原本是白色,现在被黑色的 A 引用了。但 A 已经是黑色,GC 不会再回头扫描 A。如果 D 没有其他引用了,它就会被错误回收。

为了防止漏标, CMS 会把黑色对象重新变回灰色,然后在重新标记阶段重新扫描它。

垃圾清除算法

标记–清除算法

标记-清除算法(Mark-Sweep)是最基础的垃圾回收算法,也是后续所有 GC 算法的"鼻祖"。它分为两个阶段:标记和清除。

  • 标记阶段:从 GC Roots 出发,沿着引用链遍历所有可达对象,将可达对象标记为"存活",不可达对象保持"未标记"状态。
堆内存: [存活A] [垃圾B] [存活C] [垃圾D] [存活E] [垃圾F] 
        标记    未标记   标记    未标记  标记    未标记
  • 清除阶段:扫描整个堆内存,将所有未标记的对象回收,释放其占用的内存空间。
清除后: [存活A] [空闲] [存活C] [空闲] [存活E] [空闲]
          ↑      ↑      ↑       ↑      ↑     ↑            
        4KB     2KB    4KB     2KB    4KB   2KB

标记–清除算法逻辑清晰,但有个致命的问题:内存碎片化。

如上清除后,存活对象之间的空隙变成了不连续的可用空间,当需要分配一个 6KB 的对象时,虽然有 6KB 的空闲空间,但它们是分散的,无法直接使用。这就需要进行内存整理(Compact) ,代价很大。

而且标记和清除阶段都需要暂停用户线程(Stop-The-World),暂停时间与堆中对象数量成正比。

纯粹的标记-清除算法在现代 JVM 中很少单独使用,但它是很多 GC 算法的基础:

  • CMS:老年代使用标记-清除,因此会产生内存碎片,碎片过多时会触发一次标记-整理。
  • G1:每个 Region 内部使用标记-整理,Region 之间使用复制算法,本质上是对标记-清除的改进。

复制算法

复制算法(Copying)的核心思想是:把堆内存分成两块,每次只用其中一块,垃圾回收时把存活对象复制到另一块,然后一次性清空原来的那块。

  • 初始状态:堆内存分为两块:From 区(使用中) 和 To 区(空闲)
 From 区:[存活A] [垃圾B] [存活C] [垃圾D] [存活E] 
 To 区:  [空闲]  [空闲]  [空闲]  [空闲]  [空闲]  [空闲] [空闲] 
  • 复制阶段:从 GC Roots 出发,找到所有存活对象,逐个复制到 To 区:
From 区:[存活A][垃圾B][存活C][垃圾D][存活E] ← 待复制 
To 区:  [存活A][存活C][存活E] ← 复制完成
  • 清空阶段:一次性清空 From 区,然后 From 和 To 互换角色:
 原 From 区:  [空闲]  [空闲]  [空闲]  [空闲]  [空闲]  [空闲] [空闲] 
 原 To 区:[存活A] [垃圾B] [存活C] [垃圾D] [存活E] 

只需要复制存活对象,不需要逐个扫描和清除垃圾对象。如果大部分对象都是垃圾(这在新生代中是常态),复制的开销非常小。而且存活对象被紧凑地排列在 To 区,分配内存时只需移动指针即可,速度极快。

当然,也有缺点,就是堆内存被分成两块,每次只有一半可用,空间利用率只有 50% 。

实际应用中不会真的把内存对半分,比如 Serial New 和 Parallel New 垃圾收集器,复制算法只对新生代有效。

新生代 Eden(80%) + S0(10%) + S1(10%)

  • 对象优先在 Eden 区分配。
  • GC 时,把 Eden 和其中一个 Survivor(比如 S0)中的存活对象复制到另一个 Survivor(S1)。
  • S0 和 S1 轮流作为 From 和 To。

这样空间利用率从 50% 提升到了 90% ,代价是当存活对象超过 Survivor 区容量时,需要通过分配担保机制将多余对象晋升到老年代,确保新生代 GC 不会因为空间不足而失败。

G1的 Region 之间也使用复制算法,将存活对象从一组 Region 复制到另一组 Region。

标记-整理算法

标记-整理算法(Mark-Compact)可以理解为标记-清除 + 内存整理,它在标记-清除的基础上多了一步:把所有存活对象向一端移动,然后清理边界外的内存。

  • 标记阶段:从 GC Roots 出发,沿着引用链遍历所有可达对象,将可达对象标记为"存活"。
整理前:[存活A][垃圾B][存活C][垃圾D][存活E][垃圾F][存活G]
  • 整理阶段:把所有存活对象向内存的一端紧凑排列,然后清理边界以外的空间。
整理后:[存活A][存活C][存活E][存活G][空闲空闲空闲空闲]

和复制算法相比,标记-整理算法空间利用率更高,但这也是拿时间换空间, 虽然复制算法确实也需要移动对象、更新引用,但复制算法的目标地址不需要计算,To 区是全新的空白区域,从起始位置开始,挨个往里放就行,一个指针不断往前移,而且复制算法的应用场景是新生代,新生代里的对象朝生夕灭,通常 98% 以上都是垃圾,存活对象极少,复制的对象极少。

Serial 和 Paralle的老年代就是使用标记-整理,老年代的对象存活时间长,因为需要复制存活对象到新的位置,对象越多,移动开销越大。而且移动后必须更新所有指向这些对象的引用,需要扫描整个老年代。 所以整理阶段也必须 STW,且耗时与存活对象数量成正比。

这也是为什么 CMS 选择放弃标记-整理,改用标记-清除——虽然会产生碎片,但不用移动对象、不用更新引用,停顿时间大大缩短。代价就是碎片积累到一定程度后,不得不退化为一次完整的标记-整理,那一次 STW 会非常长。

对于 G1 来说,因为堆被切成很多小 Region(几十 MB),虽然每个 Region 内部也使用标记-整理,但整理一个 Region 的开销和整理整个 4GB 老年代完全不在一个量级,单次停顿时间可控,用户几乎无感。

垃圾收集相关概念

finalization()

Java 提供了一套对象终止(finalization)机制,允许开发者在对象被垃圾回收之前执行自定义的清理逻辑。核心就是 java.lang.Object 中定义的 finalize() 方法:

protected void finalize() throws Throwable { }

一个对象从"不可达"到真正被回收,需要经历两次标记过程:

  1. 第一次标记:当 GC 通过可达性分析发现某个对象到 GC Roots 没有引用链时,会进行第一次标记,将其判定为"可回收"。

GC 会判断该对象是否有必要执行 finalize():

  • 不需要执行:对象没有重写 finalize(),或者 finalize() 已经被 JVM 调用过 → 对象直接进入"不可触及"状态,等待回收。
  • 需要执行:对象重写了 finalize() 且尚未执行过,对象被放入 F-Queue 队列中。JVM 会创建一个低优先级的守护线程 FinalizerThread,它不断从 F-Queue 中取出对象,并调用其 finalize() 方法。
  1. 第二次标记:finalize() 执行完毕后,GC 会再次判断该对象是否可达:
  • 如果对象在 finalize() 中与某个 GC Roots 建立了连接(比如 this 被赋值给静态变量),对象复活,移出"即将回收"集合。
  • 如果对象仍然不可达,进入"不可触及"状态,真正被回收。

由于 finalize() 的存在,虚拟机中的对象可能处于三种状态:

  • 可触及:从 GC Roots 可达,正常存活。
  • 可复活:对象不可达,但 finalize() 尚未执行,还有复活的机会。
  • 不可触及:finalize() 已执行且未复活,永远不可能再复活,等待回收。

它的设计初衷是提供一种类似 C++ 析构函数的资源释放机制,但实际使用中存在很多缺陷,Java 9 中已被标记为废弃。推荐使用以下替代方案:

  • AutoCloseable + try-with-resources:显式释放资源,最推荐的方案,适用于文件流、数据库连接等。
  • java.lang.ref.Cleaner(Java 9+):基于 PhantomReference 实现,可以注册清理任务,由 JVM 后台线程执行。
  • PhantomReference:虚引用,可以配合引用队列在对象被回收前得到通知。

System.gc()

System.gc() :它只是"建议"JVM 进行垃圾回收,不保证一定会执行。

// System.java 
public static void gc() { 
    Runtime.getRuntime().gc(); 
}

System.gc() 只是调用了 Runtime.getRuntime().gc(),两者完全等价。

调用 System.gc() 后,JVM 的行为取决于具体实现:

  • HotSpot JVM(默认) :收到建议后,通常会触发一次 Full GC。
  • 其他 JVM 实现:可能完全忽略这个建议。

即使 HotSpot 通常会响应,也不保证立即执行,JVM 可能延迟处理,甚至在某些情况下直接忽略。

通过 JVM 参数 -XX:+DisableExplicitGC 可以完全禁用 System.gc(),调用后什么都不发生。这个参数在生产环境中很常用,因为有些第三方库(如 NIO 的 DirectByteBuffer)内部会调用 System.gc(),可能导致不可预期的 Full GC。

内存溢出

内存溢出:程序在运行时申请的内存超出了 JVM 对应区域的上限,直接抛出 OutOfMemoryError,程序崩溃。

错误信息溢出区域常见原因
Java heap space堆对象太多,堆不够用
Metaspace元空间加载的类太多(如动态代理疯狂生成类)
unable to create new native thread线程栈线程数太多,系统资源耗尽
GC overhead limit exceeded堆GC 回收太频繁但回收太少,JVM 放弃了
Direct buffer memory堆外内存NIO 的 DirectByteBuffer 用多了
StackOverflowError虚拟机栈递归调用过深

内存泄漏

内存泄漏:程序中已不再使用的对象,因仍被强引用持有,导致 GC 无法回收。这些"僵尸对象"持续占用堆内存,像细水长流般缓慢蚕食可用空间。

根据可达性算法,就知道除了虚拟机栈中正在被方法调用使用的对象,就是方法区静态属性引用的对象,就是方法区中常量引用的对象了。

对于方法区静态属性引用的对象,单个对象,如果单例,可能不大,但如果拿静态集合当缓存,就有可能引起内存泄漏了。

static Map<String, User> userCache = new HashMap<>(); 
public void process() { 
    User user = loadUser(); 
    userCache.put(user.getId(), user); // 不断 put,从不 remove → 泄漏 
}

对于方法区中常量引用的对象,这个问题一般也不大,最常见的就是字符串常量池(String Pool):

List<String> list = new ArrayList<>(); 
for (int i = 0; i < 100000; i++) { 
    String s = ("key" + i).intern(); // 每次生成不同的字符串,放入常量池 
    list.add(s); 
}

除此之外,对于栈里面引用的对象,方法调用结束,栈帧出栈,局部变量表中的引用就消失了,对象不再被 GC Root 可达,自然可以被 GC 回收。

最后要小心的是未关闭的资源:

public void read() { 
    InputStream is = new FileInputStream("data.txt"); // 忘了 is.close() 
} 

虽然方法结束,InputStream 对象本身可以被 GC 回收,但 InputStream 对象内部持有一个文件描述符(fd) ,这是操作系统级别的资源,不在 JVM 堆中,如果不调用 close(),文件描述符就一直被占用,这就导致操作系统级别的资源泄漏了。

总之,泄漏是溢出的常见原因,但不是唯一原因。溢出也可能是代码本身就没问题,纯粹是内存配太小或一次性加载了过大的数据。

Stop-The-World

STW 就是:GC 工作时,暂停所有应用线程,整个世界停下来(Stop The World ),等 GC 干完活再恢复。

GC 阶段是否需要 STW说明
初始标记STW标记 GC Roots 直接引用的对象,速度快
并发标记不停从 GC Roots 出发遍历整个对象图,与应用线程并发执行
重新标记STW修正并发标记期间因程序运行导致的标记变动(增量更新)
并发清除不停清理死亡对象,与应用线程并发执行
复制/整理(Compact)STW移动存活对象、整理内存碎片,必须暂停

对于标记阶段,初始标记一定会 STW,无论是复制算法还是标记-清除、标记-清理算法,对于清除阶段,除了标记-清除算法不STW,复制和标记-整理算法都会 STW。

安全点、安全区域

GC虽然 STW 暂停所有应用线程,但不能在任意位置暂停。假设线程正在执行:

user.setName("Tom");

这行代码底层可能拆成多步:

  1. 从堆中取出 name 字段的地址
  2. 创建 "Tom" 的 String 对象
  3. 把引用写入 name 字段

如果在第 1 步和第 3 步之间强行暂停,寄存器里可能存着还没写回栈帧的对象引用,GC 扫描栈帧时看不到这个引用,可能把正在使用的对象当垃圾回收掉。

所以必须规定:线程只能在特定的、状态一致的位置暂停。

这个特定位置就是安全点(Safepoint),在这些位置上:

  • 栈帧完整,局部变量表状态确定
  • 寄存器中的对象引用已经写回栈帧
  • 对象引用关系不会处于"修改了一半"的状态

安全点通常设在方法调用处(调用前后栈帧状态清晰)、循环回边(循环末尾,避免死循环永远到不了安全点)、异常抛出位置、或 方法返回前...

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

  • 抢先式中断:首先中断所有线程,如果还有线程不在安全点,就恢复线程,让线程跑到安全点。
  • 主动式中断:设置一个中断标志,各个线程运行到Safe Point的时候主动轮询这个标志,如果中断标志为真,则将自己进行中断挂起。

抢先式中断已经没有人用了,现在 HotSpot 用的是主动式中断。

但有一个问题,安全点是假设线程一直在执行代码,总能跑到下一个安全点。但有些线程根本不动,如果Thread.sleep()、比如 I/O 阻塞,这些线程无法响应轮询,JVM 不可能无限等待,于是引入安全区域。

安全区域是指在一段代码片段中,对象的引用关系不会发生变化,在这个区域中的任何位置开始 GC 都是安全的。可以把安全区域理解为被放大的安全点:安全点是一个"位置",安全区域是一段"区间"。

四种引用

引用就是指向对象的"把手" ,JVM 内部通过指针来实现引用(32 位系统 4 字节,64 位系统 4 或 8 字节),你通过它来访问对象,但引用本身不是对象。

Object obj = new Object(); 
//      ↑          ↑ 
//     引用       对象

Java 引用本质上是被阉割的指针——保留了"指向对象"的能力,但不想 C/C++ 一样可以 "直接操作内存"。

Java 中对象可以被四种不同强度的引用指向,GC 对它们的回收策略不同:

⨳ 强引用(Strong Reference):最传统的“引用”的定义,就是在程序代码之中普遍存在的引用赋值。

Object obj = new Object();

只要强引用在,GC 永远不会回收,哪怕内存不够也宁可抛 OutOfMemoryError。

⨳ 软引用(SoftReference):使用 SoftReference 包装的引用。

SoftReference<User> softRef = new SoftReference<>(new User());

在垃圾收集器收集完垃圾对象的内存之后,如果内存仍然吃紧,此时垃圾收集器会把软引用的对象列入回收范围之中进行第二次回收,适合做缓存。

⨳ 弱引用(WeakReference):使用 WeakReference 包装的引用。

WeakReference<User> weakRef = new WeakReference<>(new User());

被弱引用关联的对象只能生存到下一次垃圾收集之前。只要发生 GC,不管内存够不够,都会被回收。适合做不干扰对象生命周期的关联。

⨳ 虚引用(PhantomReference):使用 PhantomReference 包装的引用。

PhantomReference<User> phantomRef = new PhantomReference<>(new User(), queue);

一个对象是否有虚引用的存在,完全不会对其生存时间构成影响,可以说是最弱的引用了,随时可能被回收,而且 get() 永远返回 null,根本拿不到对象。唯一作用是配合 ReferenceQueue 追踪对象被回收的时机。

强引用软引用弱引用虚引用
GC 回收时机永不回收内存不足时下次 GC 时随时,等同无引用
get() 返回值对象本身对象或 null对象或 null永远 null
必须配合队列否否否是(ReferenceQueue)
典型用途日常开发缓存WeakHashMap、ThreadLocal跟踪回收、堆外内存释放

垃圾收集器

在计算机领域 有一个 CAP 理论,也叫分布式系统不可能三角,它指出,在一个分布式系统中,C (Consistency) 一致性、A (Availability) 可用性、P (Partition Tolerance) 分区容错性 这三个核心指标最多只能同时满足两个,不可能三者兼得:

对于 JVM 来说,也有个不可能三角:

  • 低延迟(Low Latency) :GC 暂停时间(STW)极短,应用响应极快。
  • 高吞吐量(High Throughput) :CPU 尽可能多地用于运行业务代码,而不是花在 GC 上。
  • 低内存占用(Low Memory Footprint) :堆内存占用小,不浪费系统资源。

Java 垃圾收集器(Garbage Collector, GC)的发展史,本质上是一部围绕吞吐量 、暂停时间 和内存占用这三大核心指标不断权衡与突破的工程演进史。

第一代:串行与并行时代 Serial GC、Parallel GC

90年代到2000年初,CPU 大多都是单核的,而且内存(RAM)极其昂贵且容量极小。

Serial GC 的核心工作模式可以概括为“一个线程、全程独占、全局暂停”。

Serial GC 针对不同的内存区域使用的算法:

  • 新生代(Serial GC):采用复制算法。当 Eden 区满时触发 Minor GC,将存活对象复制到 Survivor 区。这种方式速度快、内存连续,但会浪费一半的 Survivor 空间。
  • 老年代(Serial Old GC):采用标记-整理(Mark-Compact)算法。先标记出所有存活对象,然后将它们向内存一端紧凑移动,最后清理边界外的空间。这有效避免了内存碎片问题。

因为 Serial 在进行垃圾回收时,它只启用一个 CPU 或一条 GC 线程来完成所有工作(包括对象标记、内存复制等),所以它的 STW 时间最长。

通过 JVM 参数 -XX:+UseSerialGC 可以强制新生代和老年代都使用串行收集器。

JDK 1.3 默认收集器就是 Serial GC,在 JDK 8 及以前的版本中,运行在 Client 模式下的 JVM 默认使用 Serial GC。它的代码轻量,逻辑简单,非常适合内存受限(如几十 MB 到一两百 MB)的嵌入式设备、小型工具类程序。

2000年初,随着多核 CPU 的普及,Parallel GC(并行垃圾收集器)应运而生。

Parallel GC  内存回收算法 和 Serial GC  一样,新生代使用复制算法,老年代采用 标记-整理算法,但Parallel GC 是支撑多线程并行的。

Parallel GC 利用多核 CPU 的优势,启动多个 GC 线程同时执行垃圾回收工作,所以它的STW 会比 Parallel更快 。

而且 Parallel GC 还有个自适应调节策略,开启该策略后,JVM 会根据运行时的状态,自动调整新生代大小、Eden 与 Survivor 的比例、以及对象晋升老年代的年龄等参数。开发者只需告诉 JVM 期望的吞吐量或最大停顿时间,JVM 就会自己在堆大小、吞吐量和停顿时间之间寻找最佳平衡点,极大降低了手动调优的难度。

-XX:+UseParallelGC:启用新生代 Parallel Scavenge 收集器。

-XX:+UseParallelOldGC:启用老年代 Parallel Old 收集器(JDK 8 默认开启)。

-XX:ParallelGCThreads=N:设置 GC 线程数。默认值通常等于 CPU 核心数(当 CPU > 8 时,公式为 3 + 5*CPU_Count/8)。

-XX:MaxGCPauseMillis:设置目标最大停顿时间(毫秒)。JVM 会尽力控制 STW 时间,但需注意,设置过小会导致 GC 频繁,反而降低吞吐量。

-XX:GCTimeRatio:设置吞吐量目标(取值 0-100,默认 99)。表示 GC 时间不超过总时间的 1%。

在 JDK 6 ~ 8 版本中,Parallel GC 是 HotSpot 虚拟机在 Server 模式下的默认垃圾收集器。

Parallel GC 虽然提升了 STW 时间,但它依然没有解决“用户线程被完全暂停”的问题。随着互联网应用对响应速度的要求越来越高,JVM 迫切需要一款能让 GC 线程和用户线程“同时工作”的收集器。

第二代:并发时代 CMS GC

如果说 Parallel GC 是为了追求极致的吞吐量而容忍了长时间的暂停,那么 CMS 则是 JVM 历史上第一款真正以获取最短停顿时间(低延迟) 为目标的收集器。它的出现,开启了 Java 垃圾回收的“并发时代”。

CMS GC(Concurrent Mark Sweep,并发标记清除)收集器:通过并发标记和并发清除,让 GC 线程与用户线程交替运行。

CMS 最核心的突破在于将垃圾回收过程拆分,让 GC 线程与用户线程尽可能并发执行。它的工作流程分为四个主要阶段:

  • 初始标记(Initial-Mark) :短暂暂停用户线程(STW),仅标记 GC Roots 能直接关联到的对象。速度极快。
  • 并发标记(Concurrent-Mark) :从 GC Roots 开始遍历整个对象图。此阶段耗时最长,但不需要暂停用户线程,GC 线程与应用线程并发运行。
  • 重新标记(Remark) :短暂暂停用户线程(STW),修正并发标记期间因用户线程继续运行而导致标记变动的对象。
  • 并发清除(Concurrent-Sweep) :清理死亡对象并释放内存。同样不需要暂停用户线程,与用户线程并发执行。

CMS 诞生于 JDK 1.5,但官方从未把CMS作为默认推荐,就是因为CMS的缺点也挺明显的。

  • 内存碎片问题:CMS 采用的是“标记-清除”算法,不是标记-整理(Mark-Compact),回收后会产生大量不连续的内存碎片。当需要分配大对象时,可能因为找不到连续空间而不得不提前触发 Full GC。
  • 无法处理浮动垃圾(Floating Garbage) :在并发标记和清除阶段,用户线程还在运行并不断产生新垃圾。这些新垃圾无法在本次 GC 中被清理,只能留到下一次,这要求 CMS 必须预留一部分老年代空间来应对。
  • 并发模式失败(Concurrent Mode Failure) :如果在 CMS 运行期间,老年代预留的空间不足以容纳新产生的对象或晋升的对象,CMS 就会宣告失败。此时,JVM 会启动后备预案:临时启用单线程的 Serial Old 收集器重新进行老年代回收,这会导致长时间的 STW。

所以说对于是否使用 CMS ,需要开发者自己评估风险后主动启用, 最终随着 G1 收集器的逐渐完善,CMS 在 JDK 9 被废弃,并在 JDK 14 被正式移除。

第三代:分区与可预测停顿时代 G1 GC

如果说 CMS 是“并发时代”的开拓者,那么 G1 就是“分区与可预测停顿时代”的集大成者。它被 Oracle 官方称为“全功能的垃圾收集器”,旨在为配备多核 CPU 及大容量内存的机器提供高吞吐量与低延迟的完美平衡。

G1(Garbage-First,垃圾优先)彻底抛弃了传统收集器将内存物理隔离为“年轻代”和“老年代”的做法。它将整个堆内存划分为多个大小相等的独立区域(Region,通常在 1MB 到 32MB 之间)。

  • 逻辑分代:虽然物理上不连续,但每个 Region 在逻辑上依然扮演 Eden、Survivor 或 Old(老年代)的角色,并且这些角色可以随着 GC 动态变化。
  • 巨型对象(Humongous) :如果分配的对象大小超过 Region 的一半,G1 会使用连续的多个 Region 来存储,并将其标记为巨型 Region。

为什么叫 Garbage-First(垃圾优先)?

G1 在后台维护着一个优先列表,持续追踪各个 Region 内垃圾堆积的价值(即回收能获得的空间大小及所需时间)。在进行垃圾回收时,G1 会优先挑选那些“垃圾最多、回收收益最大”的 Region 进行回收,避免在整个Java堆中进行全区域的垃圾收集。这种“好钢用在刀刃上”的增量式回收策略,正是其名字的由来。

相对于 CMS GC ,G1 GC 有三个核心优势:

  • 减少内存碎片:CMS 采用“标记-清除”算法,容易产生内存碎片。而 G1 在 Region 之间采用“复制算法”,整体上等效于“标记-压缩(Mark-Compact)算法”。这使得 G1 能够完美消除内存碎片,保证长时间运行后依然有连续空间分配大对象。

  • 可预测的停顿时间模型:G1 引入了停顿预测模型。开发者可以通过参数(如 -XX:MaxGCPauseMillis=200)设定一个期望的最大停顿时间。G1 会根据历史数据,计算出在满足该时间目标的前提下,本次最多能回收多少个 Region,从而实现高度可预测的低延迟。

  • 并发与并行结合:G1 既有与用户线程并发执行的阶段(如并发标记),也有多线程并行执行且暂停用户线程的阶段(STW,如初始标记、最终标记),充分利用了多核 CPU 的性能。

G1 引入了停顿预测模型,能够在不牺牲大量吞吐量的前提下,将停顿时间控制在用户设定的目标内(如 200ms)。自 JDK 9 起,G1 成为 JVM 的默认垃圾收集器,是目前中大型应用的主流选择。

第四代:超低延迟时代(确定性工程)

面对 TB 级超大堆内存和实时风控、高频交易等对延迟“零容忍”的场景,G1 的百毫秒级停顿仍显不足。

ZGC 是 HotSpot 中追求极致低延迟的垃圾收集器,目标是亚毫秒级停顿,且停顿时间不随堆大小增长而增长。

ZGC 也是基于 Region 的,ZGC 的 Region 称为 ZPage,与 G1 固定大小的 Region 不同,ZPage 支持动态创建和销毁,,并且大小不固定,分为三类:

类型容量存放对象特点
小型 ZPage固定 2MB< 256KB 的小对象容量固定
中型 ZPage固定 32MB≥ 256KB 且 < 4MB 的对象容量固定
大型 ZPageN × 2MB(动态)≥ 4MB 的大对象每个只放一个大对象,不会被重分配

G1 的 Region 大小固定,大对象只能跨多个 Region 存储(Humongous 对象),回收代价高。ZGC 直接根据对象大小分配不同规格的 ZPage,大型 ZPage 中的对象不会被并发重分配(复制) ,因为复制大对象的代价太高。GC 只回收小型和中型 ZPage 中的存活对象。

除此之外,ZGC 还有三大核心技术:

  • 染色指针(Colored Pointers):64 位指针中,只用 42 位寻址(4TB),剩余高位用来存储 GC 状态信息。每个指针自带"身份标签",应用线程一看指针就知道对象处于什么状态,无需访问对象头,减少缓存未命中。
64 位指针布局: 
┌──────────────────┬─────────────────────────────┐ 
│ 高位(元数据位)  │ 低 42 位(真实地址)          │ 
│ Marked0          │                             │ 
│ Marked1          │                             │ 
│ Remapped         │                             │ 
│ Finalizable      │                             │ 
└──────────────────┴─────────────────────────────┘
  • 读屏障(Load Barrier):每次应用线程从堆中加载对象引用时,JVM 插入一段轻量检查逻辑:如果在 标记阶段,发现未标记的引用,协助 GC 标记(解决漏标);如果在 转移阶段,发现对象被搬走,立即修正指针指向新地址,这样就保证应用线程永远拿到正确的地址。

  • 多重映射(Multi-Mapping):利用操作系统的虚拟内存机制,将同一块物理内存映射到多个虚拟地址视图:

物理内存(实际对象数据) 
    ↑ 
    ├── Marked0 视图(虚拟地址 A) 
    ├── Marked1 视图(虚拟地址 B) 
    └── Remapped 视图(虚拟地址 C)

三个视图同时指向同一块物理数据。当 GC 并发移动对象时,旧地址(Marked 视图)仍然有效,应用线程访问不会崩溃,新地址(Remapped 视图)是对象的最终位置,读屏障逐步将引用从旧视图切换到新视图,全部切换完成后,解除旧视图映射,释放内存。

GC 线程工作流程:

  1. 选择需要回收的 Region
  2. 将存活对象复制到新 Region(新物理地址)
  3. 新地址映射到 Remapped 视图
  4. 在转发表中记录:旧地址 → 新地址

应用线程(同时运行):

  1. 访问对象时触发读屏障;
  2. 读屏障检查指针的染色位;如果 Remapped 位 == 1 就表示对象已搬走,查转发表,拿到新地址,进而更新本地指针完成自愈;如果Remapped 位 == 0 表示 对继续执行,完全无感知象未搬走,直接返回。
  3. 应用线程继续执行,完全无感知

染色指针让每个引用自带状态,读屏障在每次访问时拦截修正,多重映射保证搬移期间旧地址仍然可用——三者配合,实现了复制算法不需要 STW 的壮举。

Shenandoah 是由 Red Hat 主导开发的,它和 ZGC 是"同一目标、两条路径",都用并发复制实现了极低停顿,不同的是 ZGC 选择在指针高位染色来标记对象状态 ,Shenandoah 选择在每个对象头上加一个转发指针来追踪对象移动。

每个对象的对象头前面增加一个引用字段(转发指针),正常状态(非并发移动),转发指针指向自身,并发移动后,转发指针指向新对象。

当应用线程访问旧对象时,先检查转发指针,如果指向自己表示对象未被移动,直接访问,如果指向新地址表示对象已被搬走,自动转发到新对象。

总结

支持,垃圾收集介绍完毕,一句话记住每个收集器:

  • Serial:单线程,最简单,最古老,适合小堆、容器、简单应用
  • Parallel:多线程版 Serial,吞吐量之王,适合普通 Web 应用
  • CMS:第一个并发收集器,碎片致命,已被移除
  • G1:内存 Region 化,可预测停顿,适合中大型应用
  • ZGC:染色指针,亚毫秒停顿,堆可达 TB 级,适合超大堆 + 超低延迟
  • Shenandoah:同 ZGC,只不过是 Red Hat 出品的
收集器算法堆布局分代停顿特点状态
Serial复制 + 标记-整理连续有单线程 STW可用
Parallel复制 + 标记-整理连续有多线程 STWJDK 6~8 默认
CMS标记-清除连续有低延迟,但有碎片JDK 14 移除
G1标记-整理(局部复制)Region有可预测停顿JDK 9+ 默认
ZGC并发复制Region(ZPage)JDK 21+ 有亚毫秒级生产可用
Shenandoah并发复制RegionJDK 21+ 有1~10ms生产可用