栈很棒,但它不能解决所有问题。那些在编译时大小未知的数据怎么办呢?集合、字符串和其他动态大小的数据无法完全在栈上分配。这时就需要引入了。

堆分配

你可以将堆想象成一大块内存——如果愿意的话,就像一个巨大的数组。每当需要在堆上存储数据时,你就要向一个特殊的程序请求,即分配器,为你保留堆中的一部分。我们将这种交互(以及你保留的内存)称为堆分配。如果分配成功,分配器会给你指向已预留块起始位置的指针

无自动解除分配

堆的结构与栈大不相同。堆分配不是连续的,它们可以位于堆内的任意位置。

+---+---+---+---+---+---+-...-+-...-+---+---+---+---+---+---+---+
|  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字节文本的StringString::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中都表示为usize2

堆上没有std::mem::size_of

std::mem::size_of返回类型在栈上会占用的空间量,这也被称为类型的大小

那么String在堆上管理的内存缓冲区呢?难道那不是String大小的一部分吗?

不!那个堆分配是String正在管理的一个资源。编译器并不将其视为String类型的一部分。

std::mem::size_of不知道(也不关心)类型可能通过指针管理或引用的额外堆分配数据,如String的情况,因此它不跟踪其大小。

不幸的是,没有std::mem::size_of的等价物来衡量某个值在运行时分配的堆内存量。某些类型可能提供了检查其堆使用情况的方法(例如Stringcapacity方法),但在Rust中没有通用的“API”来检索运行时堆使用情况。然而,你可以使用内存分析工具(例如DHAT自定义分配器)来检查程序的堆使用情况。

参考

  • 本节练习位于 exercises/03_ticket_v1/09_heap
1

如果你创建一个String(即String::new()),标准库实际上并不会分配堆内存。首次向其中推送数据时才会预留堆内存。

2

指针的大小也取决于操作系统。在某些环境中,指针比内存地址(例如CHERI)。Rust做了一个简化的假设,即指针和内存地址的大小相同,这对大多数你可能遇到的现代系统来说都是正确的。