堆 (Heap)

栈很棒,但它不能解决我们所有的问题。那些大小在编译期未知的数据怎么办? 集合 (collection)、字符串 (string) 以及其他动态尺寸 (dynamically-sized) 的数据无法(完整地)放到栈上。 这就轮到堆 (heap) 登场了。

堆分配 (Heap allocations)

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

没有自动释放 (No automatic de-allocation)

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

+---+---+---+---+---+---+-...-+-...-+---+---+---+---+---+---+---+
|  Allocation 1 | Free  | ... | ... |  Allocation N |    Free   |
+---+---+---+---+---+---+ ... + ... +---+---+---+---+---+---+---+

哪些区域在使用、哪些是空闲的,由分配器来跟踪。 不过分配器不会自动释放你分配的内存:你需要主动行事,再次调用分配器来释放 (free) 你不再需要的内存。

性能 (Performance)

堆的灵活性是有代价的:堆分配比栈分配更慢。 其中涉及大量的簿记!
如果你读过性能优化方面的文章,常会看到这样的建议:尽量减少堆分配,能用栈分配的就用栈。

String 的内存布局 (String's memory layout)

当你创建一个 String 类型的局部变量时, Rust 不得不在堆上分配1:它无法事先知道你要往里塞多少文本, 所以无法在栈上提前预留合适的空间。
String 并不是_完全_位于堆上,它在栈上也保留了一些数据。具体来说:

  • 指向你预留的堆区域的指针 (pointer)
  • 字符串的长度 (length),即字符串当前包含的字节数。
  • 字符串的容量 (capacity),即在堆上预留了多少字节。

我们看个例子来更好理解:

let mut s = String::with_capacity(5);

如果你运行这段代码,内存布局会是这样:

      +---------+--------+----------+
Stack | pointer | length | capacity | 
      |  |      |   0    |    5     |
      +--|------+--------+----------+
         |
         |
         v
       +---+---+---+---+---+
Heap:  | ? | ? | ? | ? | ? |
       +---+---+---+---+---+

我们请求了一个最多能装 5 字节文本的 String
String::with_capacity 向分配器请求 5 字节的堆内存。分配器返回一个指向那块内存起始位置的指针。
不过此时 String 是空的。在栈上,我们通过区分长度 (length) 和容量 (capacity) 来记录这一信息:这个 String 最多可装 5 字节,但当前只装了 0 字节实际文本。

如果你往 String 里塞一些文本,情况就变了:

s.push_str("Hey");
      +---------+--------+----------+
Stack | pointer | length | capacity |
      |  |      |   3    |    5     |
      +--|  ----+--------+----------+
         |
         |
         v
       +---+---+---+---+---+
Heap:  | H | e | y | ? | ? |
       +---+---+---+---+---+

s 现在装了 3 字节的文本。它的长度更新为 3,但容量仍是 5。 堆上 5 个字节中有 3 个被用来存储字符 Hey

usize

我们要在栈上存指针、长度和容量,需要多大空间?
这取决于你运行机器的架构 (architecture)

机器上的每个内存位置都有一个地址 (address),通常表示为一个无符号整数。 根据地址空间的最大大小(即你的机器能寻址多少内存)不同, 这个整数可能有不同的大小。大多数现代机器使用 32 位或 64 位的地址空间。

Rust 通过提供 usize 类型来抽象掉这些与架构相关的细节: 一个无符号整数,其大小恰好等于在你机器上寻址内存所需的字节数。 在 32 位机器上,usize 等价于 u32;在 64 位机器上,对应 u64

容量、长度和指针在 Rust 中都用 usize 表示2

堆上没有 std::mem::size_of (No std::mem::size_of for the heap)

std::mem::size_of 返回某个类型在栈上会占用多少空间, 也称为类型的大小 (size of the type)

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

不算!
那块堆分配是 String管理的资源。 编译器并不把它看作 String 类型的一部分。

std::mem::size_of 不知道(也不在乎)某个类型可能管理或通过指针引用的额外堆内存——String 就属于这种情况—— 因此它不会跟踪这部分大小。

不幸的是,目前没有等价于 std::mem::size_of 的工具来在运行时测量某个值正在分配多少堆内存。 某些类型可能提供方法来查看它们的堆使用情况(例如 Stringcapacity 方法), 但 Rust 没有通用的"API"来在运行时获取堆使用情况。
不过你可以用内存分析工具(例如 DHAT自定义分配器 (custom allocator))来检查程序的堆使用情况。

1

如果你创建一个 String(即 String::new()),std 不会进行堆分配。 当你第一次往里塞数据时,才会预留堆内存。

2

指针的大小还取决于操作系统。 在某些环境下,指针内存地址更大(例如 CHERI)。 Rust 做了一个简化假设——指针和内存地址同样大——这对你大多数现代系统来说是成立的。

原文链接:英文原文