跳转至

内存碎片与整理方式

内存碎片是对象随机分配/释放后产生的空洞,主流 GC 用标记-清除、标记-压缩、复制算法三种方式应对。


什么是内存碎片

程序运行时,内存就像一排格子,每次 new 对象就占几个格子。释放后留下空洞,如果空洞分散,就会出现**总空闲空间够用,但放不下一个大对象**的情况。

碎片产生过程

初始分配:

┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │A │B │B │C │C │C │D │D │D │E │E │F │F │F │G │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘

释放 B、D、F 后:

┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │A │░░│░░│C │C │C │░░│░░│░░│E │E │░░│░░│░░│G │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
 ░░ = 空闲碎片

总空闲 8 格,但没有一块连续。想分配一个 4 格的对象?放不下。 这就是内存碎片。


三种主流整理方式

1. 标记-清除(Mark-Sweep)

不做整理,只标记死对象然后就地释放。

graph LR
    A[标记阶段] -->|从 GC Root 遍历| B[标记所有存活对象]
    B --> C[清除阶段]
    C -->|释放未标记对象| D[就地释放]
    D --> E[产生碎片]

流程演示:

标记阶段:标记哪些还活着
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │A │B │B │C │C │C │D │D │D │E │E │F │F │F │G │
│✓ │✓ │  │  │✓ │✓ │✓ │  │  │  │✓ │✓ │  │  │  │✓ │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘

清除后:
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │A │░░│░░│C │C │C │░░│░░│░░│E │E │░░│░░│░░│G │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘

✅ 速度最快,只扫描一遍 ❌ 碎片原封不动,碎片越来越多

2. 标记-压缩(Mark-Compact)

标记后把所有活对象**挤到一端**,消除空洞。

graph LR
    A[标记阶段] --> B[标记存活对象]
    B --> C[压缩阶段]
    C -->|活对象向一端移动| D[消除碎片]
    D --> E[更新引用]

流程演示:

标记完成:
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │A │░░│░░│C │C │C │░░│░░│░░│E │E │░░│░░│░░│G │
│✓ │✓ │   │   │✓ │✓ │✓ │   │   │   │✓ │✓ │   │   │✓ │
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘

压缩 — 活对象向左移动:
  A → 移到 [0]
  C → 紧跟 A 放到 [2]
  E → 紧跟 C 放到 [5]
  G → 紧跟 E 放到 [7]

整理后:
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│A │A │C │C │C │E │E │G │░░│░░│░░│░░│░░│░░│░░│░░│
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴──┘
 已用(紧凑)              空闲(连续一大块)

✅ 碎片彻底消除 ❌ 要移动对象,更新所有指向它的引用,暂停时间长

3. 复制算法(Copying)

把内存切成两半(From / To),每次只用一半,GC 时把活对象复制到另一半。

graph LR
    A[From 区使用中] --> B[GC 触发]
    B --> C[扫描存活对象]
    C --> D[复制到 To 区]
    D --> E[From/To 角色互换]
    E --> A

流程演示:

From 区(当前使用)          To 区(空闲备用)
┌──┬──┬──┬──┬──┬──┬──┐    ┌──┬──┬──┬──┬──┬──┬──┐
│A │A │░░│C │C │░░│E │    │  │  │  │  │  │  │  │
│✓ │✓ │   │✓ │✓ │   │✓ │    │  │  │  │  │  │  │  │
└──┴──┴──┴──┴──┴──┴──┘    └──┴──┴──┴──┴──┴──┴──┘

GC 时,把活对象按顺序复制到 To 区:

From 区(废弃)              To 区(新的活跃区)
┌──┬──┬──┬──┬──┬──┬──┐    ┌──┬──┬──┬──┬──┬──┬──┐
│A │A │░░│C │C │░░│E │    │A │A │C │C │E │░░│░░│
└──┴──┴──┴──┴──┴──┴──┘    └──┴──┴──┴──┴──┴──┴──┘
   ↑ 全部作废                   紧凑排列,后面全是空闲

✅ 分配极快(指针一推就行),天然无碎片 ❌ 浪费一半内存,存活对象多时复制成本高


总结对比

算法 速度 碎片 内存代价 适用场景
标记-清除 ⚡ 快 ❌ 有碎片 无额外 CMS 老年代
标记-压缩 🐢 慢 ✅ 无碎片 无额外 G1 老年代
复制算法 ⚡ 快 ✅ 无碎片 浪费 50% 年轻代(死亡率高)

实际的 GC 实现通常**混合使用**——比如 Java 的分代 GC:年轻代用复制算法(对象死亡率高,复制量小),老年代用标记-压缩(对象存活久,复制成本太高)。

练习题

题目一:为什么复制算法通常只用于年轻代?
答案

因为研究表明绝大多数对象都是"朝生夕灭"的,年轻代中每次 Minor GC 后存活对象通常不到 10%。复制算法只需要复制极少量存活对象,效率很高。而老年代对象存活率高,复制成本太大,所以用标记-压缩。

题目二:标记-清除为什么会导致碎片越来越多?
答案

标记-清除只是就地释放死对象的内存,不会移动任何活对象。随着程序不断分配和释放,空洞越来越分散,连续空闲空间越来越小。大对象可能因为找不到连续空间而触发 Full GC 甚至 OOM。