「JVM」Java 垃圾回收机制全解析:回收算法、分代流转与 G1 收集器底层拆解

在 Java 开发中,JVM 的垃圾回收(Garbage Collection, GC)机制是我们避不开的核心内功。理解 GC 不仅能帮我们在面试中游刃有余,更是日常排查 OOM 问题、进行线上服务调优的基石。

本文将带你系统地梳理 JVM 垃圾回收的核心脉络:如何判断对象已死?垃圾该怎么回收?堆内存如何分代?以及主流的垃圾回收器都有哪些,最后奉上一些宏观的调优思路。


1. 灵魂拷问:如何判断对象可以被回收?

垃圾回收的第一步,是找出内存中哪些对象已经"失去价值"。

引用计数法(已淘汰)

这是一种最直观的算法:给对象添加一个引用计数器,有其他地方引用它时计数加一,引用失效时减一。计数为 0 时即可回收。

致命缺陷 :无法解决循环引用的问题(A 引用 B,B 引用 A,两者计数均为 1,导致永远无法被回收),因此现代 JVM 均不采用此方法。

可达性分析算法(主流)

JVM 采用"可达性分析"(Reachability Analysis)来判定对象存活。

基本思路是通过一系列称为 GC Roots 的根对象作为起始点,沿着引用链向下搜索。如果一个对象到 GC Roots 没有任何引用链相连,说明该对象不可达,可以被回收。

可以作为 GC Roots 的对象包括:

  • 虚拟机栈(栈帧中的局部变量表)中引用的对象 -- 当前方法里直接 new 出来的,且还在用的对象。
  • 方法区中类静态属性引用的对象 -- 带有 static 关键字的引用对象。
  • 方法区中常量引用的对象 -- StringTable 里的引用。
  • 本地方法栈中 JNI(Native 方法)引用的对象 -- Java 调用 C/C++ 底层库产生的对象。
  • synchronized 锁持有的对象 -- 正在被锁使用的对象。
追问:不可达的对象,就一定死定了吗?(缓刑期)

当一个对象经过可达性分析,发现没有与 GC Roots 相连,它并不是立刻死亡,而是进入了**"缓刑阶段"**。

真正宣告一个对象死亡,至少要经历两次标记过程

  1. 第一次标记 :对象不可达,被盖上"待回收"的戳。JVM 会判断这个对象有没有重写 finalize() 方法。
    • 如果没有重写,或者已经被调用过一次了,那就直接判死刑。
    • 如果重写了且没被调用过,对象会被放进一个叫 F-Queue 的队列里。
  1. 第二次标记(自救机会) :JVM 会由一个低优先级的 Finalizer 线程去执行队列里对象的 finalize() 方法。如果在这个方法里,对象将自己重新赋值给了某个正在存活的对象(比如把 this 赋值给某个 static 变量),它就复活 了!如果在 finalize() 里什么也没干,那就彻底被回收。

最佳实践 :永远不要在代码里重写 finalize() 来拯救对象或释放资源。它的运行极其不可靠,且严重拖慢 GC 性能。JDK 9 之后这个方法已经不推荐使用了,释放资源请老老实实用 try-with-resources


2. Java 中的四种引用类型

为了让程序员能更灵活地控制对象的生命周期,Java 提供了四种(严格说是五种)不同强度的引用类型:

|-------------------|-----------------------------|--------------------------------|
| 引用类型 | 回收时机 | 主要应用场景 |
| 强引用 (Strong) | 永远不回收,宁可抛出 OOM 异常。 | 日常开发中最常见的 new 对象。 |
| 软引用 (Soft) | 内存不足时回收(配合 ReferenceQueue)。 | 缓存(如图片缓存、网页缓存)。 |
| 弱引用 (Weak) | 只要发生垃圾回收,无论内存是否充足均回收。 | ThreadLocalMap 中的 Key。 |
| 虚引用 (Phantom) | 无法通过它访问对象,仅在对象被回收时收到一个系统通知。 | 配合引用队列管理直接内存(Direct Memory)释放。 |

