Rust Vec 的真实内存布局、扩容与 ZST
前面几节攒下的 MaybeUninit<T>、Drop、ptr::read、ptr::write、set_len(),最后都会在 Vec 的实现里汇合。这篇就把它们对起来看。
还有一个我最初没想明白的问题:
如果一个类型占 0 字节,
Vec<T>还需要分配内存吗?如果不需要,它怎么表示自己拥有 100 个这样的元素?
参考:Vec<T>、Rustonomicon: Exotic Sizes。
一、先建立 Vec 的内存模型
let mut values: Vec<u32> = Vec::with_capacity(8);
values.push(10);
values.push(20);
values.push(30);values.len() // 3
values.capacity() // 至少 8
概念上的布局:
Vec<u32> 这个对象
┌─────────────────────┐
│ ptr │ ─────────────┐
│ len = 3 │ │
│ capacity >= 8 │ │
└─────────────────────┘ │
▼
堆内存
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ ?? │ ?? │ ?? │ ?? │ ?? │
└────┴────┴────┴────┴────┴────┴────┴────┘
└─────────┘
len = 3
└───────────────────────────────────┘
capacity >= 8
注意那些 ??。
这些位置属于分配出来的存储空间,但不一定包含已经初始化的 u32。这就是前几节反复强调的区别:
- 容量描述可用存储空间;
- 长度描述已经初始化、由容器拥有的元素。
Vec<T> 必须维护这个不变量:
0 ≤ len ≤ capacity
并且对普通的非零大小类型,[0, len) 中每个元素都必须是有效的 T。
这不是可有可无的约定,而是 Vec 能正确访问和析构元素的基础。
1.1 Vec 对象本身不等于元素数组
let values = vec![10u32, 20, 30];
values 这个变量保存的是容器的管理信息,元素通常在另一块单独分配的内存里。
Vec<T> 的管理对象
≠
Vec<T> 所拥有的元素存储空间
所以移动一个 Vec<T>,通常不需要把所有元素逐个复制到新地址——搬的只是那三个字的头。
二、RawVec:只管理内存,不负责元素生命周期
理解 Vec 的实现,可以从职责划分入手。概念上它分两层:
┌────────────────────────────┐
│ Vec<T> │
│ │
│ len │
│ 元素初始化状态 │
│ 元素所有权与析构 │
└─────────────┬──────────────┘
│
▼
┌────────────────────────────┐
│ RawVec<T> │
│ │
│ 原始内存指针 │
│ 容量管理 │
│ 分配与重新分配 │
│ 释放存储空间 │
└────────────────────────────┘
这是概念模型。具体字段和内部辅助类型会随 Rust 版本变化,别把这张图当成当前源码的逐字结构。
两层关心的东西不一样:
RawVec 只管:内存在哪、分配了多少容量、要不要扩容、怎么重新分配、怎么释放。
Vec 还要管:哪些位置已初始化、当前拥有几个元素、哪些要析构、怎么安全地增删和移动元素。
为什么要拆开?因为分配了一段内存,不代表里面已经存在对应数量的 T 对象。
let mut values: Vec<String> = Vec::with_capacity(100);
这行不会创建 100 个 String。它创建的是一个 len = 0、capacity >= 100 的容器。底层空间是有了,但没有 100 个 String 等着被析构。
这就是内存分配层和元素生命周期管理层必须协同工作的原因。
三、扩容时究竟发生了什么
let mut values = Vec::with_capacity(4);
values.push(10);
values.push(20);
values.push(30);
values.push(40);
此时 capacity = 4、len = 4。再 push(50),容器得腾出第 5 个位置:
原有内存
┌────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ 40 │
└────┴────┴────┴────┘
capacity = 4
len = 4
│
▼
申请更大空间
新内存
┌────┬────┬────┬────┬────┬────┬────┬────┐
│ 10 │ 20 │ 30 │ 40 │ ?? │ ?? │ ?? │ ?? │
└────┴────┴────┴────┴────┴────┴────┴────┘
capacity >= 5
这是逻辑结果,不意味着分配器一定会把数据搬到另一个地址。重新分配可能原地扩展,也可能整个搬走,取决于分配器、布局和实现策略。
如果要搬,原来的元素必须在新位置继续保持有效,并且不能在搬运过程中被重复析构。
3.1 Rust 的「移动」不一定逐字节复制
let a = String::from("hello");
let b = a;
执行后 a 不再拥有这个 String,所有权给了 b。
从语义上说这是一次移动。但对 String 这种类型,Rust 通常只要搬指针、长度、容量这几个字,不需要复制全部字符。
Vec 扩容时的情形又不一样:如果底层分配要移动,容器得保证原有元素被正确搬到新地址,而且对带析构函数的类型,不能留下两份都被认为有效的所有者。
旧地址:元素原本所在的位置
│
│ 受控地转移元素
▼
新地址:元素后续所在的位置
原来的位置不能再被当作仍然拥有该对象的有效元素。
真实的实现会按具体操作选不同策略,不能假定所有扩容都走同一套逐元素复制或移动算法。
四、为什么扩容用几何增长
如果每次加元素只把容量加 1,插 1000 个元素会怎样?
容量 0 → 1
容量 1 → 2
容量 2 → 3
...
容量 999 → 1000
每次扩容都要重新分配并搬移现有元素,总搬移量是:
1 + 2 + 3 + ... + (n-1) = n(n-1)/2
也就是 O(n²)。这对大量插入来说完全不能接受。
几何增长(比如按大于 1 的比例翻倍)就不同:
4 → 8 → 16 → 32 → 64 → ...
所有扩容中搬移的元素总数可以控制在 O(n)。于是连续 n 次 push() 的总成本是 O(n),单次 push() 的摊销复杂度是 O(1)。
要注意两点:
- 摊销不等于每次都常数时间。触发扩容那一次仍然要搬一大批元素。
- 具体倍率不是稳定 API 保证,会随标准库版本和实现变化。
真正该记住的是:几何增长在额外空间和重新分配成本之间取了不错的平衡。
五、重新分配失败怎么办
len = 4
capacity = 4
要给第 5 个元素腾地方,但新分配失败了,合理的容器行为是:
- 原来的 4 个元素仍然有效;
- 原来的所有权关系不能被破坏;
- 不得析构未初始化的内存;
- 不得释放仍由容器拥有的有效元素;
- 失败不能让容器进入不一致状态。
这正好对应上一节的异常安全原则。
原有容器
│
▼
申请新的存储空间
│
├── 失败
│ │
│ ▼
│ 保留原有状态
│
└── 成功
│
▼
转移已有元素
│
▼
更新指针与容量
│
▼
释放旧存储空间
这是逻辑流程,不代表所有路径都严格这么分步。
还有个现实细节:某些分配器接口在失败时会直接触发分配错误处理,而不是返回 Result。所以别指望所有 Vec 扩容失败都像普通业务错误一样被 catch 住。
自己设计容器或 FFI 缓冲区时,就得明确回答:
- 分配失败返回错误还是 panic?
- 保留原有数据吗?
- 允许部分完成吗?
- 需不需要强异常保证?
这些都属于 API 设计的一部分。
六、最特殊的情况:零大小类型
前面聊的 u32、String 都占空间,但 Rust 允许定义零大小类型:
struct Marker;
struct Nothing {
marker: (),
}
它们的大小都是 0:
use std::mem::size_of;
fn main() {
println!("size_of::<()>() = {}", size_of::<()>());
println!("size_of::<Marker>() = {}", size_of::<Marker>());
println!("size_of::<Nothing>() = {}", size_of::<Nothing>());
}size_of::<()>() = 0
size_of::<Marker>() = 0
size_of::<Nothing>() = 0
编译器不需要为每个值保留独立的有效载荷字节。
于是问题来了:
let mut values: Vec<Marker> = Vec::new();
values.push(Marker);
values.push(Marker);
values.push(Marker);len = 3
三个 Marker 不占三个独立的元素存储区域,那容器怎么表示自己拥有三个元素?
答案:靠长度这类元数据表达元素数量,而不是靠元素占了多少字节。
len 表示元素数量,不是有效载荷字节数。这和前面建立的内存模型完全一致。
七、ZST 的 Vec 为什么不需要元素分配
use std::mem::size_of;
struct Marker;
fn main() {
println!("Marker size = {}", size_of::<Marker>());
let mut values = Vec::new();
values.push(Marker);
values.push(Marker);
values.push(Marker);
println!("len = {}", values.len());
println!("capacity = {}", values.capacity());
}
注意这里的类型推导:因为后面插入了 Marker,编译器推出 Vec<Marker>。
标准库对 ZST 用了特殊路径。我在本机实测(rustc 1.99):
Marker size = 0
len = 3
capacity = 18446744073709551615
capacity 是 usize::MAX。这很合理:零大小元素不需要实际存储空间,容量上限就只剩 usize 能表示多少的问题。
不过 ZST 的具体内部表示属于实现细节,写代码时应该依赖 Vec 的公开 API,而不是去推断内部指针值。
7.1 ZST 不等于没有生命周期
这点特别容易误解。
use std::sync::atomic::{AtomicUsize, Ordering};
static DROPS: AtomicUsize = AtomicUsize::new(0);
struct Marker;
impl Drop for Marker {
fn drop(&mut self) {
DROPS.fetch_add(1, Ordering::SeqCst);
}
}
Marker 不占有效载荷字节,但它有 Drop 行为:
let mut values = Vec::new();
values.push(Marker);
values.push(Marker);
values.push(Marker);
drop(values);
三个元素仍然要按容器的生命周期语义逐个析构。我实测打印了 3 次 drop Marker。
所以 ZST 的特殊性只在于:
- 每个元素不需要独立的有效载荷存储;
- 容器仍然要维护元素数量;
- 元素仍然可能有析构行为;
- 所有权与生命周期规则仍然生效。
零大小 ≠ 零生命周期,也不等于零析构成本。
八、ZST 为什么让指针运算变特殊
struct Marker;
因为 size_of::<Marker>() == 0,如果按普通数组的字节布局算地址:
第 0 个元素的偏移量 = 0 × 0 = 0
第 1 个元素的偏移量 = 1 × 0 = 0
第 2 个元素的偏移量 = 2 × 0 = 0
所有偏移量都是 0。
ptr.add(0)
ptr.add(1)
ptr.add(2)
对 ZST,不能期待这几个指针像普通数组那样对应不同字节地址。
但这不影响 Vec<Marker> 表示多个元素——Rust 的元素数量语义本来就不要求每个零大小值占据不同的字节。
同时要注意:不能把「元素大小为 0」理解成「指针运算可以随便做」。原始指针操作仍然必须满足 API 的安全契约。
8.1 一个常见的错误设计
假设自己写容器:
struct MyVec<T> {
ptr: *mut T,
len: usize,
capacity: usize,
}
push 里用:
let destination = self.ptr.add(self.len);
对普通类型这符合直觉:索引对应不同字节偏移。但对 ZST:
size_of::<T>() == 0
按字节算的偏移量不随索引增长,这套逻辑就失效了。
容量管理也有问题:如果每个元素大小为 0,用「总分配字节数 ÷ 元素大小」来算容量会直接除零。
这就是标准库必须给 ZST 走特殊路径,而不能套用普通分配公式的原因。
九、ZST 与 Drop Check
把这一节和之前的 PhantomData<T> 接上:
use std::marker::PhantomData;
struct MyHandle<T> {
raw: *mut u8,
marker: PhantomData<T>,
}
PhantomData<T> 本身不占空间:
size_of::<PhantomData<String>>() == 0
但它仍然表达类型系统中的关系。即使不占内存,它也会影响型变和 auto trait 推导。
这里要分清两件事:
PhantomData<T>能告诉编译器类型之间存在什么关系;- 它不能凭空证明原始指针有效,也不能保证外部资源还在。
如果 raw 指向 C 分配的对象,那么何时释放、谁负责释放、能否跨线程使用,仍然得由实现和安全契约保证。
这和 ZST 的核心思想一致:类型的内存大小,与它在类型系统中表达的语义,不是一个概念。
十、把 ZST、容量与 Drop 放一起
struct Marker;
impl Drop for Marker {
fn drop(&mut self) {
println!("drop Marker");
}
}
fn main() {
let mut values = Vec::new();
for _ in 0..3 {
values.push(Marker);
}
drop(values);
}
即使 Marker 大小为 0,容器仍然必须知道「我拥有三个元素」,否则没法正确析构。
所以 Vec 的核心不变量不能简化成:
len = 已经分配的字节数 ❌
正确的是:
len = 已初始化、由容器拥有的元素数量
普通类型下,元素存储空间大约是:
capacity × size_of::<T>()
但 ZST 时 size_of::<T>() = 0,这个乘法结果是 0,而不能据此推断容器没有任何逻辑元素。
长度、所有权、析构语义一样都不能少。
十一、一张表收尾
| 概念 | 解决的问题 | 关键注意 |
|---|---|---|
Vec<T> | 管理一组拥有的元素 | len 与 capacity 含义不同 |
RawVec<T> 这类原始存储管理 | 管分配与容量 | 分配内存 ≠ 初始化元素 |
| 扩容 | 为更多元素腾空间 | 可能原地扩展,也可能搬移 |
| 几何增长 | 降低连续追加的摊销成本 | 具体倍率不是稳定 API 保证 |
| 分配失败 | 不破坏原有状态 | 必须遵守具体分配 API 的失败语义 |
| ZST | 表示不占有效载荷字节的类型 | 仍有类型语义与生命周期 |
ZST 的 Vec<T> | 维护零大小元素的数量与所有权 | 不能按普通分配模型推理 |
PhantomData<T> | 表达类型系统关系 | 不会自动验证原始指针 |
Drop | 清理对象持有的资源 | ZST 也可能实现 Drop |
十二、我拿这套东西检查什么
学完这些,我给自己列了几个固定的审查点。
1. 长度与初始化状态是否一致
unsafe {
vec.set_len(new_len);
}
我会问:new_len 超过容量了吗?新纳入范围的元素真的初始化了吗?有没有控制流分支跳过初始化?有没有从外部函数返回、但初始化范围没被证明的情况?
2. 原始指针读取有没有破坏所有权
let value = ptr.read();
要确认:地址指向的值有效吗?这个位置是不是还被别的所有者管着?后面会不会再读一次或析构原对象?这次读取在正确的生命周期内吗?
3. 扩容期间对象状态
旧分配什么时候还有效?新分配何时接管元素?元素搬走后旧位置是否还被当作有效对象?中途失败哪些对象要清理?
4. ZST 有没有被特殊照顾
不能直接套用:
元素索引 × 元素大小 = 字节偏移量
然后假设每个元素都有独立存储区域。ZST 得单独处理指针运算、容量和元素生命周期。
这套检查的目的不是机械地给 unsafe 打标记,而是看代码有没有真正维护背后的不变量。
十三、三道自测题
题一:移动 Vec
let a = vec![String::from("hello")];
let b = a;
为什么这里通常不需要把字符串的字符重新复制一遍?同时为什么 a 不能再用了?
题二:ZST
struct Marker;
let mut values = Vec::new();
values.push(Marker);
values.push(Marker);
values.push(Marker);
Marker 大小为 0,为什么 values.len() 还是 3?如果 Marker 实现了 Drop,销毁这个容器时还要处理什么?
题三:扩容与异常安全
自己实现 Vec<T>。容量不足时已经申请了新内存,但只成功转移了部分元素就失败了。要维护哪些状态,才能保证:已转移的元素不被重复析构、未转移的元素不丢所有权、新旧内存不被错误释放、容器不会把未初始化位置当成有效元素?
下一篇写 Pin<T>:为什么某些对象一旦放到特定地址就不能再随意移动,以及这对异步 Future、FFI 回调和底层运行时意味着什么。