一个 Vec 在内存里到底长什么样:从真实地址看 move 为什么不要钱

一个 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,和 v20x...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 出来的 v3cap 是 9,不是原来的 12。move 是把整块牌子原样搬走,连那多预留的 3 个空位一起带过去;clone 不一样,它看你现在实际有几个元素(len=9),就申请刚好够装 9 个的地,多余的预留不复制。move 照搬,clone 量体裁衣。

所以回到开头那个 &:很多时候你加它,不是因为 move 真的慢,是因为不知道 move 到底拷了什么。现在你知道了------move 一个 Vec 永远是拷 24 字节牌子的常数操作,跟元素个数无关;真正会逐个复制堆数据、可能让你掏性能的,是 clone。看清这两个,& 该不该加,你就不用靠"感觉"了。

那如果一直往一个 Vec 里 push、cap 被撑爆了会怎样?那块堆就装不下了,得重新找块更大的地方"搬家"------下一篇拆这个。

相关推荐
特立独行的猫a6 小时前
鸿蒙 PC 移植记:将微软的 `edit` 轻量级终端编辑器带到 OpenHarmony
microsoft·rust·编辑器·harmonyos·鸿蒙pc·edit
@小匠6 小时前
WebDAV 同步踩坑实录:从 405 到数据恢复不生效的完整排查
rust
爱学习的鱼佬7 小时前
告别内网模型接入烦恼!ModelStandardization:让 Open WebUI等工具无缝对接私有大模型
rust·开源·大模型·openai·openwebui·model api代理·内网部署
Rust研习社21 小时前
90% 的 Rust 新手都不知道的 3 个实用开发技巧
后端·rust·编程语言
析数塔1 天前
编译两分钟,修改五秒钟:Zig构建系统重构解决的老问题
程序员·rust
Kapaseker1 天前
Rust 是如何干掉空指针的
rust·kotlin
特立独行的猫a1 天前
OHOS (OpenHarmony) 鸿蒙的Rust 交叉编译环境搭建指南
华为·rust·harmonyos·鸿蒙pc
Rust研习社1 天前
从 LaunchBadge 到 transact-rs:SQLx 社区迈出可持续治理的第一步
开发语言·后端·rust
techdashen2 天前
你想在 Rust 中实现动态库热重载?
开发语言·chrome·rust