垃圾回收算法
这篇文章用大白话讲清楚 JVM 的垃圾回收(GC)算法。不讲那些晦涩的术语,就像给门外汉解释一样。
1. 什么是垃圾回收?
想象你的程序是一个不断产生垃圾的房间:
- 你写代码创建对象 = 往房间里放东西
- 对象用完了不再需要 = 东西变成垃圾
- 垃圾回收(GC)= 请保洁阿姨来打扫
如果不打扫会怎样? 房间会被垃圾塞满(内存溢出 OOM),程序就崩了。
2. 怎么判断是不是垃圾?
JVM 怎么知道哪些对象是垃圾呢?主要靠引用计数和可达性分析。
2.1 引用计数法(简单但有坑)
原理:每个对象有个计数器,被引用一次就 +1,引用没了就 -1,为 0 就是垃圾。
对象A被引用了 3 次 → 计数器 = 3 → 还有用,不是垃圾
对象B没人引用了 → 计数器 = 0 → 是垃圾,可以清理坑在哪?循环引用!
java
// A 引用 B,B 也引用 A
A.friend = B;
B.friend = A;
// 现在把 A 和 B 都置空
A = null;
B = null;
// 问题来了:A 和 B 互相引用,计数器都不为 0
// 但实际上它们都是垃圾!所以 JVM 不用这个方法。
2.2 可达性分析(JVM 实际用的)
原理:从一些**根节点(GC Roots)**出发,能顺着引用链找到的对象就是活的,找不到的就是垃圾。
GC Roots(根节点)
│
▼
对象A ──────► 对象B ──────► 对象C
│
▼
对象D
对象E(没有引用链连到根节点)→ 垃圾!
对象F(孤零零的)→ 垃圾!什么是 GC Roots?
简单说就是"肯定活着"的东西:
- 正在运行的方法里的局部变量
- 静态变量
- JNI 引用的对象
3. 三种基础算法
知道了什么是垃圾,接下来就是怎么清理。有三种基础算法:
3.1 标记-清除算法
原理:分两步走
- 标记:先把所有垃圾标记出来
- 清除:然后把标记的垃圾清掉
清理前:
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ A │ 垃│ B │ 垃│ 垃│ C │ D │ 垃│
└───┴───┴───┴───┴───┴───┴───┴───┘
标记垃圾:
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ A │ ✖ │ B │ ✖ │ ✖ │ C │ D │ ✖ │
└───┴───┴───┴───┴───┴───┴───┴───┘
清除后:
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ A │ │ B │ │ │ C │ D │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
↑ ↑ ↑ ↑
└───────┴───┴───────────┘
内存碎片!优点:简单直接
缺点:产生内存碎片!就像房间里东西扔了,但空出来的地方不连续,想放个大件家具都放不下。
3.2 复制算法
原理:把内存分成两半,只用其中一半。清理时,把活着的对象复制到另一半,然后把原来那半整个清空。
使用中的一半: 空闲的一半:
┌───┬───┬───┬───┐ ┌───┬───┬───┬───┐
│ A │ 垃│ B │ 垃│ │ │ │ │ │
└───┴───┴───┴───┘ └───┴───┴───┴───┘
复制活的对象过去:
┌───┬───┬───┬───┐ ┌───┬───┬───┬───┐
│ A │ 垃│ B │ 垃│ ───► │ A │ B │ │ │
└───┴───┴───┴───┘ └───┴───┴───┴───┘
清空原来的一半:
┌───┬───┬───┬───┐ ┌───┬───┬───┬───┐
│ │ │ │ │ │ A │ B │ │ │ ← 连续的!
└───┴───┴───┴───┘ └───┴───┴───┴───┘优点:没有内存碎片,分配内存超快(直接往后加就行)
缺点:浪费一半内存! 有点奢侈。
适用场景:新生代(因为大部分对象都是"朝生夕死",活着的少,复制起来不费劲)
3.3 标记-整理算法
原理:先标记垃圾,然后把活着的对象都往一头挪,最后清理掉边界以外的。
清理前:
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ A │ 垃│ B │ 垃│ 垃│ C │ D │ 垃│
└───┴───┴───┴───┴───┴───┴───┴───┘
整理(活的往左挪):
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ A │ B │ C │ D │ │ │ │ │
└───┴───┴───┴───┴───┴───┴───┴───┘
↑
边界线
清理边界外:
┌───┬───┬───┬───┐
│ A │ B │ C │ D │ ← 紧凑、连续!
└───┴───┴───┴───┘优点:没有内存碎片,不浪费内存
缺点:移动对象需要时间,比较慢
适用场景:老年代(对象活得久,不想浪费空间)
4. 三种算法对比
| 算法 | 内存碎片 | 空间利用率 | 速度 | 适用区域 |
|---|---|---|---|---|
| 标记-清除 | ❌ 有 | ✅ 高 | ⚡ 快 | - |
| 复制 | ✅ 无 | ❌ 浪费一半 | ⚡⚡ 最快 | 新生代 |
| 标记-整理 | ✅ 无 | ✅ 高 | 🐢 慢 | 老年代 |
5. 新生代和老年代
JVM 把堆内存分成两块:
┌─────────────────────────────────────────────────────────┐
│ 堆 │
├───────────────────────────┬─────────────────────────────┤
│ 新生代 │ 老年代 │
│ (年轻人住的) │ (老人住的) │
│ │ │
│ ┌─────┬─────┬─────┐ │ │
│ │Eden │ S0 │ S1 │ │ │
│ │(伊甸)│(幸存)│(幸存)│ │ │
│ └─────┴─────┴─────┘ │ │
│ │ │
│ 新对象出生在这里 │ "活久了"的对象搬到这里 │
│ GC 很频繁,但很快 │ GC 不频繁,但比较慢 │
│ 用【复制算法】 │ 用【标记-整理】 │
└───────────────────────────┴─────────────────────────────┘对象的一生
1. 新对象诞生 → 放进 Eden 区
2. Eden 满了 → 触发 Minor GC
- 活着的对象复制到 S0
- Eden 清空
3. 下次 GC → S0 的对象复制到 S1,Eden 活着的也去 S1
- S0 和 Eden 清空
4. 如此往复,S0 和 S1 轮流使用
5. 对象熬过了 15 次 GC(默认)→ 晋升到老年代
"恭喜你,年轻人,你活下来了,去养老院吧"
6. 老年代满了 → 触发 Major GC / Full GC(很慢!)6. 一句话总结
| 算法 | 一句话 |
|---|---|
| 标记-清除 | 标记垃圾,直接扔掉,但留下一地碎片 |
| 复制 | 活的搬到新家,旧家整个炸掉,干净但浪费 |
| 标记-整理 | 活的都往一边挤,再清理另一边,整洁但费劲 |
记住:没有最好的算法,只有最合适的场景。
- 新生代对象死得快 → 复制算法(活着的少,复制不费劲)
- 老年代对象活得久 → 标记-整理(不想浪费空间)