Skip to content

引用计数法(原理/优缺点)

引用计数法是什么

引用计数法(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 机制)

基于 VitePress 构建