深入理解 JVM 垃圾回收:GC 是什么、如何判定垃圾、四大回收算法全解析
Java 和 C/C++ 最大的区别之一,就是程序员不用手动
malloc/free管理内存------JVM 的**垃圾回收(GC, Garbage Collection)**机制会自动找出"没用的对象"并释放它们占用的内存。本文将讲解垃圾回收是什么、回收的是哪个区域的内存、如何判定一个对象是不是垃圾(引用计数 vs 可达性分析),以及四种经典的垃圾回收算法:标记清除、复制算法、标记整理、分代回收。
一、垃圾回收是什么?回收的是哪里的内存?
1.1 什么是垃圾回收
程序运行过程中,会不断地创建对象。有些对象用完之后再也不会被使用了,它们占着的内存就变成了"垃圾"。
- C/C++ 中:需要程序员手动
free/delete,忘了释放就内存泄漏; - Java 中:JVM 提供垃圾回收机制(GC) ,自动 找出这些废弃对象并回收其内存,程序员只管
new,不管释放。
垃圾回收要做两件事:① 发现垃圾 ------哪些对象已经没用了;② 回收垃圾------释放这些对象占用的内存空间。本文第二节讲"发现",第三节讲"回收"。
1.2 回收的是哪个区的内存?
回顾 JVM 内存划分:程序计数器、栈、堆、元数据区(方法区)。GC 回收的重点是------堆区,其次是元数据区(方法区):
- 堆区(重点) :所有
new出来的对象、数组都在堆里,对象"朝生夕死"、数量庞大、变化频繁,是 GC 的绝对主战场; - 元数据区(方法区) :主要回收废弃的类------该类的所有实例都已被回收、类加载器也被回收、Class 对象没有引用时才可能卸载,条件苛刻、收益很低,GC 基本不管;
- 栈区、程序计数器 :不需要 GC!栈帧随方法结束自动出栈,程序计数器随线程销毁消失,它们的内存分配回收是确定的、随代码执行自动完成的,根本轮不到垃圾回收器插手。
一句话记忆:GC 回收 = 主要回收堆(对象),捎带回收方法区(类);栈和程序计数器自动回收,不需要 GC。
1.3 以"对象"为基本单位回收
垃圾回收的基本单位是对象(和数组),而不是字节、也不是变量:
- 回收时是一整个对象一起释放(对象包含的成员变量、引用等都随对象整体回收);
- 你不能只回收"对象的一半",也不能手动回收某个对象------Java 没有
free这样的 API; - 基本类型的局部变量在栈上 ,随栈帧销毁自动消失,不在 GC 管辖范围内;只有堆上的对象才是 GC 的目标。
java
void method() {
int a = 10; // 栈上,方法结束自动回收,与 GC 无关
Person p = new Person(); // p 引用在栈上;new 出来的 Person 对象在堆上
p = null; // 只是断开引用,对象本身还在堆里,等 GC 来回收
}
二、怎么找到垃圾?------ 垃圾判定算法
垃圾回收的第一个问题就是:怎么判断一个对象到底是不是垃圾? 主流有两种思路:引用计数和可达性分析。Java(HotSpot JVM)采用的是可达性分析。
2.1 方案一:引用计数(Java 不采用)
思路:给每个对象配一个"计数器",记录有多少个引用指向它:
-
有引用指向这个对象 → 计数器 +1;
-
引用失效(比如引用被置 null、引用所在栈帧销毁)→ 计数器 -1;
-
计数器减到 0 → 说明没有任何引用指向它 → 它就是垃圾。
A ──引用──→ [对象obj, 引用计数=1]
A 置 null → 计数=0 → 判定为垃圾 ✓
优点:实现简单、判定高效,垃圾产生时立刻就能回收,没有延迟。
致命缺陷:循环引用------
java
class Node {
Node next;
}
Node a = new Node(); // a 的计数 = 1
Node b = new Node(); // b 的计数 = 1
a.next = b; // b 的计数 = 2
b.next = a; // a 的计数 = 2
a = null; // a 的计数 = 1(b 还指着它)
b = null; // b 的计数 = 1(a 还指着它)
// a、b 在程序里已经"死了",谁也访问不到它们
// 但它们互相引用,计数器都是 1,永远减不到 0 ------ 回收不掉!
比喻:两个落水的人互相抓着对方,谁都没抓住岸(外部引用),但按计数规则他们"还被引用着",不算垃圾------显然不合理。
正因为循环引用无解,JVM 没有采用引用计数(Python 等语言采用,但需要额外的循环引用检测辅助)。
2.2 方案二:可达性分析(Java 采用 ✓)
思路 :换个角度------不看"谁指向我",而看"我能不能被走到"。
- 先确定一组根对象(GC Roots),把它们当作"起点";
- 从 GC Roots 出发,沿着所有的引用链一路遍历下去;
- 遍历能到达的对象,都是**"可达"的(存活的)**;
- 遍历不到的对象 ------哪怕它内部有再多引用------都是**"不可达"的 → 就是垃圾**。
哪些对象可以作为 GC Roots?
-
栈上(局部变量表)引用的对象:正在执行的方法里用到的对象;
-
元数据区中:类的静态变量引用的对象;
-
元数据区中:常量引用的对象(如字符串常量池里的引用);
-
JNI 引用:native 方法引用的对象;
-
活跃的线程对象等。
GC Roots ──→ A ──→ B ──→ C A、B、C 可达 → 存活 ✓
└──→ D ──→ E D、E 可达 → 存活 ✓F ──→ G ──→ F F、G 互相引用,但从 GC Roots 走不过去
→ 不可达 → 都是垃圾 ✓(循环引用被解决!)
优点:
- 完美解决循环引用问题------上面互相引用的 F、G,虽然互相指向,但从 GC Roots 走不到它们,照样判定为垃圾;
- 判定结果准确,是 Java 官方采用的方式。
代价:
- 每次 GC 都要从 GC Roots 做一次全图遍历,对象多的时候耗时;
- 遍历期间需要暂停所有业务线程 (著名的 Stop The World,STW )------否则遍历的同时对象引用还在变,结果就不准了。各种 GC 算法/垃圾回收器的优化,很大程度都是在想办法缩短 STW 的时间。
补充:可达性分析判定的对象也非"必死无疑",还有一次"缓刑"机会------
finalize()方法(已废弃,了解即可)。
三、具体的垃圾回收机制(回收算法)
判定了哪些对象是垃圾之后,怎么释放它们的内存?有三种基础算法,以及一种把它们组合起来的"集大成者"------分代回收。
3.1 标记清除(Mark-Sweep)

