所以我主要从事垃圾收集编程语言的工作。我写过不少 C++ 代码,但那是在大学期间,当时我做 LeetCode 题目和大学作业。最近我开始学习 Rust,以下是我对其抽象底层原理的理解。
你可以把这篇文章看作一篇博客,我会引导你了解什么是内存,以及 Rust 中内存是如何分配的。以上就是我的理解,如果你觉得我写得有误或者有需要改进的地方,请在X/Twitter上私信我。
在此之前,WTF 是什么?
好吧,把内存想象成一个巨大的数组,每个元素可以容纳 8 位数据。这些数据就是一个地址。
Address | Value (in binary) ---------|------------------ 0x1000 | 01110011 (115 in decimal, 's' in ASCII) 0x1001 | 01101100 (108 in decimal, 'l' in ASCII) 0x1002 | 01101111 (111 in decimal, 'o' in ASCII) 0x1003 | 01110000 (112 in decimal, 'p' in ASCII)
堆栈:快速且有序Address | Value (in binary) ---------|------------------ 0x1000 | 01110011 (115 in decimal, 's' in ASCII) 0x1001 | 01101100 (108 in decimal, 'l' in ASCII) 0x1002 | 01101111 (111 in decimal, 'o' in ASCII) 0x1003 | 01110000 (112 in decimal, 'p' in ASCII)
栈的工作原理就像你只能从栈顶添加/推送或移除/弹出元素一样。它非常快,因为 CPU 只需要上下移动指针。
当我们在 Rust 中创建一个变量时(如下所示),所有变量都进入堆栈内存:
fn main() { let x: i32 = 100; let flag: bool = true; let point: f64 = 0.5; }
内存如下所示:fn main() { let x: i32 = 100; let flag: bool = true; let point: f64 = 0.5; }
Stack +---------------------+ <-- Top of Stack (higher address) | point = 0.5 | f64 (8 bytes) +---------------------+ | flag = true | bool (1 byte + padding) +---------------------+ | x = 100 | i32 (4 bytes) +---------------------+ <-- Bottom (lowest address for this frame)
这里需要注意以下几点:Stack +---------------------+ <-- Top of Stack (higher address) | point = 0.5 | f64 (8 bytes) +---------------------+ | flag = true | bool (1 byte + padding) +---------------------+ | x = 100 | i32 (4 bytes) +---------------------+ <-- Bottom (lowest address for this frame)
- 每个变量都是按照我们声明的方式从上到下存储的,但堆栈向下增长,因此
point
最后被推送,但出现在内存的顶部 - Rust 编译器会针对性能优化布局,因此实际的内存布局可能由于填充而导致变量之间出现间隙
- 关于堆栈分配:
- 编译时必须知道大小
- 当变量超出范围时,堆栈会被清理
堆:速度较慢但空间较大
堆是一个动态内存区域,你可以在运行时申请空间。它不是直接存储数据,而是指向分配的内存的指针。
fn main() { let v = vec![65, 54, 57]; let s = String::from("slop"); }
Stack Heap ┌─────────────────┐ ┌─────────────────┐ │ s: String │ │ "slop" (4B) │ ← 0x5000 │ ptr: 0x5000 ───┼───────┤ │ │ len: 5 │ └─────────────────┘ │ capacity: 5 │ ├─────────────────┤ ┌────────────────────┐ │ v: Vec<i32> │ │ 65 │ 54 │ 57 │ │ ← 0x4000 │ ptr: 0x4000 ───┼───────┤ │ │ │ │ │ len: 3 │ └────┴────┴────┴─────┘ │ capacity: 3 │ └─────────────────┘
fn main() { let v = vec![65, 54, 57]; let s = String::from("slop"); }
Stack Heap ┌─────────────────┐ ┌─────────────────┐ │ s: String │ │ "slop" (4B) │ ← 0x5000 │ ptr: 0x5000 ───┼───────┤ │ │ len: 5 │ └─────────────────┘ │ capacity: 5 │ ├─────────────────┤ ┌────────────────────┐ │ v: Vec<i32> │ │ 65 │ 54 │ 57 │ │ ← 0x4000 │ ptr: 0x4000 ───┼───────┤ │ │ │ │ │ len: 3 │ └────┴────┴────┴─────┘ │ capacity: 3 │ └─────────────────┘
所以这里要注意一件事,堆栈有一个指针“ptr”,指向堆上的实际数据。Stack Heap ┌─────────────────┐ ┌─────────────────┐ │ s: String │ │ "slop" (4B) │ ← 0x5000 │ ptr: 0x5000 ───┼───────┤ │ │ len: 5 │ └─────────────────┘ │ capacity: 5 │ ├─────────────────┤ ┌────────────────────┐ │ v: Vec<i32> │ │ 65 │ 54 │ 57 │ │ ← 0x4000 │ ptr: 0x4000 ───┼───────┤ │ │ │ │ │ len: 3 │ └────┴────┴────┴─────┘ │ capacity: 3 │ └─────────────────┘
所有权与记忆
让我们举个例子:
let x = String::from("x"); let y = x;
在let x = String::from("x"); let y = x;
let y=x;
内存布局如下:
Stack Heap ┌─────────────────┐ ┌─────────────────┐ │ x: String │ │ "x" (1B) │ │ ptr: 0x1000 ───┼───────┤ │ │ len: 1 │ └─────────────────┘ │ capacity: 1 │ └─────────────────┘
后Stack Heap ┌─────────────────┐ ┌─────────────────┐ │ x: String │ │ "x" (1B) │ │ ptr: 0x1000 ───┼───────┤ │ │ len: 1 │ └─────────────────┘ │ capacity: 1 │ └─────────────────┘
let y = x;
发生的事情是 x 被移动到 y 所以:
Stack Heap ┌─────────────────┐ ┌─────────────────┐ │ x: (invalid) │ │ "x" (1B) │ ├─────────────────┤ │ │ │ y: String │ │ │ │ ptr: 0x1000 ───┼───────┤ │ │ len: 1 │ └─────────────────┘ │ capacity: 1 │ └─────────────────┘
为什么 x 无效!?!?Stack Heap ┌─────────────────┐ ┌─────────────────┐ │ x: (invalid) │ │ "x" (1B) │ ├─────────────────┤ │ │ │ y: String │ │ │ │ ptr: 0x1000 ───┼───────┤ │ │ len: 1 │ └─────────────────┘ │ capacity: 1 │ └─────────────────┘
Rust 编译器使x
无效以防止:
- 双重释放:x 和 y 都试图释放同一块内存
- 释放后使用:在 y 释放内存后使用 x
借用与记忆
让我们再举一个例子(这里 y 将借用 x):
let x = String::from("tpot"); let y = &x;
Stack Heap ┌─────────────────┐ ┌─────────────────┐ │ x: String │ │ "tpot" (4B) │ ← 0x5000 │ ptr: 0x5000 ───┼───────┤ │ │ len: 4 │ └─────────────────┘ │ capacity: 4 │ ├─────────────────┤ │ y: &String │ │ ptr: &x │ └─────────────────┘
let x = String::from("tpot"); let y = &x;
Stack Heap ┌─────────────────┐ ┌─────────────────┐ │ x: String │ │ "tpot" (4B) │ ← 0x5000 │ ptr: 0x5000 ───┼───────┤ │ │ len: 4 │ └─────────────────┘ │ capacity: 4 │ ├─────────────────┤ │ y: &String │ │ ptr: &x │ └─────────────────┘
什么是参考文献?Stack Heap ┌─────────────────┐ ┌─────────────────┐ │ x: String │ │ "tpot" (4B) │ ← 0x5000 │ ptr: 0x5000 ───┼───────┤ │ │ len: 4 │ └─────────────────┘ │ capacity: 4 │ ├─────────────────┤ │ y: &String │ │ ptr: &x │ └─────────────────┘
- 他们不拥有数据
- 它们不会比它们所引用的数据存在得更久
Box:显式堆分配
Box 是一个智能指针,可以让你明确地将值放在堆上
Stack Heap ┌─────────────────┐ ┌─────────────────┐ │ b: Box<i32> │ │ 5 (4 bytes) │ ← 0x6000 │ ptr: 0x6000 ───┼───────┤ │ └─────────────────┘ └─────────────────┘
let b = Box::new(5);
Stack Heap ┌─────────────────┐ ┌─────────────────┐ │ b: Box<i32> │ │ 5 (4 bytes) │ ← 0x6000 │ ptr: 0x6000 ───┼───────┤ │ └─────────────────┘ └─────────────────┘
Rust 中的复制Stack Heap ┌─────────────────┐ ┌─────────────────┐ │ b: Box<i32> │ │ 5 (4 bytes) │ ← 0x6000 │ ptr: 0x6000 ───┼───────┤ │ └─────────────────┘ └─────────────────┘
某些类型实现了Copy
特性,因此会被复制而不是移动
let x = 100; let y = x; // x & y both a valid cause y is an independent copy
实现的类型let x = 100; let y = x; // x & y both a valid cause y is an independent copy
Copy
(可能还有更多,请核对):
- 整数类型
- 浮点类型
- 布尔值
- 字符
- 复制类型的元组
- 复制类型的数组
你注意到了一件事!?只有在Stack
上分配的数据类型才会实现Copy
特性。这是我注意到的,可能是 true 也可能是 false。
Rust 中的内存泄漏
但是 Rust 是内存安全的,对吧!?没错,Rust 可以防止user after free
和double-free
,但我们仍然可以使用Rc<T>
和RefCell<T>
来创建循环引用。
struct Node { next: Option<Rc<RefCell<Node>>>, }
在上面的代码中,它创建了一个不会被释放的循环。当两个struct Node { next: Option<Rc<RefCell<Node>>>, }
Rc
指针相互循环引用,它们的引用计数永远不会达到零,这意味着内存永远不会被释放。这是为什么呢?因为Rc
使用引用计数,为了打破循环,它使用Weak<T>
来建立单向关系,这在强引用被移除时不会阻止内存释放。
TLDR 😉
- 堆栈:快速、固定大小、自动清理、后进先出(LIFO)
- 堆:大小灵活,需要分配/释放,速度较慢
- 所有权:每个值只有一个所有者
- 移动:转移所有权,防止双重释放
- 借用:临时使用但不授予所有权