1. 先搞清楚一个问题:JVM 为什么死磕垃圾回收
我刚入行那会儿,写 Java 最爽的一点就是不用手动释放内存。C/C++ 的朋友天天跟malloc、free较劲,我只需要new一个对象,用完扔那儿就行。但后来被线上 OOM 折腾过几次,才意识到一个问题:不释放内存,不代表内存不用管——恰恰因为 Java 把内存管理的脏活累活全包了,我们才更得搞清楚它在背后怎么干活。
JVM(Java Virtual Machine)的垃圾回收,英文叫Garbage Collection,简称GC。它干的事情说白了就三件:哪些对象该回收、什么时候回收、怎么回收。听起来简单,但"怎么判断一个对象死了"这件事,远比大多数人以为的复杂。
先说一个最常见的误解:很多人以为 GC 是 JVM 定时触发的一个"清理动作",像闹钟一样,到点就开始扫垃圾。其实不是。GC 的触发时机完全取决于内存的状况——堆内存快满了,或者分配内存失败,GC 才会被触发。而且 JVM 里不是只有一个 GC,是一套完整的组件,包含了不同的回收算法和实现方式。
这篇文章是"JVM 垃圾回收"的上篇,我打算先把地基打牢:堆内存结构、对象判活机制、三大经典垃圾回收算法,以及对象从创建到回收的完整生命周期。这一块搞明白了,下篇再聊具体有哪些收集器(Serial、Parallel、CMS、G1、ZGC),它们的参数怎么调,你才能真正看得懂为什么要这样调。
如果你是刚开始学 JVM,或者面试前想系统梳理 GC 的知识体系,这篇很适合你。如果你已经写过几个 GC 日志、调过几次堆参数,这篇也能帮你把那些碎片化的知识串成一条线。
2. GC 的工作舞台:堆内存到底是怎么规划的
垃圾回收不是在一整块平坦的内存上乱扫。JVM 的堆(Heap)在设计上就做了分区,这套分区方案是所有垃圾回收算法落地的物理基础。
2.1 新生代(Young Generation)和老年代(Old Generation)
先看一张 JVM 堆内存的经典划分(JDK 8 以后,且使用默认的分代收集器时):
| 区域 | 占比(默认) | 主要存放内容 | GC 频率 |
|---|---|---|---|
| Eden 区 | 新生代的 80% 左右 | 大多数新建对象 | 极高,Minor GC 频繁发生 |
| Survivor 0(S0) | 新生代的约 10% | Minor GC 后存活的对象 | 每次 Minor GC 互相复制 |
| Survivor 1(S1) | 新生代的约 10% | 同上 | 同上 |
| Old 区 | 堆的约 2/3 | 熬过多次 GC 的对象、大对象 | 较低,Major GC / Full GC 触发 |
这个分区的逻辑,来源于一个被大量实践验证过的经验规律:绝大多数对象都是"朝生夕死"的。你写一个方法,方法里创建一堆中间对象,方法执行完,这些对象就没人引用了。如果所有对象都堆在一起,GC 每次要扫描整个堆,成本高得离谱。分代之后,新生代只需要高频处理"短命对象",老年代处理那些真正能存活很久的对象。
我最早学 JVM 的时候对这个分区有点困惑:为什么 Survivor 要有两块?为什么不直接 Eden 到 Old?这个问题问到点上了,答案藏在下一节。
2.2 对象分配的默认路径
一个普通 Java 对象被new出来之后,第一站几乎都是 Eden 区(除非走了 TLAB 或者直接分配在老年代的特殊情况,后面细说)。为什么是"几乎"?因为 JVM 有TLAB(Thread-Local Allocation Buffer),每个线程有一小块私有的 Eden 空间,对象先在这里分配,避免多线程竞争同一块内存的锁开销。
Eden 区很快就会被塞满——毕竟 80% 的对象都是垃圾。此时 JVM 触发一次Minor GC,把存活的对象复制到 S0 区,然后一次性清空 Eden 区。下次 Minor GC 时,Eden 和 S0 里存活的对象复制到 S1,清空 Eden 和 S0。再下次,Eden 和 S1 复制到 S0。两个 Survivor 区轮流倒腾,这就是"复制"策略。
这里有个关键点:对象在 Survivor 之间每倒腾一次,年龄(age)就加 1。默认情况下,对象年龄到 15 岁(可以通过-XX:MaxTenuringThreshold调整),就会被"晋升"到老年代。为什么是 15?因为 HotSpot 虚拟机里对象头的 Mark Word 只预留了 4 个 bit 来存年龄,最大值就是 15。这是碎片化的历史包袱,但至今仍然生效。
2.3 永久代和元空间,别再把它们搞混了
很多老资料还在说"永久代",我建议你直接忘掉这个词。JDK 8 之后,方法区(Method Area)被彻底移出堆内存,改由元空间(Metaspace)承担,而且元空间用的是本地内存(Native Memory),不再受-Xmx控制。
这意味着什么呢?你调 JVM 参数的时候,-XX:MaxMetaspaceSize没设置的话,元空间理论上可以无限使用本地内存——有些应用频繁生成动态类,或者 CGLIB 代理类爆炸,就会出现 Metaspace OOM 而不是堆 OOM。我在实战中排查过一个内存问题,-Xmx设了 4G,堆内存看起来很正常,最后发现是元空间被动态生成的类塞爆了,直接导致容器被操作系统杀掉。
3. 判断对象生死:可达性分析到底在分析什么
GC 要回收对象,第一步是判定哪些对象该死。这一步做错了,要么误杀还在用的对象,要么放过已经死掉的对象。所以判活机制是整个 GC 体系的第一道防线。
3.1 引用计数法:听起来完美,落地全是坑
早期一些语言(比如 Python 的早期实现、COM 组件)采用引用计数:每个对象记录自己被引用的次数,引用数归零就立刻回收。JVM 没有选这条路,原因有两个:
循环引用问题。A 引用了 B,B 也引用了 A,但它们俩谁都不再被外部引用了。引用计数法下,A 的计数是 1(被 B 引着),B 的计数是 1(被 A 引着),永远不会归零,内存泄漏。
计算开销。每次引用赋值、引用销毁都要同步更新计数,这个开销在并发场景下会很可观。
3.2 GC Roots:可达性分析的起点集合
JVM 选用的是可达性分析(Reachability Analysis)。思路很简单:从一组叫作GC Roots的根节点出发,沿着引用链往下走。能被走到的对象标记为"存活",走不到的对象,就是垃圾。
面试和实际调优里,最常考的就是哪些对象能当 GC Roots。我帮你整理成一张表:
| 类型 | 具体内容 | 示例 |
|---|---|---|
| 栈帧中的本地变量 | 当前正在执行的方法里的局部变量 | 方法内Object obj = new Object()的 obj |
| 静态变量 | 类加载后存在方法区的静态字段 | public static List cache指向的对象 |
| 常量引用 | 字符串常量池、类常量等 | 被final修饰且指向对象的常量 |
| JNI 引用 | 本地方法(Native 方法)持有 | 通过 JNI 调用的 C/C++ 代码保存的对象引用 |
| 活跃线程 | 正在运行的线程 | 线程对象本身及其关联的 ThreadLocal 值 |
| 锁对象 | 被synchronized持有的对象 | synchronized(obj)中的 obj |
手动排查内存泄漏时,用MAT(Memory Analyzer Tool)或jprofile这类工具看"Dominator Tree"和"GC Roots Path",本质就是在做可达性分析的可视化操作:从泄漏对象的引用链里,一眼就能看出哪个根节点把它"钉死"了。
这里有个容易被忽略的坑:静态集合类。public static List<Data> CACHE = new ArrayList<>(),往里面塞了一堆数据从不清理。CACHE 是静态变量,是 GC Roots 之一,它引用的所有对象永远可达。这种情况 GC 永远不会回收它们,你的堆再怎么调大也没用——这不是 GC 的问题,是代码问题。我在排查线上内存飙升时,至少有一半的案例最后定位到这种静态缓存。
3.3 四种引用类型:不只是强弱之分
JDK 1.2 之后,Java 把引用分成了四种强度,这是面试高频考点,也是理解内存泄漏的重要基础:
| 引用类型 | 回收时机 | 典型应用 |
|---|---|---|
| 强引用(Strong Reference) | 只要还存在,GC 永不回收 | new出来的普通对象 |
| 软引用(Soft Reference) | 内存充足不回收,内存不足才回收 | 缓存系统,如图片缓存 |
| 弱引用(Weak Reference) | 下次 GC 必定回收 | ThreadLocal 的 key、WeakHashMap |
| 虚引用(Phantom Reference) | 随时可能回收,用于追踪对象被回收的时刻 | 堆外内存的回收通知 |
举个例子:你做一个图片加载框架,如果所有图片都强引用,缓存一多内存立刻爆。用软引用包一层,JVM 在内存紧张时自动回收这些缓存图片,既保证了功能,又避免了 OOM。这就是软引用的价值。
3.4 finalize() 自救:看似神奇,实则坑人
关于对象"自救",有一个很经典的说法:在finalize()里重新建立引用,对象就能避免被回收。理论上确实可以——对象判定为不可达后,会被放进 F-Queue,由 Finalizer 线程执行finalize(),如果在里面重新把自己赋给某个静态变量,它就能"死而复生"。
但我强烈建议你:永远不要依赖 finalize() 来做资源清理或对象自救。原因很现实:
finalize()什么时候执行、执行到什么程度,时序完全不确定- 它会导致对象进入 F-Queue,延迟回收时间,拖慢 GC
- 每次执行完毕,第二次 GC 时会直接被回收,没有任何再次执行的机会
- 如果
finalize()本身写了烂代码(比如死循环),Finalizer 线程会被阻塞,整个 GC 跟着遭殃
Java 9 已经把finalize()标记为废弃。资源清理用try-with-resources或Cleaner(基于虚引用实现)才是正道。
4. 三大经典 GC 算法:原理与致命伤
判生死解决的是"哪些对象该死",接下来要解决"怎么杀"。三大经典算法——标记-清除、标记-复制、标记-整理——是所有收集器的理论基石。把这三个吃透,后面看任何收集器的源码或者参数配置,都会觉得顺理成章。
4.1 标记-清除:最朴素,但有两个硬伤
流程很简单:先按可达性分析标记存活对象,然后统一回收所有未被标记的对象。听起来很直接,但存在两个致命问题:
碎片化严重。回收掉散落的内存之后,剩下的空间不连续。比如一个 1MB 的连续空间需要分配,但可用内存碎片化后没有一块连续区域能装下它,就会触发一次新的 GC。内存碎片累积到一定程度,GC 频率会越来越高,形成恶性循环。
标记和清除两个阶段都要遍历一堆对象,尤其清除阶段要逐个处理死亡对象,大堆场景下开销不小。
4.2 标记-复制:用空间换时间,新生代的主力算法
为了解决碎片化,复制算法把一块内存分成两半,只用一半,GC 时把存活对象复制到另一半,然后一次性清空原来那半块。无碎片、分配简单、效率极高——代价是空间最多只能用一半。
如果你直接把这个算法套到大堆上,等于内存利用率直接砍半,谁受得了?所以 JVM 在新生代做了妥协设计:不按 1:1 分两半,而是按Eden : Survivor = 8 : 1的比例划分。Minor GC 时,Eden 和当前有数据的 Survivor 存活对象复制到另一个 Survivor。大部分对象都是朝生夕死的,每次存活下来的很少,所以 10% 的 Survivor 空间对"存活对象"来说其实够用了。
但空间分配担保机制保证了极端情况兜底:如果 Survivor 区装不下存活对象,多出来的对象直接晋升到老年代。所以即使只有 10% 的备用空间,也不会因为存不下直接爆掉。
4.3 标记-整理:老年代的救赎,但也有代价
复制算法在存活率高的场景下性能骤降——如果对象几乎都活下来了,你得先复制一遍再到新空间写完,纯纯的白费功夫。老年代的对象恰恰都是经过多次 GC 依然存活的老油条,存活率高,不能直接搬复制算法的方案。
标记-整理算法在标记之后多了一步:让所有存活对象向一端移动,然后直接清理掉端边界以外的内存。既解决了碎片化,又避免了空间砍半的浪费。
代价是什么呢?移动对象意味着更新引用地址,而且移动过程会 stop-the-world(STW,暂停所有用户线程)。对于老年代这个大区域,一次性移动所有存活对象,停顿时间可能会很长。现代收集器(比如 G1)已经试图把这种"移动整理"改成"局部整理 + 增量回收"来规避全局停顿,但基本原理依然是标记-整理。
4.4 分代收集理论:组合拳才能打天下
如果你理解到这一步,就会自然产生一个问题:三种算法各有优缺点,单用哪个都不完美,怎么办?
答案是分代收集理论:新生代用标记-复制,因为对象存活率低,复制成本小,且天然无碎片;老年代用标记-整理(或标记-清除的变体),因为存活率高,适合整理;**跨代引用用记忆集(Remembered Set)**来记录老年代对象指向新生代的引用,避免每次 Minor GC 都要扫描整个老年代。
现代 JVM 的绝大多数收集器,包括 G1、ZGC、Shenandoah,都是在这个理论框架下实现的。理解不了这个框架,看收集器源码完全是在看天书;理解了这个框架,你再看任何收集器的设计思想,都是"在既定约束下做工程取舍"。
5. 对象的一生:从 Eden 到老年代的全流程
理论讲完了,我们把视角拉回到一个具体的对象,看看它从出生到死亡,到底经历了哪些关键事件。
5.1 第一步:对象的出生与分配
当你执行Object obj = new Object()时,JVM 会优先在当前线程的 TLAB内分配空闲连续空间,这是 Eden 区里预留的线程私有缓冲区。TLAB 满了之后,才尝试在 Eden 区公共区域分配。
这里有个实战经验:调优高并发系统时,TLAB 的默认大小(Eden 的 1%)经常不够用,可以通过-XX:TLABSize适当调大来减少对象在公共区域的分配竞争。但注意,TLAB 调大会吃掉更多 Eden 空间,间接影响 Minor GC 频率,需要结合监控数据来找平衡点。
5.2 第二步:Minor GC 与 Survivor 的轮流迁徙
Eden 区空间耗尽时,JVM 触发 Minor GC。此时:
- 从 GC Roots 出发做可达性分析,找到 Eden + S0(假设当前在 S0)中的存活对象
- 将这些对象复制到 S1,年龄 +1
- 清空 Eden 和 S0 的所有对象
下次 Minor GC 时,Eden + S1 的存活对象复制到 S0,再清空 Eden 和 S1。两块 Survivor 区就这么来回倒腾。
5.3 第三步:动态年龄与晋升老年代
对象年龄到 15(可以调)时,晋升老年代。但 JVM 有个细节许多人不知道:动态年龄判断。如果 Survivor 中某个年龄段的对象总大小超过 Survivor 区的 50%(参数是-XX:TargetSurvivorRatio),系统会把年龄大于等于该阈值的对象直接晋升到老年代,而不是死等 15 岁上限。
我理解这个机制的设计意图:Survivor 区空间有限,如果某个年龄段的存活对象已经很多,继续复制下去只会反复搬运,不如尽早把它们送进老年代,让 Survivor 区保持轻量化。代价是可能出现"过早晋升",导致老年代提前承担压力,Full GC 变频繁。这是分代设计里常见的权衡。
此外,大对象(-XX:PretenureSizeThreshold设定阈值,默认是 0,即不启用)会直接进入老年代。为什么?因为大对象在新生代的复制成本太高,Eden 和两个 Survivor 之间来回倒腾几次,时间和空间都不划算,直接放老年代一锤子干完。这个参数在线上要谨慎开,开小了会导致小对象也直接进老年代,老年代快速膨胀。
5.4 第四步:空间分配担保——最后的兜底
Minor GC 还有一个前置条件:JVM 在触发之前要检查老年代的连续空间是否足够容纳新生代所有存活对象的最坏情况。如果足够,直接 GC;如果不够,会看-XX:-HandlePromotionFailure是否开启(JDK 6 Update 24 之后的默认行为发生了调整)。
简单说,现代 JVM 的规则是:只要老年代的连续空间大于等于历次 Minor GC 后晋升老年代对象的平均大小,就尝试进行一次 Minor GC,否则提前触发 Full GC。
这一步在线上经常被忽略,却直接影响 GC 行为。我以前排查过一个系统:老年代空间明明还很大,Minor GC 却频繁触发,Full GC 隔三差五来一次。最后发现就是因为大对象直接进老年代的参数配置不当,导致老年代可用的连续空间碎片化严重,JVM 在做空间分配担保判断时认为"老年代剩余空间不够用",被迫频繁 Full GC。解决方式是把大对象阈值调低 + 对老年代做一次碎片整理,GC 立刻安静了。
6. 上篇总结与下篇预告:从算法到工程实现的距离
这一篇的内容量已经不小了。梳理一下主线逻辑:
- 堆内存分区(Eden、Survivor、Old)是 GC 算法的物理舞台,分代设计源于"对象朝生夕死"的经验规律
- 可达性分析从 GC Roots 出发判定生死,四种引用强度决定了对象在内存压力下的不同命运
- 三大算法各有优劣:标记-清除防碎片能力弱,标记-复制空间成本高,标记-整理移动开销大,分代收集理论把三者组合成一套可落地的方案
- 对象的一生经历了 TLAB 分配 → Eden → Survivor 轮流迁徙 → 动态年龄判定 → 晋升老年代 → 空间分配担保兜底,每个环节都有可调的参数和值得细究的权衡
上篇到这里可以收束了。下篇我会重点展开 JVM 具体实现的那些垃圾收集器:Serial 的单线程保守、Parallel 的吞吐量优先、CMS 的低延迟梦想与碎片化困境、G1 的 Region 化改造、ZGC 和 Shenandoah 面向超大规模堆的停顿控制。每个收集器对应一类业务场景,它们的参数调优其实都是在"吞吐量、停顿时间、内存占用"这个不可能三角里找平衡点。
我在实际项目中体会最深的一点是:调优 GC 之前,先确认业务模型。到底是批处理、在线交易还是大数据实时计算,决定了你要优先看吞吐量还是停顿时间。很多人在网上背了一堆-XX参数,上来就往生产环境堆,结果越调越糟。先把上篇这些底层机制揉碎了理解,再去看参数,你自然会判断哪些该动、哪些不该动。
最后说一个小技巧,建议你看 GC 日志的时候,别只盯着Full GC看,重点看Minor GC 的触发频率和晋升速率(Allocation Rate 和 Promotion Rate)。这两个指标能告诉你:到底是对象创建太猛,还是晋升策略出问题。找到根因再动手调参,才是工程化的做法。