本文探讨编程领域的一个重要内容:内存管理。你可能对内存管理的概念比较模糊,或者经常忽略它。我们将重点介绍高级内存管理抽象,如果您希望从更广泛的角度了解内存管理,尤其是作为 Web 开发人员,这可能会很有用。
问题
我先问你一个问题:
什么数据放在栈上,什么数据放在堆上?
如果你精通 JavaScript/Python/Java 等具有自动内存管理功能的语言,你可能会说出以下答案:
基础类型(Primitive types)存储在栈中,对象(objects)存储在堆中,闭包变量(closure variables)存储在堆中,等等。
这个答案正确吗?没问题,但这只是表象,不是本质。那么本质是什么呢?
让我们先分析一下程序中的栈和堆,然后给出答案。
栈
栈数据结构的特点是先进后出(first-in, last-out)。正因为这个特点,它非常适合记录程序的函数调用,也被称为函数调用栈。那么请看下面的简单代码示例:
fn test() {} fn main() { test() }
让我们来分析一下。首先,我们编写的代码将作为 "entry "执行。请看下图:
函数调用栈从上到下递增(grows from top to bottom)。在这个简单的代码示例中,调用流程是 entry -> main() -> test()
。
接下来,每当执行一个函数时,堆栈顶部都会分配一块连续的内存,这块内存被称为页帧(frame) 。
在页式存储管理当中,它把物理的地址空间分成的基本单位叫页帧,或者叫帧(frame)。这个大小是2的n次幂,那么为什么会是2的n次幂呢?原因在于我们要在地址转换的过程当中让地址转换比较方便,那在计算机里二进制的移位是做乘法的一个非常重要的快速的一个因素,所以这一定要求它是2的整次幂。比如说现在在32位机器里头,4K(4096)是常见的页帧大小(64位一般为8k)。
该 "frame "存储了当前函数通用寄存器和当前函数局部变量的上下文信息。
在本例中,当 main()
调用 test()
时,会暂时中断当前的 CPU 执行进程,并在堆栈中存储 main()
通用寄存器的副本。执行完 test()
后,将根据之前的副本恢复原来的寄存器上下文,就像什么都没发生过一样。
酷,这就是通用寄存器的神奇之处!
然后,随着函数被逐层调用,堆栈会一层层扩展,调用结束后,堆栈会逐层回溯,每帧占用的内存会被逐一释放。
但等等,我们似乎遗漏了什么。在通常情况下,它需要连续的内存空间,这意味着程序在调用下一个函数之前,必须知道下一个函数需要多少内存空间。
那么,程序是如何知道的呢?
答案是编译器为我们完成了这一切。
编译代码时,函数是最小的编译单元。每当编译器遇到一个函数,它就会知道当前函数使用寄存器和局部变量所需的空间。
因此,在编译时无法确定大小或大小可以改变的数据不能安全地放在堆栈中。
堆
如上所述,这些数据不能安全地放在堆栈中,因此最好放在堆上,例如下面的变长数组:
fn main() { let mut arr = Vec::new(); arr.push(1); println("length: {}, capacity: {}", arr.len(), arr.capacity()); }
创建数组而不指定其长度时,程序需要动态分配内存。例如,在 C 语言中,通常使用 malloc()
函数进行分配。最初,程序会预留一定的空间(例如,在 Rust 中可能会预留 4 个元素的空间)。如果数组的实际使用量超过了这个容量,程序会分配一个更大的内存块,将现有元素复制到其中,添加新元素,然后释放旧内存。这个过程允许数组根据需要动态调整大小。
请求系统调用并找到新的内存,然后一个个复制的过程是非常低效的。
因此,最好的做法是提前预留数组真正需要的空间。
此外,需要跨栈引用的内存也需要放在堆上,这一点很好理解,因为一旦栈帧被回收,其内部局部变量也会被回收,所以在不同的调用栈中共享数据只能使用堆。
但这又带来了一个新问题,堆上占用的内存何时释放?
垃圾回收
主流编程语言都给出了自己的答案:
早期的 C 语言将所有这些都留给开发人员手动管理,这对于经验丰富的程序员来说是一个优势,因为他们可以更精细地控制程序的内存。但对于初学者来说,必须牢记那些内存管理的最佳实践。但与机器不同的是,总有一些疏忽会导致内存安全问题,导致程序运行缓慢或直接崩溃。
以 Java 为代表的一系列编程语言都使用追踪式垃圾收集(Tracing GC,Tracing Garbage Collection)来自动管理堆内存。这种方法通过定期标记不再被引用的对象,然后将其清理掉,从而自动管理内存,减轻了开发人员的负担。但它在标记和释放内存时需要执行额外的逻辑,这会导致 STW(Stop The World),比如程序卡住,而且这些时间也是不确定的。因此,如果要开发一些实时性要求较高的系统,一般不会使用类似 GC 的语言。
Apple 公司的 Objective-C 和 Swift 使用 ARC(Automatic Reference Counting,自动引用计数),在编译时为每个函数插入 retain/release
语句,以自动维护堆上对象的引用计数。当对象的引用计数为 0 时,release 语句就可以释放对象。但它增加了大量额外代码来处理引用计数,因此效率和吞吐量都不如 GC。
Rust 使用所有权 ownership 机制,默认情况下将堆上数据的生命周期与栈帧的生命周期绑定在一起。一旦栈帧被销毁,堆上的数据也将被丢弃,占用的内存也将被释放。Rust 还提供了 API,供开发人员更改默认行为或自定义释放时的行为。
总结
栈中存储的数据是静态的,大小固定,生命周期固定,不能跨栈引用。
堆上存储的数据是动态的,大小不固定,生命周期也不固定,可以跨堆引用。
文章来源:Memory Management Every Developer Should Know