补充 :还有一种隐藏在内部使用的终结器引用 (FinalReference) ,主要用于配合引用队列调用对象的 finalize() 方法,日常编码无需关注。


3. 垃圾去哪儿:三大经典回收算法

确定了哪些是垃圾后,JVM 又是如何清理它们的呢?

标记-清除算法 (Mark-Sweep)
  • 过程:先通过可达性分析标记活着的对象,没打标记的垃圾直接清理掉。
  • 优点:执行速度快。
  • 缺点 :容易产生大量内存碎片 (因为所谓的清楚就是把垃圾内存的起始地址和大小,记录到一个 空闲列表(Free List) 里面,下一次需要分配内存只需要覆盖即可)。如果之后需要分配大对象,可能会因为找不到连续的内存空间而提前触发另一次 GC。
标记-整理算法 (Mark-Compact)
  • 过程:标记过程与"标记-清除"一样,但是标记完成之后让所有存活的对象全部"滑动"挨近到内存的一端,然后把边界以外的内存一刀切全部清空。
  • 优点:解决了内存碎片问题(适合老年代:老年代对象存活率高,需要移动的少,且老年代空间大,不能容忍严重的内存碎片)。
  • 缺点 :涉及到对象的移动(不仅是对象的物理地址,还有 所有指向这些对象的引用变量),性能开销较大,速度慢。
复制算法 (Copying):空间换时间
  • 过程:把内存劈成两半,平时只用一半。GC 的时候,把活着的对象整整齐齐的复制到另外一半去,然后把原来的一半直接清空。
  • 优点:实现简单,运行高效,无内存碎片。
  • 缺点:内存空间利用率低,硬生生浪费了一半的内存。
  • 深挖:浪费一半内存,JVM 怎么可能接受这种败家行为?
    • 事实上:新生代里 98%的对象都是"朝生夕死" (比如你在 Controller 里临时的 new DTO、VO 对象,方法一结束就没用了)。
    • 所以考虑到绝大部分对象活不过第一轮, JVM 就把新生代划分了三个区域:一个大的Eden(伊甸园)、和两个小的Survivor(幸存区),默认比例是 8:1:1。
    • 每次只使用 Eden 和其中一个 Survivor (From),GC 的时候,把 90% 空间里存活下来的(大概不到 10%)的对象,复制到另一个空的 Survivor (To)里,**这样一来,内存利用率从 50% 飙升到 90%!**既享受了复制算法"无碎片、速度快"的优点,又完美规避了浪费空间的缺点。

4. 堆内存的"新陈代谢":分代垃圾回收

由于大多数对象都是"朝生夕死"的,JVM 并没有采用单一的算法管理所有内存,而是采用了分代收集的思想。

为什么一定要分代?

Q:G1 回收器都在弱化物理分代了,为什么传统的 JVM 还要死磕新生代和老年代的划分?不分代难道不能回收吗?

A:不分代当然能回收,但极其低效

  • 设想在开发"黑马点评"这样的高并发系统时,用户疯狂刷新商铺列表或抢购秒杀券。每一次 HTTP 请求打进 Controller,都会瞬间 new 出海量的 DTO、VO、以及各种临时组装的 ListMap
  • 请求一旦响应完毕(可能就几十毫秒),这些对象瞬间变成垃圾,这就是典型的**"朝生夕死"**。
  • 如果不分代,每次 GC 都要把整个堆(比如 8GB)全部扫描一遍,去找这几十毫秒产生的垃圾,这简直是大炮打蚊子。分代的本质,是为了把那 98% 马上要死的对象圈在一个极小的范围(新生代)内集中高效处理。
堆内存结构

堆内存通常被划分为 新生代 (Young Generation)老年代 (Old Generation) 。新生代又细分为 伊甸园 (Eden)两个幸存区 (Survivor From / Survivor To) 。这里必须要有两个幸存区是为了 彻底消灭内存碎片

