在 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 相连,它并不是立刻死亡,而是进入了**"缓刑阶段"**。
真正宣告一个对象死亡,至少要经历两次标记过程:
- 第一次标记 :对象不可达,被盖上"待回收"的戳。JVM 会判断这个对象有没有重写
finalize()方法。
-
- 如果没有重写,或者已经被调用过一次了,那就直接判死刑。
- 如果重写了且没被调用过,对象会被放进一个叫
F-Queue的队列里。
- 第二次标记(自救机会) :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、以及各种临时组装的List和Map。 - 请求一旦响应完毕(可能就几十毫秒),这些对象瞬间变成垃圾,这就是典型的**"朝生夕死"**。
- 如果不分代,每次 GC 都要把整个堆(比如 8GB)全部扫描一遍,去找这几十毫秒产生的垃圾,这简直是大炮打蚊子。分代的本质,是为了把那 98% 马上要死的对象圈在一个极小的范围(新生代)内集中高效处理。
堆内存结构
堆内存通常被划分为 新生代 (Young Generation) 和 老年代 (Old Generation) 。新生代又细分为 伊甸园 (Eden) 和两个幸存区 (Survivor From / Survivor To) 。这里必须要有两个幸存区是为了 彻底消灭内存碎片。
经典的 Minor GC(新生代 GC)流程:
- 新创建的对象默认分配在 Eden 区。
- 当 Eden 区满时,触发 Minor GC。
- Eden 区和 From 区存活的对象会被复制到 To 区,存活对象的年龄 +1。
- 清空 Eden 区和 From 区,然后交换 From 和 To 的逻辑指针(保证 To 区总是空的)。
- 当对象的年龄达到晋升阈值(默认最大 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 是怎么把停顿时间降下来的?(著名的四步走)
-
- 初始标记 (STW):只标记跟 GC Roots 直接相连的老祖宗对象。速度极快,停顿时间极短。
- 并发标记 (无 STW) :核心精髓! 垃圾回收线程和你的业务线程一起跑。顺着老祖宗往下顺藤摸瓜找存活对象。虽然耗时最长,但用户感觉不到卡顿!
- 重新标记 (STW):因为第二步是大家一起跑的,业务线程可能会中途又创建了新对象,或者修改了引用。这一步停下来,专门修正这些"错漏"。时间稍长于第一步,但远比一口气全扫一遍快。
- 并发清除 (无 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 毫秒的红线马上停手,完美保证了低延迟。
- G1 允许你设置一个最大期望停顿时间 (比如
- 进阶深挖:跨代引用与卡表 (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)。只要有家长牵了学生的手,就必须在门卫室登记一下。清退学生时,只要看一眼登记簿就行了!
底层技术实现细节:
-
- 卡表 (Card Table):老年代的内存被划分为一个个大小为 512 字节的小块,称为"卡页(Card Page)"。
- 写屏障 (Write Barrier) :这是 JVM 层面的一段切面拦截代码。每次在你的 Java 代码里发生引用赋值(比如
老年代对象.属性 = 新生代对象)时,JVM 就会触发写屏障。 - 标记脏卡 (Dirty Card) :写屏障会把发生赋值操作的老年代对象所在的那个"卡页",在卡表上标记为 "Dirty(脏)"。
- 精准打击 :当发生 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。