最直观的方案,分两步:
-
标记(Mark) :从 GC Roots 做可达性分析,标记所有存活的对象;
-
清除(Sweep) :遍历整个堆,把没有被标记的对象直接释放。
回收前: [A][垃圾][B][垃圾][垃圾][C]
回收后: [A]▁▁▁▁[B]▁▁▁▁▁▁▁[C] ← 对象不动,垃圾位置变成空洞
优点:实现简单,对象不搬家,不需要移动存活对象。
缺点:产生内存碎片
- 清除后空闲内存是一块一块的不连续空洞;
- 之后想分配一个较大的对象 时,虽然空洞的总容量够,但没有一块连续的空间放得下------"有内存却分配不了";
- 为了找到合适的空洞,还可能引入额外的空闲链表维护开销。
比喻:房间里的东西不挪动,只把垃圾抽走,地上留下一堆零散的空地------想放一张新沙发(大对象)却发现没有一块够大的完整地面。
3.2 复制算法(Copying)

思路 :不与碎片纠缠,直接"换个地方重新排"------把内存一分为二(两块一样大的空间 A、B),同一时刻只用其中一块:
-
平时所有对象都分配在 A 区,B 区完全空闲;
-
A 区满了触发 GC:从 GC Roots 可达性分析,把所有存活的对象复制到 B 区 (并且紧凑排列,不留缝隙);
-
然后把 A 区整个清空------垃圾一个一个挑?不用了,直接全部扔掉;
-
之后在 B 区分配对象;B 满了再复制回 A,如此往复。
回收前: A区: [A][垃圾][B][垃圾][C] B区: (空)
复制: B区: [A][B][C] ← 存活对象紧凑排列
清空: A区: (整块清空) ← 垃圾不用单独处理,一起扬了
优点:
- 没有内存碎片------存活对象复制时都排在一起;
- 回收效率高 ------不用逐个标记清除垃圾,整块清空即可,成本只和存活对象数量相关,与垃圾数量无关(垃圾越多越划算)。
缺点:内存利用率只有一半
- 两块空间同一时刻只用一块,一半内存长期闲置------代价太奢侈;
- 如果存活对象很多(比如老年代,对象普遍长期存活),复制要搬运大量数据,效率反而差。
适用场景:存活率低的区域(新生代------大部分对象朝生夕死,每次 GC 存活的只有一小撮)。这正是 JVM 堆中新生代采用复制算法的原因;新生代按 8:1:1 划分 Eden 和两个 Survivor 区,就是对"整块对半分"的改良------平时只用 Eden + 一个 Survivor,GC 时存活对象复制到另一个 Survivor,只有 10% 的空间浪费。
3.3 标记整理(Mark-Compact)