经典的 Minor GC(新生代 GC)流程:
  1. 新创建的对象默认分配在 Eden 区。
  2. 当 Eden 区满时,触发 Minor GC
  3. Eden 区和 From 区存活的对象会被复制到 To 区,存活对象的年龄 +1。
  4. 清空 Eden 区和 From 区,然后交换 From 和 To 的逻辑指针(保证 To 区总是空的)。
  5. 当对象的年龄达到晋升阈值(默认最大 15),或者碰到无法放入新生代的"大对象"时,对象会直接晋升到老年代

补充晋升规则: 前面提到默认阈值(15次)和大对象两个晋升规则,但其实还有一个 动态年龄判断。

动态年龄判断: 如果 Survivor 空间里,某个年龄(比如年龄 5)的所有对象总大小,超过了 Survivor 空间的一半,那么年龄大于等于 5 的对象,不管有没有到 15 岁,直接全部提前打包装进老年代。这叫动态年龄判断机制。

终极梦魇:Full GC 与 Stop The World

老年代满了,或者新生代想要晋升的对象老年代实在装不下了,就会触发 Full GC

  • 发生 Full GC 时,JVM 要清理整个老年代(可能好几个 GB),使用的是标记-整理算法或标记-清除算法。
  • 在整理内存时,存活对象的物理内存地址发生了大范围的移动
  • 试想,如果你在抢购秒杀券的业务代码正好执行到一半,拿着一个对象的引用准备扣减库存,结果下一秒 JVM 底层把这个对象的物理地址挪到了另一边。如果不暂停你的业务线程,你一头扎进旧地址,读到的就是乱码,甚至直接导致 JVM C++ 底层内核崩溃。
  • 所以,必须 STW! 让全世界(所有用户线程)都停下来,等垃圾回收线程把对象挪完,把引用地址都更新好,再让业务线程继续跑。一次几十秒的 Full GC 停顿,对于互联网 C 端产品来说,这将是灾难级的(所有用户的请求全部超时)。

5. 垃圾回收器大盘点

随着 JVM 的演进,垃圾回收器也在不断迭代,针对不同的业务场景提供了多种选择:

1. 串行回收器 (Serial/Serial Old):时代的眼泪
  • 大白话 :整个垃圾回收过程只有一个线程在干活,而且干活的时候,所有用户线程必须全部排队等着(全程 Stop The World)。
  • 面试定位:基本不问。除非是在非常古老的单核机器,或者分配内存极小(几十兆)的客户端应用中才会用。在动辄几核几十核的后端服务器上,用它等于自寻死路。
2. 吞吐量优先 (Parallel Scavenge/Parallel Old):JDK 8 的"沉默主力"
  • 大白话 :多线程版本的 Serial。它不在乎单次停顿时间有多长,它只在乎**"吞吐量"**。
  • 什么是吞吐量?
    • 吞吐量 = 运行用户代码时间 / (运行用户代码时间 + 垃圾收集时间)。
    • 比如系统跑了 100 分钟,其中 1 分钟在做 GC,吞吐量就是 99%。
  • 真实业务场景选型
    • 如果你在写一个后台报表跑批系统(半夜运行,没有用户等着看界面),选它!因为它能最大化利用 CPU 算力把大量数据算完,中途卡顿几秒根本无所谓。
    • 但如果在外卖抢单、商品秒杀接口用它,一旦发生 Full GC 卡顿了几秒,用户的请求直接全部报 504 Timeout,后果不堪设想。
3. CMS (Concurrent Mark Sweep):低延迟的里程碑,也是"背锅侠"(🔥 绝对高频)

