引用计数法(原理/优缺点)
引用计数法是什么
引用计数法(Reference Counting)是垃圾回收最直觉的思路:每个对象维护一个「被引用次数」的计数器。计数器为 0 时,对象就是垃圾。
工作原理
计数器维护规则
java
public class ReferenceCounting {
public static void main(String[] args) {
// 计数器为 0,创建对象
Object a = new Object(); // count = 1
Object b = a; // count = 2(b 也引用了 a 指向的对象)
Object c = a; // count = 3
b = null; // count = 2(b 不再引用)
c = null; // count = 1
a = null; // count = 0 → 对象可回收
}
}引用计数流程:
Object 创建 ──→ 计数器 = 1
│
├─ 引用计数 +1 ──→ 计数器 = N
│
└─ 引用失效 ──→ 计数器 -1
│
└── 计数器 = 0 ──→ 立即回收引用计数法的优点
1. 垃圾发现即回收
引用计数最大的优势:一旦计数器为 0,对象立即被回收,不需要等待 GC 暂停。
java
public class Immediate回收 {
public static void main(String[] args) {
// 方法结束时,局部变量出栈
// 引用失效,计数器 -1 = 0
// 对象立即被回收
{
byte[] data = new byte[1024 * 1024]; // 1MB
}
// data 引用失效,对象立即回收
// 内存立刻释放,不需要等待 GC
}
}2. 不需要 Stop-The-World
引用计数是增量式的——回收是分散的,不需要暂停整个程序。
java
public class NoSTW {
public static void main(String[] args) {
// 引用计数:每个对象的回收是独立的、增量的
// 不需要遍历整个堆,不需要暂停所有线程
}
}3. 缓存友好
引用计数法的缓存局部性很好——每次只处理引用关系,不需要扫描整个堆。
引用计数法的致命缺点
1. 无法处理循环引用
循环引用是引用计数法的阿喀琉斯之踵:
java
public class CycleReference {
public static void main(String[] args) {
// 创建两个对象,互相引用
Node a = new Node("A");
Node b = new Node("B");
a.next = b; // a 的引用计数 = 1(被 main 中的 a 引用)
b.next = a; // b 的引用计数 = 1(被 main 中的 b 引用)
// 此时:
// a 的计数器:main引用(1) + b.next引用(1) = 2
// b 的计数器:main引用(1) + a.next引用(1) = 2
// a 和 b 互相引用,形成循环
a = null; // a 计数器 = 1(只剩 b.next 引用)
b = null; // b 计数器 = 1(只剩 a.next 引用)
// 现在 a 和 b 都不被 GC Roots 引用了
// 但它们的计数器都是 1(互相引用)
// 引用计数法认为它们是「存活」的!
// → 内存泄漏!
}
static class Node {
String name;
Node next;
Node(String name) { this.name = name; }
}
}循环引用示意图:
GC Roots (main 的局部变量)
│
│ a
▼
┌───────────────┐
│ Node A │
│ count = 1 │
│ next ───────────┐
└───────────────┘ │
│ b.next
▼
┌───────────────┐
│ Node B │
│ count = 1 │
│ next ───────────┐
└───────────────┘ │
▲ │
└────────────┘ a.next
↑
│ b
GC Roots (main 的局部变量)
A 和 B 形成循环引用,都不被 GC Roots 引用
但引用计数认为它们各有 1 个引用,所以不回收
→ 内存泄漏!2. 计数器的更新开销
每次引用赋值都需要更新计数器:
java
public class CountingOverhead {
public static void main(String[] args) {
Object a = new Object();
Object b = new Object();
// 每次赋值都涉及计数器的增减
// 如果一个对象被频繁引用和取消引用
// 计数器更新会成为性能瓶颈
for (int i = 0; i < 1_000_000; i++) {
b = a; // a 的计数器 +1,b 原来引用的计数器 -1
}
}
}3. 原子性问题
在多线程环境下,计数器的增减需要原子操作(CAS 或锁),否则会出现计数错误:
java
public class AtomicIssue {
public static void main(String[] args) {
// 两个线程同时更新计数器
// 如果没有原子性保护:
// 线程 A: count++ (read=5, write=6)
// 线程 B: count++ (read=5, write=6)
// 结果: count=6,正确应该是 7
// 原子性保证带来额外的同步开销
}
}JVM 为什么不使用引用计数法
正是因为循环引用这个致命缺陷,JVM 使用的是追踪式 GC(可达性分析),而不是引用计数。
| 维度 | 引用计数 | 追踪式 GC(JVM) |
|---|---|---|
| 循环引用 | ❌ 无法处理 | ✅ 正常处理 |
| 回收时机 | ✅ 立即回收 | ❌ 需要 GC 暂停 |
| STW | ✅ 无 | ❌ 有 |
| 实现复杂度 | 简单 | 复杂 |
| 计数器开销 | 高(每次引用变化都更新) | 无额外计数器开销 |
| 被引用者 | 无法回收(谁在引用我?) | 可正常回收 |
引用计数法的应用场景
虽然 JVM 整体不使用引用计数法,但引用计数的思想在其他地方有应用:
Python 的垃圾回收
Python 底层使用引用计数(实现对象的即时回收),但同时引入了标记-清除和分代回收来处理循环引用。
智能指针(Rust / C++)
C++ 的 std::shared_ptr 底层使用引用计数,但同样无法自动处理循环引用——需要配合 weak_ptr 使用。
缓存淘汰策略
Redis、Guava Cache 等缓存框架使用引用计数(LRU)来决定淘汰哪些缓存条目。
本节小结
引用计数法的核心要点:
| 关键点 | 说明 |
|---|---|
| 原理 | 每个对象维护引用计数器,为 0 即回收 |
| 优点 | 即时回收、无 STW、缓存友好 |
| 致命缺点 | 无法处理循环引用 |
| JVM 使用 | 不使用(使用可达性分析) |
| 应用场景 | Python 底层、shared_ptr、缓存淘汰 |
理解引用计数法的缺陷,是理解 JVM 为什么选择追踪式 GC 的关键。
下一节,我们来看 可达性分析(GC Roots/finalization 机制)。
