堆
栈很棒,但它不能解决所有问题。那些在编译时大小未知的数据怎么办呢?集合、字符串和其他动态大小的数据无法完全在栈上分配。这时就需要引入堆了。
堆分配
你可以将堆想象成一大块内存——如果愿意的话,就像一个巨大的数组。每当需要在堆上存储数据时,你就要向一个特殊的程序请求,即分配器,为你保留堆中的一部分。我们将这种交互(以及你保留的内存)称为堆分配。如果分配成功,分配器会给你指向已预留块起始位置的指针。
无自动解除分配
堆的结构与栈大不相同。堆分配不是连续的,它们可以位于堆内的任意位置。
+---+---+---+---+---+---+-...-+-...-+---+---+---+---+---+---+---+
| Allocation 1 | Free | ... | ... | Allocation N | Free |
+---+---+---+---+---+---+ ... + ... +---+---+---+---+---+---+---+
跟踪堆的哪些部分正在使用,哪些部分是空闲的是分配器的工作。然而,分配器不会自动释放你分配的内存:你需要主动去做这件事,再次调用分配器来释放不再需要的内存。
性能
堆的灵活性是有代价的:堆分配比栈分配慢。涉及更多的管理操作!如果你阅读关于性能优化的文章,往往会建议你尽量减少堆分配,并尽可能优先使用栈上分配的数据。
String
的内存布局
当你创建一个类型为String
的局部变量时,Rust被迫在堆上分配1:它事先不知道你要放入多少文本,因此无法在栈上预留正确大小的空间。但String
并非完全堆分配,它也在栈上保留了一些数据。具体来说:
- 指向你在堆上预留区域的指针。
- 字符串的长度,即字符串中有多少字节。
- 字符串的容量,即在堆上预留了多少字节。
让我们通过一个例子更好地理解这一点:
#![allow(unused)] fn main() { let mut s = String::with_capacity(5); }
如果运行这段代码,内存将如下布局:
+---------+--------+----------+
Stack | pointer | length | capacity |
| | | 0 | 5 |
+--|------+--------+----------+
|
|
v
+---+---+---+---+---+
Heap: | ? | ? | ? | ? | ? |
+---+---+---+---+---+
我们要求一个可以容纳最多5字节文本的String
。String::with_capacity
去到分配器那里请求5字节的堆内存。分配器返回指向该内存块起始位置的指针。不过,String
是空的。在栈上,我们通过区分长度和容量来记录这个信息:这个String
最多可以容纳5字节,但目前它实际持有0字节的文本。
如果你向String
中推送一些文本,情况就会改变:
#![allow(unused)] fn main() { s.push_str("Hey"); }
+---------+--------+----------+
Stack | pointer | length | capacity |
| | | 3 | 5 |
+----|----+--------+----------+
|
|
v
+---+---+---+---+---+
Heap: | H | e | y | ? | ? |
+---+---+---+---+---+
s
现在持有3字节的文本。它的长度更新为3,但容量保持为5。堆上的5个字节中有3个用于存储字符嘿
。
usize
我们在栈上存储指针、长度和容量需要多少空间?这取决于你运行机器的架构。
机器上的每个内存位置都有一个地址,通常表示为无符号整数。根据地址空间的最大大小(即你的机器可以寻址多少内存),这个整数可以有不同的大小。大多数现代机器使用32位或64位地址空间。
Rust通过提供usize
类型抽象了这些与架构相关的细节:一个无符号整数,其大小与在你的机器上所需寻址内存的字节数相同。在32位机器上,usize
等同于u32
。在64位机器上,它匹配u64
。
容量、长度和指针在Rust中都表示为usize
2。
堆上没有std::mem::size_of
std::mem::size_of
返回类型在栈上会占用的空间量,这也被称为类型的大小。
那么
String
在堆上管理的内存缓冲区呢?难道那不是String
大小的一部分吗?
不!那个堆分配是String
正在管理的一个资源。编译器并不将其视为String
类型的一部分。
std::mem::size_of
不知道(也不关心)类型可能通过指针管理或引用的额外堆分配数据,如String
的情况,因此它不跟踪其大小。
不幸的是,没有std::mem::size_of
的等价物来衡量某个值在运行时分配的堆内存量。某些类型可能提供了检查其堆使用情况的方法(例如String
的capacity
方法),但在Rust中没有通用的“API”来检索运行时堆使用情况。然而,你可以使用内存分析工具(例如DHAT或自定义分配器)来检查程序的堆使用情况。
参考
- 本节练习位于
exercises/03_ticket_v1/09_heap
如果你创建一个空的String
(即String::new()
),标准库实际上并不会分配堆内存。首次向其中推送数据时才会预留堆内存。
指针的大小也取决于操作系统。在某些环境中,指针比内存地址大(例如CHERI)。Rust做了一个简化的假设,即指针和内存地址的大小相同,这对大多数你可能遇到的现代系统来说都是正确的。