内存管理概念篇

发表于 · 归类于 代码 · 阅读完需 10 分钟 · 报告错误 · 阅读:

程序如何使用内存

每个程序都需要调用内存才能运行,它们都有各种各样的内存需求。

处于安全性和故障隔离方面的考虑,不允许进程直接访问物理内存,而是使用虚拟内存,操作系统使用被成为页面的内存数据结构映射到实际的物理内存,这些数据结构在页面表中维护。进程必须从操作系统请求内存供其使用,它获得的是一个内存映射到随机存取存储器(Random Access Memory,RAM)物理地址的虚拟地址。

处于性能方面的考虑,内存都以为单位被请求和处理。当进程访问虚拟内存时,内存管理单元执行从虚拟内存到物理内存的实际转换。

 

内存管理及其分类

计算机中的RAM是有限资源,并且由所有正在运行的程序共享。当进程完成其指令后,它必须释放所有使用的内存,以便操作系统可以回收内存,并将内存交给其他进程使用。

直到20世纪90年代中期,大多数编程语言都依赖于手动内存管理,这需要程序员分别调用内存分配器API(例如malloc和free)代码来分配和释放内存。大约在1959年,Lisp的创建者John McCarthy发明了GC,这是一种自动内存管理机制,Lisp是第一种采用它的语言。作为运行程序的一部分,GC以守护线程的形式出现,并分析程序中不在引用内存的任何变量,然后在某个时间点在运行程序的同时自动释放它们。

低级语言不附带GC,因为这会引入不确定性和运行时开销,GC线程在后台运行,在某些情况下会暂停程序的运行。这种暂停有时会出现几毫秒的延迟。这可能违反了系统软件在时间和空间上的硬性约束。低级语言要求程序员可以手动控制内存管理。但是,像C++和Rust这样的语言通过类型系统抽象(如智能指针)使程序员能够减轻一些负担。

内存垃圾回收

基于语言之间的差异,我们可以将它们采用的内存管理策略大致分为3类:

 

内存分配

程序运行时,进程中的内存分配即可能发生在 stack 上,也可能发生在 heap 上。

Rust偏向于使用stack内存。通常,你创建并绑定到变量的任何类型的值或实例都会存储到stack中。存储到heap上是显式的,可以通过智能指针类型来实现。

 

stack

无论何时调用函数或方法,stack都用于为函数内部创建的值分配空间。函数中的所有 let 绑定都存储在stack中,它既可以是值本身,也可以是指向heap内存地址的指针。 这些值构成了活动函数的堆栈帧,堆栈帧是stack存储器的逻辑块,用于存储函数调用的上下文。该上下文可能包括函数参数、局部变量、返回地址,以及从函数返回需要恢复的任何易保存的寄存器值。随着越来越多的函数被调用,它们对应的堆栈帧会被压入stack。一旦函数返回,与之相关的堆栈帧,以及其中声明的所有值都会一起被清理释放。

这些值会根据它们声明的相反顺序删除,并且遵循后进先出(Last In First Out, LIFO)规则。

stack内存分配速度很快,因为分配和释放内存只需要一条CPU指令:递增/递减堆栈帧指针。堆栈帧指针(esp)是一个CPU寄存器,它始终指向stack的最顶端。堆栈帧指针在函数被调用或返回时实时更新。当函数返回时,通过将堆栈帧指针恢复到进入函数之前的位置丢弃该堆栈帧。使用stack是一种临时性内存分配策略,但由于其简单性,它在释放已使用内存方面是可靠的。但是,stack的相同属性不适用于超出当前堆栈帧生命周期的情况。


fn double_of(b: i32) -> i32 {
    let x = 2 * b;
    x
}

fn main() {
    let a = 12;
    let result = double_of(a);
}

 

heap

