适用读者:零基础学习者。
核心目标:彻底理解计算机内存的真实物理布局(栈与堆),掌握 Rust 享誉世界的“所有权”心智模型,搞懂变量在内存中的生命轮回。
1. 物理内存的实相:为什么需要分为“栈”与“堆”?
在阶段零中我们知道,内存就像一排排排满储物格子的仓库。但在现代计算机操作系统中,为了平衡运行速度与灵活性,内存被清晰地划分成两种完全不同的工作区域:栈(Stack) 与 堆(Heap)。
┌──────────────────────────────────────────────────────────────────┐
│ 进程内存空间 │
│ │
│ ┌───────────────────────────┐ ┌───────────────────────┐ │
│ │ 栈内存 (Stack) │ │ 堆内存 (Heap) │ │
│ ├───────────────────────────┤ ├───────────────────────┤ │
│ │ 【整齐严密的餐盘架】 │ │ 【自由申请的大型货场】 │ │
│ │ │ │ │ │
│ │ • 速度极快 (指针上下移动) │ │ • 速度较慢 (需寻址) │ │
│ │ • 编译期大小必须固定 │ │ • 空间巨大、动态伸缩 │ │
│ │ • 进出严格后进先出 (LIFO)│ │ • 返回门牌号 (指针) │ │
│ └───────────────────────────┘ └───────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘1.1 栈(Stack):整齐紧凑的“餐盘架”
- 生活模型:想象食堂里的弹簧餐盘架。洗好的盘子一个个往上压叠,取用时只能从最顶上拿走最后一个放上去的盘子。这种先进后出、后进先出的结构,在计算机中称为 LIFO(Last In, First Out)。
- 决定性特征:
- 大小必须绝对固定且已知:放进栈里的每一个数据,在代码还没运行前(编译期),编译器就必须确切知道它占几个字节。
- 速度登峰造极:CPU 不需要任何复杂的寻找操作,它只需要把栈指针向下或向上拨动几个刻度,数据就存好了或取出了。
- 存放内容:整数(
i32占 4 字节)、浮点数、布尔值(1 字节)以及指向外部的门牌号(指针)。
1.2 堆(Heap):自由申请的“大型货架仓库”
- 为什么需要堆?
假设你正在开发一个用户注册系统,用户输入的名字可能是 2 个字,也可能是 20 个字,甚至可能是一本几万字的小说。你在写代码时根本不可能提前预知它的大小。这种动态增长、大小未知的数据,严禁存入紧凑的栈中。
- 生活模型:堆就像一个需要登记入驻的巨大集装箱货场。
- 程序向操作系统前台登记:“请在货场给我划一块 100 字节大小的空地”;
- 操作系统在广袤的堆内存里四处搜寻,找到一块足够大的空闲区域;
- 操作系统登记该区域,并将这块区域的首地址(门牌号,即指针 Pointer)交回给你。
- 你把这个小小的“门牌号”(大小是固定的 8 字节)揣在随身的栈内存里,而真正庞大臃肿的数据则安放在远处的堆内存中。
2. 堆内存管理的“历史死结”:谁来打扫垃圾?
堆内存空间极其宝贵,用完之后必须及时归还给操作系统。但数十年来,其他编程语言陷入了两难境地:
- 手动管理模式(如 C 语言)——“谁借谁还,全靠自律”:
- 规定程序员在申请堆内存后,用完必须亲自写一行释放指令。
- 人类天生是健忘的:
- 忘记释放 内存泄漏(Memory Leak):电脑内存越耗越空,最后系统卡死;
- 释放了两次 双重释放(Double Free):系统内存严重混乱,黑客趁虚而入;
- 内存释放了,但手里的门牌号忘了扔 野指针(Dangling Pointer):凭着旧门牌号去非法读取别人新入住的数据,引发系统闪退。
- 垃圾回收模式(如 Java / Go / Python)——“专职保洁,强制停工”:
- 不让程序员操心,后台派一个保洁员定期巡逻,看哪个堆对象没人用了就回收。
- 高昂代价:保洁员巡逻需要占用很多 CPU 和额外内存;在特定扫描阶段,整个系统所有工作必须全部强行挂起(Stop-The-World),导致工业控制和游戏产生掉帧卡顿。
3. Rust 的革命突破:所有权三大定律(Ownership Rules)
Rust 既不信任粗心的人类,也不愿忍受保洁阿姨的昂贵打扰。它确立了三条如同物理定律般铁面无私的规则:
👑 所有权三大定律
1. Rust 中的每一个值,都有一个被称为其“所有者(Owner)”的变量。
2. 在任意时刻,每个值有且仅能有一个所有者。
3. 当所有者变量离开其所在的作用域大括号
{}时,该值占用的内存将被立即自动清扫归还。
3.1 作用域清扫机制:人走茶凉(RAII)
{ // s 在这里还不存在
let s = String::from("hello"); // 从这里开始,s 诞生在栈上,堆上分配了 "hello",s 成为这片堆内存的唯一所有者
// 尽情使用 s
} // 走到这里,作用域围墙结束!
// Rust 在编译时在此处自动插入代码,调用 drop() 销毁 s,堆内存瞬间被自动归还操作系统!你不需要写任何释放内存的代码,编译器通过精准的作用域分析,把内存释放的时机完全固化在了编译后的机器指令中。这在计算机工业中被称为 RAII(Resource Acquisition Is Initialization,资源获取即初始化)。
4. 数据的传递方式:复制、移动与克隆
4.1 栈上数据的轻松复制:Copy 特征
对于完全保存在栈上的简单数据(如整数、浮点数),因为它们体积微小,复制一份的物理代价极低:
let x = 5;
let y = x; // 栈上直接快速复制了一份新的 5 存进 y
// x 和 y 此时均完全有效且互不影响4.2 堆上数据的“产权过户”:移动语义(Move)
如果是堆内存数据,事情将发生颠覆性的改变:
let s1 = String::from("hello");
let s2 = s1; // 发生所有权转移 (Move)!
// println!("{}", s1); // 编译报错!s1 已经被彻底废弃,无法使用!房产证过户类比
- 堆上的数据是一套真正的实体房屋;
- 栈上的
s1是写着房屋物理地址的房产证原件; - 当你执行
let s2 = s1;时,你将房产证亲手过户移交给了s2。此时房子依然只有一套,但唯一合法的产权人变成了s2。
[栈 Stack] [堆 Heap]
s1 (已作废失效 ❌)
s2 (有效所有者 ✅) ──指针指向──> [ 'h', 'e', 'l', 'l', 'o' ]为什么 s1 必须被立即废弃?
设想反证:如果允许 s1 和 s2 同时有效。当代码运行到大括号结尾时,按照定律 3,s1 会去叫拆迁队释放一次这片堆内存,紧接着 s2 又会带着拆迁队去释放同一片内存!这正是臭名昭著的双重释放崩溃。
因此,Rust 强制规定:所有权过户的那一刻起,原所有者 s1 立即死亡,彻底扼杀隐患于萌芽之中。
4.3 显式深拷贝:克隆(Clone)
如果你真的需要一份完完全全独立的堆数据副本,你必须显式调用 .clone():
let s1 = String::from("hello");
let s2 = s1.clone(); // 显式向堆重新申请一片新内存,两套房子各自独立
// s1 和 s2 此时各自拥有独立的房屋产权5. 借用与引用:只借不给产权的生活智慧
如果每次把数据传给函数或交给别人算一算,都必须发生所有权转移(Move),那写代码将极其痛苦(每次传出去后还得想办法从返回值里把数据再移交回来)。
在现实生活中,朋友想借你的书读一读,你绝不需要把整本书的产权永久赠送给他,你可以借给他翻阅几天,阅后归还。在 Rust 中,这被称为借用(Borrowing),借出的凭据被称为引用(Reference)。
5.1 不可变引用(&T):只读借阅证
在变量名前加上取地址符 &,代表创建对该值的引用。你只借出了访问权,原所有权依然牢牢掌握在主人手中:
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1); // 借出只读引用 &s1
println!("'{}' 的长度是 {}", s1, len); // s1 依然完好可用!
}
fn calculate_length(s: &String) -> usize {
s.len() // s 只是一个借阅证,它不拥有底层内存
} // s 离开作用域,因为它不是所有者,所以绝对不会触发内存销毁动作!5.2 可变引用(&mut T):允许涂改的修改借条
如果借用人不仅想看,还想在上面增删内容,可以使用 &mut(可变引用):
fn main() {
let mut s = String::from("hello");
change(&mut s); // 借出可变借条
println!("{}", s); // 输出: hello, world
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}6. 借用检查器的至高天条:读写排他铁律
为了从数学逻辑上消灭并发多线程与复杂代码中的数据竞争,Rust 编译器的“借用检查器(Borrow Checker)”强制推行一条核心准则:
⚡ 读写排他铁律 (Aliasing XOR Mutability)
对于同一块数据,在任意给定的同一作用域范围内:
- 要么允许无数个人同时拥有只读引用(
&T);- 要么只允许一个人独占拥有可变修改引用(
&mut T);- 绝不允许只读引用与可变修改引用同时并存!
┌──────────────────────────────────────────────────┐
│ 读写排他状态机 (XOR) │
│ │
│ 【多读者模式】 【单写者独占模式】 │
│ &T, &T, &T... (全员只读) OR &mut T (唯一独占) │
│ │
│ ⚡ 绝对禁止两者交叉并存 ⚡ │
└──────────────────────────────────────────────────┘为什么必须读写排他?
生活常识反证法:
假设黑板上写着一份航班计划。如果有 5 位旅客正在同时聚精会神地抄录这份航班表(只读引用);突然跑来一个工作人员,一边拿抹布把黑板上的字全部擦掉重写(可变引用),那这 5 位旅客抄下来的信息必定支离破碎、前后矛盾,这就是计算机系统中最致命的数据竞争(Data Race)。
Rust 在编译期间就以极高敏锐度扫描所有借用的存活周期。只要发现存在一边读一边写、或者两人同时写的苗头,编译器立刻阻断编译。
7. 切片(Slices):安全透视集合的视窗
切片(Slice)是对集合中某段连续局部序列的只读借用,它同样不拥有底层数据所有权:
let s = String::from("hello world");
let hello: &str = &s[0..5]; // 借出前 5 个字母构成的切片
let world: &str = &s[6..11]; // 借出后 5 个字母- 切片的内部构成:仅由两个数字组成——指向切片起始字节的指针 + 切片包含的长度(在 64 位系统上仅占 16 字节)。
- 安全防线:当你持有字符串的切片时,由于切片是只读借用,借用检查器会断然禁止原字符串调用
.clear()清空内容,防止指针指向已经被销毁的字串残骸。