为了解决高并发下 STW 时间过长的问题,CMS 诞生了。它是第一款真正意义上的并发收集器。

  • 问:CMS 是怎么把停顿时间降下来的?(著名的四步走)
    1. 初始标记 (STW):只标记跟 GC Roots 直接相连的老祖宗对象。速度极快,停顿时间极短。
    2. 并发标记 (无 STW)核心精髓! 垃圾回收线程和你的业务线程一起跑。顺着老祖宗往下顺藤摸瓜找存活对象。虽然耗时最长,但用户感觉不到卡顿!
    3. 重新标记 (STW):因为第二步是大家一起跑的,业务线程可能会中途又创建了新对象,或者修改了引用。这一步停下来,专门修正这些"错漏"。时间稍长于第一步,但远比一口气全扫一遍快。
    4. 并发清除 (无 STW):用"标记-清除"算法,和业务线程一起,把垃圾清理掉。
  • 大厂连环杀机:CMS 有什么致命缺陷?
    • 内存碎片 :因为它为了做到"并发清除",不能去移动存活对象的位置(否则业务线程找不到了),所以只能用标记-清除 算法。碎片多了,大对象放不下,就会引发可怕的 Concurrent Mode Failure(并发模式失败)
    • 失败的后果 :一旦失败,CMS 会直接"摆烂",JVM 会立刻启动备用方案------退化成单线程的 Serial Old。想象一下,原本几十毫秒的停顿,瞬间变成卡死好几秒,整个系统直接处于瘫痪边缘。
4. G1 (Garbage First):面向未来的"大一统"王者

JDK 9 之后,G1 直接把 CMS 踹下了神坛,成为了默认回收器。G1 的设计理念可以说是 JVM 发展史上的神来之笔。

  • 颠覆性创新:化整为零的 Region
    • 以前的内存是物理上严格划分新生代、老年代。G1 直接把整个几 G 甚至几十 G 的堆,像切豆腐一样切成了 2048 个大小相等的 Region(区域)
    • 每个 Region 在逻辑上可以是 Eden、Survivor 或者 Old。今天这个小方块是新生代,明天回收清空后,它就可以摇身一变成为老年代。
  • 核心拷问:为什么叫 Garbage "First"(垃圾优先)?
    • G1 允许你设置一个最大期望停顿时间 (比如 -XX:MaxGCPauseMillis=200,即每次 GC 卡顿绝不超过 200 毫秒)。
    • 每次 GC 时,G1 会在后台计算每个 Region 里的垃圾数量和回收所需时间。然后它会挑出垃圾最多、回收收益最大的那几个 Region 优先进行回收(这就是 First 的由来)。它绝不贪心,到了 200 毫秒的红线马上停手,完美保证了低延迟。
  • 进阶深挖:跨代引用与卡表 (Card Table) / RSet
    • :如果老年代里的对象引用了新生代里的对象(跨代引用),新生代做 Minor GC 时,难道要把庞大的老年代全扫一遍来找引用吗?
    • :绝对不会。G1 在每个 Region 里都维护了一个叫做 Remembered Set (RSet) 的结构。它就像一个"外来访客登记簿",如果有其他 Region 的对象引用了自己,就会登记在这个簿子上(底层通过写屏障 + 脏卡队列异步更新)。GC 时,只需要扫这个登记簿,就能瞬间找出谁引用了自己,极大地缩短了搜索时间。

· 详细说明:

首先来讲为什么不能跨代引用:

设想下面这种情况:

在老年代里,有一个存活了很久的全局缓存大对象 Map<String, User> cache;。在某次业务代码中,你执行了 cache.put("001", user);。 这就是所谓的**"跨代引用"**:老年代的对象,偷偷引用了新生代的对象。

于是灾难发生了 : 如果 Minor GC 不看老年代 ,它就会觉得新生代里的"张三"没人要了,直接把它清理掉。 结果下一秒,你的业务代码通过老年代的 cache.get("001") 去找张三,拿到的就是一个极其可怕的野指针(空指针),系统直接抛出 NullPointerException 甚至引发底层内存崩溃。

所以在执行 Minor GC 的时候,如果老年代还指着新生代的对象,这个新生代对象就绝对不能死,

在确认跨代引用是必须的后,又引出了一个巨大的矛盾。

  • 新生代的初衷:因为对象死得快,所以 Minor GC 应该极其轻量、速度极快(通常在几毫秒到几十毫秒)。
  • 老年代的现实:老年代的空间通常非常庞大(可能是几 GB 甚至几十 GB),里面装着无数密密麻麻的存活对象。