思路 :吸收前两者的长处------标记完不"直接删",而是把存活对象往一端移动,让它们紧凑排列,然后清理边界之外的内存:
-
标记:同标记清除,标记所有存活对象;
-
整理(Compact) :把所有存活对象向内存的一端移动,紧凑排列;
-
清理:边界之外的内存一次性全部回收。
回收前: [A][垃圾][B][垃圾][垃圾][C]
移动: [A][B][C] ← 存活对象向左靠拢、紧挨着
回收后: [A][B][C][▁▁▁▁▁▁▁] ← 右侧整块连续空闲,无碎片
优点:
- 没有内存碎片(移动时自然紧凑排列);
- 内存利用率 100%(不需要像复制算法那样空着一半)。
缺点:移动对象开销大
- 移动存活对象需要搬运数据 + 更新所有指向它们的引用,成本高;
- 移动期间同样要 STW,且比标记清除停顿更久。
适用场景:存活率高的区域(老年代------对象长期存活,垃圾只占少数,用复制算法要搬运大量对象太亏,标记整理更划算)。
3.4 分代回收(集大成者)

三种基础算法各有优劣、各有适用场景,JVM 的做法是:不二选一,而是按对象的特征把堆分成不同"代",不同代用不同算法------这就是分代回收,也是 HotSpot 实际采用的方案。
理论依据:弱分代假说------绝大多数对象都是"朝生夕死"的,只有少数对象能长期存活。
堆的分区:
堆
├── 新生代(对象出生地,朝生夕死的短命对象)------ 特点:存活少 → 用【复制算法】
└── 老年代(熬过多次 GC 的长期存活对象、大对象)------ 特点:存活多 → 用【标记清除/标记整理】
完整的回收流程:
-
对象出生 :
new出来的对象优先分配在新生代的 Eden 区; -
第一次 Minor GC :Eden 满了触发 Minor GC(复制算法)------存活对象复制到 Survivor 区(S0/S1 之间来回倒),垃圾整块清空;
-
年龄增长 :每熬过一次 Minor GC,对象"年龄"+1;年龄达到阈值(默认 15) ,晋升到老年代;
-
大对象直接进老年代:超大对象(如大数组)不在新生代折腾,直接分配到老年代(避免复制开销);
-
Major GC / Full GC :老年代满了触发老年代回收(标记清除或标记整理),通常伴随整堆回收,STW 时间更长。
new 对象 → Eden
Eden 满 → Minor GC(复制算法)→ 存活的进 Survivor(年龄+1)
年龄 ≥ 15 或大对象 → 晋升老年代
老年代满 → Major GC / Full GC(标记清除/标记整理)
记忆:新生代"死得快"用复制(复制的是少数存活者),老年代"活得久"用标记清除/整理(清理的是少数垃圾)------分代回收让每种算法都用在刀刃上。
四、总结
- GC 是什么 :JVM 自动回收废弃对象内存的机制;回收的是堆区 (对象,主战场),捎带元数据区(废弃类);以对象为基本单位,栈和程序计数器不需要 GC;
- 怎么找垃圾 :
- 引用计数 :计数器归零即垃圾,简单高效但循环引用无解,Java 不用;
- 可达性分析(Java 采用) :从 GC Roots 出发沿引用链遍历,走不到的就是垃圾,完美解决循环引用,代价是遍历时需要 STW;
- 怎么回收 :
- 标记清除 :简单但碎片化;
- 复制算法 :无碎片、回收快,但浪费一半内存 ,适合存活少的新生代;
- 标记整理 :无碎片、内存全利用,但移动对象开销大,适合存活多的老年代;
- 分代回收:按"弱分代假说"把堆分为新生代(复制算法)和老年代(标记清除/整理),让各算法扬长避短,是 HotSpot 的实际方案。
一句话收尾:GC 的全部工作可以概括为"找垃圾(可达性分析)+ 清内存(分代 + 三大算法组合)"。理解了这两步,再看 CMS、G1、ZGC 等具体垃圾回收器,无非是在这个框架上优化 STW 时间而已。希望这篇文章对你有帮助,欢迎点赞收藏!