内存碎片与整理方式¶
内存碎片是对象随机分配/释放后产生的空洞,主流 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。