JVM 的神级操作:卡表(Card Table)与记忆集(RSet)

为了解决"必须扫,但不能全扫"的矛盾,JVM 引入了空间换时间的神器。

大白话比喻: 新生代就像一所学校(人来人往,毕业就走),老年代就像一个庞大的居民小区。每次学校清退学生(Minor GC),都要查一下小区里有没有家长(老年代对象)还牵着学生的手(跨代引用)。如果挨家挨户敲门查(全盘扫描),效率太低。 于是,学校在门卫室放了一个"访客登记簿"(卡表/RSet)。只要有家长牵了学生的手,就必须在门卫室登记一下。清退学生时,只要看一眼登记簿就行了!

底层技术实现细节

    1. 卡表 (Card Table):老年代的内存被划分为一个个大小为 512 字节的小块,称为"卡页(Card Page)"。
    2. 写屏障 (Write Barrier) :这是 JVM 层面的一段切面拦截代码。每次在你的 Java 代码里发生引用赋值(比如 老年代对象.属性 = 新生代对象)时,JVM 就会触发写屏障。
    3. 标记脏卡 (Dirty Card) :写屏障会把发生赋值操作的老年代对象所在的那个"卡页",在卡表上标记为 "Dirty(脏)"
    4. 精准打击 :当发生 Minor GC 时,垃圾回收器根本不看整个老年代,它只扫描卡表上被标记为"脏卡"的那几个很小的内存块

通过这种极其精妙的设计,JVM 既保证了新生代存活对象的绝对安全(防止误杀),又把扫描老年代的时间压缩到了极限。在 G1 回收器中,这个逻辑被升级为了粒度更细的 RSet(Remembered Set),每个 Region 都自带一个登记簿,使得多 Region 并发回收成为可能。

如果为了做一次几毫秒的 Minor GC,每次都要把好几个 GB 的老年代从头到尾全盘扫描一遍,去大海捞针般地寻找"有没有跨代引用"......那 Stop The World 的时间就会长达几秒甚至几十秒。这就彻底违背了分代回收"快"的初衷。

一张表总结垃圾回收器:

|-------------------------|---------------------------------|-----------|------------|-----------------------------------------------------------|
| 垃圾回收器分类 | 代表回收器 | 工作区域 | 核心算法 | 适用场景与特点 |
| 串行回收(时代的眼泪) | Serial, Serial Old | 新生代 / 老年代 | 复制 / 标记-整理 | 整个垃圾回收过程只有一个线程在干活,而且干活的时候全程 STW。 |
| 吞吐量优先(JDK 8 的主力) | Parallel Scavenge, Parallel Old | 新生代 / 老年代 | 复制 / 标记-整理 | 多线程并行,JDK 8 默认组合。致力于达到一个可控制的吞吐量(后台计算型任务)。 |
| 响应时间优先(低延迟的里程碑) | CMS(Concurrent Mark Swap) | 新生代 / 老年代 | 复制 / 标记-清除 | 追求最短的用户停顿时间(STW)。CMS 是老年代并发收集器的里程碑,但容易产生碎片。 |
| 全能型 G1 : 面向未来的大一统王者 | Garbage First (G1) | 整个堆空间 | 标记-整理 + 复制 | JDK 9+ 默认回收器。不再追求物理分代,而是将堆划分为多个 Region。兼顾吞吐量与低延迟,可预测停顿时间。 |


6. JVM GC 调优浅尝辄止

调优不是玄学,而是一套基于数据和监控的方法论。

调优的核心黄金法则:最快的 GC 就是不发生 GC

