一个 Vec 在内存里到底长什么样:从真实地址看 move 为什么不要钱
写 Rust 的时候,你是不是也这样:函数要传个 Vec,心里一犹豫------直接传会不会把一大坨数据拷过去?保险起见,加个 & 吧。久而久之,代码里到处是 &,该不该加全凭"感觉安全"。
这篇想用真实内存地址说清一件事:大多数时候,那个 & 是你脑补出来的成本。把 Vec 直接 move 给别人,跟它装 3 个还是一百万个元素毫无关系,永远只是拷 24 个字节。
本文所有地址都是下面这段程序真跑出来的,复制即可复现(rustc main.rs && ./main,或丢进 cargo 项目 cargo run):
rust
fn show<T>(name: &str, v: &Vec<T>) {
println!(
"{name:<12} 栈上牌子@{:p} -> 堆数据@{:p} len={} cap={}",
v, // Vec 这块「牌子」自己在栈上的地址
v.as_ptr(), // ptr 字段指向的堆地址
v.len(),
v.capacity(),
);
}
fn main() {
// 牌子本身固定 24 字节(64 位机:ptr 8 + len 8 + cap 8)
println!("size_of::<Vec<i32>>() = {} 字节\n", std::mem::size_of::<Vec<i32>>());
// 第一幕:一个 Vec 就是栈上的牌子 + 堆上的数据
let mut v = vec![1, 2, 3];
show("vec![1,2,3]", &v);
// push 到 9 个元素(cap 会翻倍增长到 12,下一篇细讲)
for i in 4..=9 {
v.push(i);
}
println!();
// 第二幕:move ------ 栈上牌子换了地方,堆数据原地不动
show("move 前 v", &v);
let v2 = v; // v 被 move,此后再用 v 会编译报错
show("move 后 v2", &v2);
println!("↑ 堆地址完全一样:move 只拷了栈上那 24 字节牌子\n");
// 第三幕:clone ------ 另开一块新堆,逐个复制;注意 cap 按 len 收紧
let v3 = v2.clone();
show("原 v2", &v2); // v2 毫发无损
show("clone v3", &v3); // 新堆地址,且 cap=9 不是 12
println!("↑ clone 是另一块堆,cap 按 len 重新申请(9,不是 12)");
}
一句提醒:受地址随机化(ASLR)影响,你跑出来的具体数值 和本文不会一样,每次运行也都不同。但关系是恒定的------move 前后堆地址必然相同、clone 必然是另一块新地址、clone 后 cap 必然收紧。看关系,不看具体数字。
一、Vec 只是栈上 24 字节的牌子
先记住一句话:数据不在 Vec 里。
v 这个变量在栈上只占 24 个字节,里面就三样东西------指针 ptr 指向堆上真正存数据的地方,len 记当前有几个元素,cap 记这块堆最多能放几个。64 位机器上各 8 字节,加起来正好 24。你可以验证:size_of::<Vec<i32>>() 永远是 24,装 3 个是 24,装一百万个还是 24------因为元素根本不在这块牌子上,它们全在堆里那块连续内存中。栈上这东西更像一张牌子:本身不大,只写着"真货在哪、有多少、还能放多少"。
ini
let v = vec![1, 2, 3];
栈 (stack) 堆 (heap)
┌────────────────────────┐
│ v : Vec<i32> │
│ ┌──────┬────────────┐ │
│ │ ptr │0x10271dbb0 │─┼──────┐
│ ├──────┼────────────┤ │ │
│ │ len │ 3 │ │ ▼ 0x10271dbb0
│ ├──────┼────────────┤ │ ┌──────┬──────┬──────┐
│ │ cap │ 3 │ │ │ 1 │ 2 │ 3 │
│ └──────┴────────────┘ │ └──────┴──────┴──────┘
│ 共 24 字节 @0x16ddf9f48│ 索引: [0] [1] [2]
└────────────────────────┘
栈上只有这块"牌子", 真正的元素全在堆上,
大小固定,不随元素增长 一块连续内存
顺带一个反直觉的细节:Vec::new() 的空 Vec 压根不碰堆。它的 cap 是 0,ptr 填的是个对齐用的假地址(我机器上打出来是 0x4,一个不可能真存数据的位置)。Vec 是懒的------你不往里放东西,它一字节堆内存都不申请。
二、move 干了什么,旧变量又去哪了
let v2 = v; 看着像"把 v 复制给 v2",但内存层面 Rust 几乎什么都没干:把那 24 字节牌子从 v 的栈地址(9f48)拷到 v2 的栈地址(9fe0),结束。
rust
let v2 = v; // move
栈 (stack) 堆 (heap)
┌──────────────────────────┐
│ v : Vec<i32> @9f48 │
│ ┌──────┬───────────────┐ │ ░ 字节还在原地,没被擦
│ │ ptr │ 0x10271e720 │ │ ░ 但编译器已把「v」这个名字
│ │ len │ 9 │ │ ░ 标记为失效 ------ 再用 v 直接编译报错
│ └──────┴───────────────┘ │ ░
│ ✗ 已失效 (moved) │
│ │ 0x10271e720
│ v2 : Vec<i32> @9fe0 │ ┌──┬──┬──┬──┬──┬──┬──┬──┬──┐
│ ┌──────┬───────────────┐ │ │1 │2 │3 │4 │5 │6 │7 │8 │9 │
│ │ ptr │ 0x10271e720 │─┼─────▶└──┴──┴──┴──┴──┴──┴──┴──┴──┘
│ │ len │ 9 │ │ 同一块堆!没有任何复制
│ │ cap │ 12 │ │
│ └──────┴───────────────┘ │
└──────────────────────────┘
move = 把 24 字节牌子从 9f48 拷到 9fe0,堆上 9 个元素没动,旧牌子原地作废。
堆上那 9 个元素一根毛没动。看地址就知道------move 前 v 指向 0x...e720,move 后 v2 还是 0x...e720,分毫不差。所以无论装 9 个还是九百万个,move 的代价都是固定的 24 字节,跟元素个数无关。这就是 move 不要钱的原因。
那旧的 v 去哪了?这是最容易讲错的地方。栈上那块旧牌子的字节其实还在原地,没人去擦它。变的是编译期:编译器把 v 这个名字标记成"已经被移走了",从此你再碰 v 就直接编译报错。运行时根本没有"清理 v"这个动作------所谓 move,是编译器在编译期把旧名字封印掉,而不是程序在运行时搬动或销毁了什么。于是堆数据只有 v2 一个主人、不会被释放两次,安全和零成本就这么同时拿到了。
三、那 clone 呢?
clone 老实多了,它是真复制。v2.clone() 在堆上另开一块全新内存(这次跑出来是 0x...dbb0,和 v2 的 0x...e720 是两个地方),把 9 个元素一个个拷过去,v2 自己毫发无损照样能用。这就是 move 和 clone 的全部差别:一个共用同一块堆、只拷 24 字节牌子,一个另开一块堆、逐个复制元素。你在性能上付不付那笔拷贝钱,就看你写的是 move 还是 clone。
go
let v3 = v2.clone();
栈 堆
┌────────────────────┐ 0x10271e720
│ v2 @9fe0 │ ┌──┬──┬──┬──┬──┬──┬──┬──┬──┐
│ ptr │ 0x10271e720 │────────▶│1 │2 │3 │4 │5 │6 │7 │8 │9 │ 原数据,v2 还可用
│ len │ 9 / cap │ 12 │ └──┴──┴──┴──┴──┴──┴──┴──┴──┘
└────────────────────┘
0x10271dbb0 ◀── 新的一块堆!
┌────────────────────┐ ┌──┬──┬──┬──┬──┬──┬──┬──┬──┐
│ v3 @a000 │ │1 │2 │3 │4 │5 │6 │7 │8 │9 │ 逐个复制出来的
│ ptr │ 0x10271dbb0 │────────▶└──┴──┴──┴──┴──┴──┴──┴──┴──┘
│ len │ 9 / cap │ 9 │ ▲ 注意 cap=9,不是 12
└────────────────────┘
move:拷 24 字节牌子,共用堆。 clone:另开一块堆,按 len 量体裁衣地复制。
最后留意一个藏在地址里的细节:clone 出来的 v3,cap 是 9,不是原来的 12。move 是把整块牌子原样搬走,连那多预留的 3 个空位一起带过去;clone 不一样,它看你现在实际有几个元素(len=9),就申请刚好够装 9 个的地,多余的预留不复制。move 照搬,clone 量体裁衣。
所以回到开头那个 &:很多时候你加它,不是因为 move 真的慢,是因为不知道 move 到底拷了什么。现在你知道了------move 一个 Vec 永远是拷 24 字节牌子的常数操作,跟元素个数无关;真正会逐个复制堆数据、可能让你掏性能的,是 clone。看清这两个,& 该不该加,你就不用靠"感觉"了。
那如果一直往一个 Vec 里 push、cap 被撑爆了会怎样?那块堆就装不下了,得重新找块更大的地方"搬家"------下一篇拆这个。