heap用于处理更复杂的动态的内存分配需求。程序可能在某个时点在heap上分配内存,并且可能在某个其他时点释放,同时这些时点之间不存在严格的边界,就像stack一样。在stack上分配内存时,你能够确定分配和释放内存的时机。此外,heap中的值可能存活得比分配给它的函数更久,稍后也可能会被其他函数清理、释放。在这种情况下,代码无法调用 free 函数,因此最糟糕的情况可能是根本无法取消分配。

不同语言使用堆内存的方式也不尽相同。在C++中,为了避免手动调用 delete 函数,程序员经常使用诸如 unique_ptrshared_ptr这样的智能指针类型。这些智能指针类型具有析构方法,当它们超出内部作用域时,会调用delete函数。这种管理内存的范式被称为RAII原则,并由C++推而广之。

Rust对C++管理堆内存的机制也提供了类似的抽象。Rust在heap上分配内存的唯一方法是通过智能指针类型。Rust中的智能指针实现了Drop特征,它指定了如何释放值所使用的的内存,并且在语义上定义了类似C++中析构函数的方法。

为了在heap上分配内存,语言依赖于专用得内存分配器。编译器rustc自身会采用jemalloc内存分配器,从而rust构建的库和二进制文件会使用系统内存分配器。在Linux上,依赖的将是glibc内存分配器的API。jemalloc是一个支持多线程环境的高效内存分配器库,它大大减少了Rust程序的构建时间。虽然编译器采用了jemalloc,但是任何使用Rust构建的应用程序都不会使用它,因为它会增加二进制文件的大小。因此,已编译的二进制文件和库默认情况下都采用系统内存分配器。

Rust还有一个可插拔的分配器设计,可以使用系统内存分配器,或实现std::alloc模块下的GlobalAlloc特征的自定义内存分配器。

在Rust中,大部分事先不知道尺寸的动态类型都在heap上分配内存。不过这不包括基元类型。


let s = String::new("foo");

String::new 会在heap上分配一个 Vec<u8>类型,并返回对它的引用。此引用会和变量s绑定,该变量在stack上分配内存。只要 s 在作用域内,heap中的字符串就一直存在。当 s 超出其作用域时,Vec<u8>将会从heap释放,其 drop 方法将作为 Drop 实现的一部分进行调用。对于需要在heap上为基元类型分配内存的极个别情况,可以使用 Box<T> 类型,它是一种泛型智能指针类型。

 

内存安全性

内存安全性是指你的程序永远不会访问它不应该访问的位置,程序中声明的变量不能指向无效内存,并且在所有代码路径中保持有效。换句话说,安全性基本上会归结为在程序中始终具有有效引用的指针,并且使用指针的操作不会导致未定义的行为。未定义的行为指程序的状态出现了编译器未考虑到的情况,因为编译器规范中没有说明在该情况下会发生什么。

内存安全性Bug会导致内存泄漏,以分段错误的形式导致程序崩溃,或者在最糟糕的情况下产生安全漏洞。要在C语言中创建正确且安全的程序,程序员必须在使用完内存后进行适当的free函数调用。如今的C++通过智能指针类型来处理与手动内存管理有关的问题,但这并不能完全消除它们。基于虚拟机的语言(JVM是最典型的例子)使用垃圾收集器来消除所有和类有关的内存安全问题。虽然Rust没有内置GC,但由于该语言中采用了相同的RAII原则,同时根据变量的作用域为我们自动释放使用过的内存,因此比C/C++更安全。它为我们提供了几个细粒度的抽象,用户可以根据自己的需要进行选择。

内存安全三原则

Rust能够在编译期检测程序中内存安全违规,在离开作用域时自动释放相关资源等情况。我们将这些概念称作所有权、借用和生命周期。

所有权有点儿类似核心原则,而借用和生命周期是对语言类型系统的扩展。在代码的不同上下文中加强或有时放松所有权原则,可确保编译器内存管理正常运作。

所有权、借用和生命周期详情,请参考所有权章节详细查看。