在调整虚拟机参数之前,永远先检查你的代码:

  • 是否一次性从数据库加载了过多数据?
    • 假设在做"苍穹外卖"的订单导出功能,你写了一个 select * from orders where status = 'COMPLETED',想把所有历史订单捞出来用 Apache POI 导出 Excel。
    • 结果底层查出了 50 万条数据,瞬间在 JVM 堆内存里生成了 50 万个庞大的 Order 对象。新生代瞬间撑爆,直接塞进老年代,触发漫长的 Full GC,整个外卖系统直接卡死。
    • 正解:流式查询(Fetch Size)、分页处理、或者走专门的大数据离线跑批。
  • 数据结构是否过于臃肿?
  • 是否存在内存泄漏(如未释放的 IO 流、无限增长的集合)?
    • 在做全局拦截器时,用了 ThreadLocal 存用户的登录信息。但在请求结束的 afterCompletion忘记写 remove()
    • 线程池里的核心线程一直不死,导致大量的 User 对象一直被强引用着。老年代里的垃圾像滚雪球一样越来越大,最后无论怎么 Full GC 都清理不掉,直接 OOM 崩溃。
明确你的调优目标:吞吐量还是低延迟
  • 追求高吞吐量:选择 ParallelGC 组合,适合非交互式的后台定时任务、数据处理。
  • 追求低延迟:选择 CMS 或 G1,适合直面用户的 Web 应用。
调优思路:
  • 新生代调优:新生代不是越大越好。太小会导致频繁 Minor GC(影响吞吐量),太大会导致老年代空间受压榨,引发 Full GC。一般将新生代设置为能容纳并发量所对应的所有请求数据(并发量 * (请求到相应的生命周期时间))即可。
  • 老年代调优 :预留足够的空间(而不是等到老年代 100% 的时候才开始回收)。例如使用 CMS / G1 的时候,回收器是 并发 的(有一部分阶段和业务线程同时运行)。在清理老年代垃圾的时候,你的业务线程还在源源不断地往老年代里塞新的对象(这叫浮动垃圾)。
    • 所以必须预留**"安全气囊"**:
    • 比如设置 CMS 的参数 -XX:CMSInitiatingOccupancyFraction=75。意思是老年代只要用了 75%,就开始后台偷偷回收。剩下的 25% 空间,就是留给业务线程在并发回收期间继续分配对象的缓冲地带,从而完美避开 Concurrent Mode Failure 的崩溃。

7.总结

到这里,我们已经完整地丈量了 JVM 垃圾回收的整片领地。

从微观的对象生死判定(GC Roots、四大引用),到宏观的内存流转(分代模型、跨代引用与卡表机制),再到各大垃圾回收器的演进史(从 Serial 的单线程粗暴,到 CMS 的精巧妥协,再到 G1 化整为零的大一统),最后落脚于真实的线上调优与防坑指南。你会发现,JVM 垃圾回收的发展史,其实是一部**"不断与系统卡顿(Stop The World)作斗争"**的血泪史。

平时我们在写业务代码(比如写一个外卖下单、抢秒杀券的接口)时,JVM 都在底层默默为我们兜底。但当我们想要应对高并发、大流量的真实业务考验时,这层"黑盒"就必须被彻底打开。

最后,请牢记这句箴言:调优的尽头是代码,最快的 GC 是不发生 GC。

相关推荐
马猴烧酒.2 小时前
【JAVA算法|hot100】堆类型题目详解笔记
java·开发语言·笔记
载数而行5202 小时前
算法系列3之拓扑排序
c语言·数据结构·c++·算法·排序算法
!停2 小时前
数据结构排序算法—插入排序
数据结构·算法·排序算法
s砚山s2 小时前
代码随想录刷题——二叉树篇(二十一)
算法
啊哈哈哈哈哈啊哈哈2 小时前
Spring MVC 项目结构学习笔记
java·spring boot·spring·servlet·maven
谁不学习揍谁!2 小时前
基于python大数据机器学习旅游数据分析可视化推荐系统(完整系统+开发文档+部署教程+文档等资料)
大数据·python·算法·机器学习·数据分析·旅游·数据可视化
yyjtx2 小时前
DHU上机打卡D28
开发语言·c++·算法
莫寒清2 小时前
Spring MVC:MultipartFile 详解
java·spring·mvc
JavaLearnerZGQ2 小时前
Spring SseEmitter 全面解析与使用示例
java·后端·spring