本文翻译自deepu.tech
在这个系列文章中,我意图揭开内存管理背后那些概念的神秘面纱,同时更深入的探究一些现代编程语言的内存管理。我希望这个系列文章可以使你对这些语言在内存管理方面所做的事情有所了解。
在本章中,我们将研究 Rust 编程语言的内存管理。和 C&C++ 一样, Rust 是一种静态类型、编译型语言。 Rust 是内存和线程安全的,并且没有运行时和垃圾回收器。可以参考我以前的文章Rust的第一印象 。
如果你还没有阅读本系列的第一部分,请先阅读它,因为我在那里解释了堆和栈的区别,这方便你理解本章的内容。
这篇文章基于Rust 1.41的官方实现,概念细节可能会在Rust的未来版本中发生变化
Rust 与现在能见到的其他编程语言相比非常的独特,接下来看一下它的特别之处吧。
Rust内部存储器结构
首先,让我们看一下 Rust 的内部存储器结构。到目前为止, Rust 的语言规范中还没有定义内存模型,它的内存结构非常简单。操作系统( OS )为每一个 Rust 程序分配了一些虚拟内存,这是该程序可以访问的总内存。
与前几章中介绍的 JVM ,V8 和 Go 的内存结构相比, Rust 非常简单。由于不涉及垃圾回收( GC ),因此没有代内存和任何复杂的子结构。原因是 Rust 在运行时使用所有权模型(而不是使用任何种类的 GC )将内存管理作为程序执行的一部分。
让我们看看不同之处是什么:
堆
所有动态数据(在编译时无法计算大小的数据)都存储在堆上。这是最大的内存块,由 Rust 的所有权模型管理。
- Box:Box 类型是 Rust 中堆分配值的抽象,程序可以通过调用
Box::new
来分配堆内存。Box<T>
这个引用的内存包含两个部分:- 它本身是存放在栈上。
- 它持有指向堆上为T类型分配的内存的智能指针。
栈
每个线程有一个栈,静态数据(编译时数据大小已知)默认会存放在栈上。它包括函数栈帧,基本数据类型,结构体和指向堆中动态数据的指针。
Rust内存使用(栈与堆)
我们已经清楚了内存的组织方式,接下来让我们看看 Rust 在执行程序时如何使用 Stack 和 Heap。
下面是一个例子(代码没有进行优化,因此可以忽略不必要的中间变量之类的问题,重点关注栈和堆内存的使用情况。)
|
|
默认情况下, Rust 中的所有值都分配在栈上。但有两个例外:
- 当值的大小未知时。如 String 这样的结构,其大小会随着时间增长。
- 当你手动创建类似
Box::new("Hello")
这样的Box<T>
时。Box 是指向堆内存的智能指针,当 Box 离开作用域时,将调用其析构函数,销毁内部对象,同时释放堆上的内存。
在以上两种例外情况中,值分配在堆上,指针驻留在栈上。
让我们将其可视化,单击幻灯片 ,然后使用箭头键向前/向后移动,来查看上述程序是如何执行的以及如何使用栈和堆:
- Main 方法保存在栈的 main frame 中
- 每此函数调用都作为一个栈帧添加到栈存储器中
- 包括参数和返回值在内的所有静态变量都保存在栈帧内
- 无论什么类型,所有静态值都直接存储在栈中。这也适用于全局范围。
- 所有的动态类型都在堆上创建,栈通过智能指针来引用它们,这也适用于全局范围。这里,我们明确地使用动态类型是因为固定长度的字符串分配在栈上。
- 结构体中的静态数据保存在栈上,动态数据保存在堆中,并通过指针引用。
- 从当前函数调用的函数被压入栈顶
- 当函数返回时,其栈帧会从堆栈中删除
- 与使用垃圾回收的语言不同,一旦 Rust 的主进程完成,堆上的对象也将被销毁,我们将在以下各节中详细了解这一点。
可以看到,栈是由操作系统自动管理的,而不是由 Rust 本身。因此,我们不必担心栈;另一方面,Heap 并不是由操作系统自动管理的,由于其空间巨大并保存动态数据,它可能呈指数增长,从而导致我们的程序耗尽内存。随着时间的推移,它也变得碎片化,使应用程序变慢。这是需要 Rust 的所有权模型自动管理堆内存的原因。
Rust内存管理:所有权
Rust 拥有独特的管理堆内存的方法,这也是 Rust 的特殊之处。它使用所有权模型来管理内存。所有权包含以下规则:
- Rust 中的每个值都必须有一个变量作为其所有者
- 在任何时间,一个变量只能有一个所有者。
- 当所有者离开作用域时,该值将被清理以释放内存
无论值在堆中还是在栈中,这些规则都适用。在下面的示例中,当方法执行结束时,foo
被释放,而bar
的值在代码块结束的时候就被释放。
|
|
这些规则由编译器在编译时检查,内存释放在运行时和程序执行一起进行,因此这里没有额外的开销。通过仔细地确定变量的作用域,就可以确保内存使用得到了优化,这也是 Rust 允许在几乎所有地方使用块作用域的原因。这听起来很简单,但实际上,这一概念对如何编写 Rust 程序产生了深远的影响。 Rust 编译器在这一过程中也做得很好。
由于严格的所有权规则, Rust 允许将所有权从一个变量改为另一个变量,这个过程称为move
。将变量传递到函数中或给一个变量重新赋值时,move
操作自动完成。对于静态基本数据类型, Rust 使用副本而不是移动。
还有一些与内存管理相关的概念与所有权一起发挥作用,使其有效。
RAII
RAII 全称 Resource acquisition is initialization 。这不是 Rust 的新功能,它是从 C++ 借用的。 Rust 强制执行 RAII ,以便在初始化值时,变量拥有关联的资源,当变量离开作用域时,将调用其析构函数来释放资源。这样可以确保我们永远不必手动释放内存或担心内存泄漏。下面是一个例子:
|
|
借用和借用检查器
在 Rust 中,可以按值或引用传递变量,按引用传递变量称为借用。由于某一时刻,一个资源只能有一个拥有者。因此我们必须借用它,而不用拥有它的所有权。 Rust 编译器拥有借用检查器,该检查器可静态确保引用指向有效对象,并且不违反所有权规则。下面是 Rust 官方示例的简化版本。
|
|
变量的生存期
变量的生存期是使所有权模型起作用的另一个非常重要的概念。它是借用检查器用来确保对对象的所有引用均有效时会用到的一种结构。变量的生存期在编译时检查,它从一个变量初始化开始,到销毁为止。生存期与作用域不同。
这听起来很简单,但是当函数和带引用的结构体参与进来时,生存期会变得更加复杂。这个时候我们就需要开始使用生存期注解,以便借用检查器知道引用的有效时间多长。有时,编译器可以推断生存期,但并非总能如此。这不在本文讨论范围之内,所以就不展开。
智能指针
指针不过是对堆上内存地址的引用。 Rust 支持指针,并允许使用&
和*
运算符对其进行引用和解引用。 Rust 中的智能指针类似于指针,但具有附加的元数据功能。像 RAII 一样,它也是从 C++ 拿过来的一个概念。与仅仅借用数据的指针不同,智能指针拥有它们指向的数据。Box ,String 和 Vec 是 Rust 中常见的智能指。当然也可以用结构体来编写自定义智能指针。
所有权可视化
现在我们已经见识了一些用于所有权的不同概念,就让我们对其进行可视化,这与我们在 Heap 中可视化数据的其他语言不同,在这里 查看源代码会更容易理解一些。
结论
这篇文章应该提供 Rust 内存结构和内存管理的概述。这并不是全部,还有许多更高级的概念,并且实现细节在各个版本之间不断变化。在内存回收的语言中,你不需要了解内存管理模型就可以使用这些语言, Rust 和它们不同,在 Rust 中,你需要了解所有权才能编写程序。本文只是一个开始,我建议您深入研究 Rust 文档以了解有关这些概念的更多信息。
希望您能从中学到快乐,请继续关注本系列的下一篇文章。