1.2 栈与堆:为什么堆需要单独释放
上一节定下了目标:自动释放内存,不靠人手写 free,也不依赖运行时扫描或计数。要寻找释放时机的线索,先看程序常用的两类存储区域:栈(stack)和堆(heap)。
这两个词描述的是内存里的存储位置和工作方式,不是两种数据类型。
栈:编译期确定与自动回收
Section titled “栈:编译期确定与自动回收”一次函数调用进来,CPU 在栈上划出一块连续区域,叫 栈帧(stack frame),用来放这次调用的参数和局部变量;函数返回,整块栈帧直接丢弃,里面的局部变量一起消失。进出次序严格固定:后进来的先离开(后进先出,LIFO)。
栈帧的分配和收回只需移动栈指针,几乎没有开销;而且函数一返回,栈帧就自动收回,根本不需要任何人操心释放。
既然栈这么省心,假如一个程序只使用栈,会怎么样?
如果程序只是算一个两位数加法 a + b,那确实只用栈就够了——因为 a 和 b 各占几个字节,在编译期就是写死的,算完函数返回,栈帧直接收回。但只要走出这种固定大小的计算,只用栈立刻就会遇到两条硬限制:
- 栈上的变量,大小必须在编译期已知。 编译器生成指令时,必须提前算好整块栈帧占多少字节。如果一段数据的大小要到运行时才能确定——比如用户在终端输入的一行文字、从网络收到的一包数据、或者一个会动态增长的列表——栈帧根本没法提前给它留位置。
- 栈帧里的数据活不过当前这次调用。 函数一返回,整块栈帧直接丢弃,里面的数据也随之消失。
因此,只要程序需要处理「运行时才知道需要多少空间」的数据,光靠栈就办不到了。程序必须在运行时向另一块区域按需申请空间——这就是堆。
堆:动态申请与指针操控
Section titled “堆:动态申请与指针操控”堆由运行时的内存分配器(Allocator)管理。当程序在运行时需要一块空间时,告诉分配器需要多少字节,分配器就在堆上找一块足够大的连续内存划出来,并把这块内存的起始地址返回给你。
正因为这块内存是你向分配器动态申请来的,它不会随着函数返回自动消失——堆内存是一份资源,有借就必须有还;用完之后必须显式归还给分配器,别人才能再用。
这也顺带回答了栈与堆是怎么配合的:堆上的数据本体在编译期不知道有多大,放不进栈;但分配器返回的起始地址(指针)大小是固定的(在 64 位机器上固定占 8 字节,即一个机器字)。所以数据自然分成两部分:栈帧里放固定大小的控制结构(保存那个起始地址),动态大小的数据本体放在堆上。
以动态字符串 String 为例(图里变量名为 name):
变量 name 本身在栈上,固定占三个机器字:一个指向堆内存的指针(ptr)、一个当前长度(len)、一个容量(cap)。它的大小在编译期已知,可以直接放在栈帧里;而长度可变的字符缓冲区("Rust"),作为动态申请的资源放在堆上,由 ptr 指着。
为什么只有堆需要「回收」
Section titled “为什么只有堆需要「回收」”现在看函数返回的那一刻,栈和堆的命运有什么不同:
调用期间,栈帧里放着变量 a,指着堆上申请来的那块数据。当函数返回时,栈帧连同里面的变量 a 自动消失了——但堆不跟着函数调用走,堆上那块数据还完好无损地留在原地,栈上已经没有任何变量指向它。
所以,栈帧的收回已经由调用机制处理,堆缓冲区却需要额外的释放动作。 本章讨论的回收问题,主要就是谁来执行这个动作。
围绕堆资源的释放,这里先看三种问题:
- 没有人还:栈帧消失了,堆上的资源再也找不到,一直占着直到进程结束——内存泄漏(Memory Leak);
- 还早了:堆资源已经归还,但手里还留着旧地址去读写——悬垂指针(Dangling Pointer);
- 还了两次:同一块堆资源被归还了两次——双重释放(Double Free),属于未定义行为,通常当场崩溃。
写 Rust 前,脑子里先装好这张内存底图
Section titled “写 Rust 前,脑子里先装好这张内存底图”在 Python、Java、JavaScript 里,语言故意把栈和堆的区别藏在了运行时后面,你平时写代码不需要关心一个变量到底落在哪块内存上。但在 Rust 里,看清栈与堆的布局,是理解后面一切规则的前提。
读完这一节,你脑子里只需要牢牢立住这张底图:
| 维度 | 栈(Stack) | 堆(Heap) |
|---|---|---|
| 放什么数据 | 编译期大小已知的值(普通数字、布尔值,以及指向堆的控制结构) | 运行时才知道大小、或需要动态增长的数据本体 |
| 怎么分配 | 函数调用进来划出栈帧,移动栈指针即可 | 运行时向内存分配器申请一块连续内存,返回起始地址 |
| 怎么收回 | 自动:函数返回、栈帧丢弃,里面的局部变量随之消失 | 不会自动消失:是一份借来的内存资源,必须显式归还给分配器 |
把这张表收束成在本章模型里使用的三条判断:
- 直接放在栈局部变量槽位上的值,大小必须在编译期已知;需要在运行时动态申请或动态增长的数据,通常把数据本体放在堆上。(后续第 4 章还会见到住在可执行文件只读数据段里的字符串字面量,以及借用栈或堆上连续内存的切片视图。)
- 在本章的局部变量模型里,堆上的数据靠栈帧里的一个固定大小的控制结构(保存着它的起始地址)来访问和管理。
- 栈帧随函数返回自动收回;本章要解决的「内存回收」,核心是谁在什么时候归还堆上那块动态申请的资源。
看清了这张底图,也能理解为什么垃圾回收(GC)会成为主流的工程方案:既然栈帧随调用来去、堆上的资源却留在原地,很自然的思路就是在运行时派一个回收器,从当前还活着的栈变量出发,顺着指针去标记——还有变量指着的堆就留着,没有任何变量指着的堆就收走。
但如果我们不想承担运行时扫描或引用计数的成本呢?
那就回到上面第 2 条和第 3 条的联系里找线索:堆上的资源自己不知道什么时候该释放,但持有它的那个栈变量,有确定的退场时刻(离开作用域 })。下一节,我们就顺着这条线索,搭出一套不靠 GC 的最简自动回收模型。