Skip to content

垃圾回收算法

这篇文章用大白话讲清楚 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 标记-清除算法

原理:分两步走

  1. 标记:先把所有垃圾标记出来
  2. 清除:然后把标记的垃圾清掉
清理前:
┌───┬───┬───┬───┬───┬───┬───┬───┐
│ 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. 一句话总结

算法一句话
标记-清除标记垃圾,直接扔掉,但留下一地碎片
复制活的搬到新家,旧家整个炸掉,干净但浪费
标记-整理活的都往一边挤,再清理另一边,整洁但费劲

记住:没有最好的算法,只有最合适的场景。

  • 新生代对象死得快 → 复制算法(活着的少,复制不费劲)
  • 老年代对象活得久 → 标记-整理(不想浪费空间)