深入理解 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 的目标。
voidmethod(){inta=10;// 栈上,方法结束自动回收,与 GC 无关Personp=newPerson();// p 引用在栈上;new 出来的 Person 对象在堆上p=null;// 只是断开引用,对象本身还在堆里,等 GC 来回收}二、怎么找到垃圾?—— 垃圾判定算法
垃圾回收的第一个问题就是:怎么判断一个对象到底是不是垃圾?主流有两种思路:引用计数和可达性分析。Java(HotSpot JVM)采用的是可达性分析。
2.1 方案一:引用计数(Java 不采用)
思路:给每个对象配一个"计数器",记录有多少个引用指向它:
- 有引用指向这个对象 → 计数器 +1;
- 引用失效(比如引用被置 null、引用所在栈帧销毁)→ 计数器 -1;
- 计数器减到 0 → 说明没有任何引用指向它 → 它就是垃圾。
A ──引用──→ [对象obj, 引用计数=1] A 置 null → 计数=0 → 判定为垃圾 ✓优点:实现简单、判定高效,垃圾产生时立刻就能回收,没有延迟。
致命缺陷:循环引用——
classNode{Nodenext;}Nodea=newNode();// a 的计数 = 1Nodeb=newNode();// b 的计数 = 1a.next=b;// b 的计数 = 2b.next=a;// a 的计数 = 2a=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 时间而已。希望这篇文章对你有帮助,欢迎点赞收藏!