跳转到内容

1.2 栈与堆:为什么堆需要单独释放

上一节定下了目标:自动释放内存,不靠人手写 free,也不依赖运行时扫描或计数。要寻找释放时机的线索,先看程序常用的两类存储区域:栈(stack)和堆(heap)。

这两个词描述的是内存里的存储位置和工作方式,不是两种数据类型。

一次函数调用进来,CPU 在栈上划出一块连续区域,叫 栈帧(stack frame),用来放这次调用的参数和局部变量;函数返回,整块栈帧直接丢弃,里面的局部变量一起消失。进出次序严格固定:后进来的先离开(后进先出,LIFO)。

栈帧一块挨一块,每块大小由编译器固定;函数返回,整帧丢弃,后进来的先离开。
栈帧随调用划出,随返回丢弃左边是三层调用同时在栈上:main()、foo()、bar() 依次划出,bar() 在栈顶,每帧的大小由编译器固定。右边 bar() 已经返回,它那一块栈帧变成灰色虚线框,表示整帧丢弃;栈顶落到 foo(),下一个被丢弃的还是栈顶。调用中栈bar()栈顶foo()main()栈底编译器固定大小bar() 返回后栈整帧丢弃foo()栈顶main()栈底后进来的先离开

栈帧的分配和收回只需移动栈指针,几乎没有开销;而且函数一返回,栈帧就自动收回,根本不需要任何人操心释放。

既然栈这么省心,假如一个程序只使用栈,会怎么样?

如果程序只是算一个两位数加法 a + b,那确实只用栈就够了——因为 a 和 b 各占几个字节,在编译期就是写死的,算完函数返回,栈帧直接收回。但只要走出这种固定大小的计算,只用栈立刻就会遇到两条硬限制:

  1. 栈上的变量,大小必须在编译期已知。 编译器生成指令时,必须提前算好整块栈帧占多少字节。如果一段数据的大小要到运行时才能确定——比如用户在终端输入的一行文字、从网络收到的一包数据、或者一个会动态增长的列表——栈帧根本没法提前给它留位置。
  2. 栈帧里的数据活不过当前这次调用。 函数一返回,整块栈帧直接丢弃,里面的数据也随之消失。

因此,只要程序需要处理「运行时才知道需要多少空间」的数据,光靠栈就办不到了。程序必须在运行时向另一块区域按需申请空间——这就是堆。

堆由运行时的内存分配器(Allocator)管理。当程序在运行时需要一块空间时,告诉分配器需要多少字节,分配器就在堆上找一块足够大的连续内存划出来,并把这块内存的起始地址返回给你。

正因为这块内存是你向分配器动态申请来的,它不会随着函数返回自动消失——堆内存是一份资源,有借就必须有还;用完之后必须显式归还给分配器,别人才能再用。

这也顺带回答了栈与堆是怎么配合的:堆上的数据本体在编译期不知道有多大,放不进栈;但分配器返回的起始地址(指针)大小是固定的(在 64 位机器上固定占 8 字节,即一个机器字)。所以数据自然分成两部分:栈帧里放固定大小的控制结构(保存那个起始地址),动态大小的数据本体放在堆上。

变量在栈上,对象在堆上。
一个变量在栈上,一个对象在堆上栈帧里放着变量 a。箭头从 a 指向堆上的对象。栈(跟着调用走)栈帧a堆(运行时申请)对象

以动态字符串 String 为例(图里变量名为 name):

栈上三个机器字,堆上是字符缓冲区。
String 的变量在栈上,字符在堆上栈帧里的 name 占三格:指针、长度、容量。箭头从指针指向堆上的四个字符。栈帧nameStringptrlen · 4cap · 4堆字符缓冲区Rust

变量 name 本身在栈上,固定占三个机器字:一个指向堆内存的指针(ptr)、一个当前长度(len)、一个容量(cap)。它的大小在编译期已知,可以直接放在栈帧里;而长度可变的字符缓冲区("Rust"),作为动态申请的资源放在堆上,由 ptr 指着。

现在看函数返回的那一刻,栈和堆的命运有什么不同:

调用中,栈帧里放着变量;调用返回,栈帧没了,堆上的对象还在。
函数返回前后两种状态。调用中,栈帧里放着变量 a,箭头指向堆上的对象;返回后,栈帧没了,堆上的对象还在,已经没有变量指向它。调用中栈(跟着调用走)栈帧a堆(运行时申请)对象返回后栈(跟着调用走)栈帧已离开堆(运行时申请)对象

调用期间,栈帧里放着变量 a,指着堆上申请来的那块数据。当函数返回时,栈帧连同里面的变量 a 自动消失了——但堆不跟着函数调用走,堆上那块数据还完好无损地留在原地,栈上已经没有任何变量指向它。

所以,栈帧的收回已经由调用机制处理,堆缓冲区却需要额外的释放动作。 本章讨论的回收问题,主要就是谁来执行这个动作。

围绕堆资源的释放,这里先看三种问题:

  • 没有人还:栈帧消失了,堆上的资源再也找不到,一直占着直到进程结束——内存泄漏(Memory Leak);
  • 还早了:堆资源已经归还,但手里还留着旧地址去读写——悬垂指针(Dangling Pointer);
  • 还了两次:同一块堆资源被归还了两次——双重释放(Double Free),属于未定义行为,通常当场崩溃。

写 Rust 前,脑子里先装好这张内存底图

Section titled “写 Rust 前,脑子里先装好这张内存底图”

在 Python、Java、JavaScript 里,语言故意把栈和堆的区别藏在了运行时后面,你平时写代码不需要关心一个变量到底落在哪块内存上。但在 Rust 里,看清栈与堆的布局,是理解后面一切规则的前提。

读完这一节,你脑子里只需要牢牢立住这张底图:

维度 栈(Stack) 堆(Heap)
放什么数据 编译期大小已知的值(普通数字、布尔值,以及指向堆的控制结构) 运行时才知道大小、或需要动态增长的数据本体
怎么分配 函数调用进来划出栈帧,移动栈指针即可 运行时向内存分配器申请一块连续内存,返回起始地址
怎么收回 自动:函数返回、栈帧丢弃,里面的局部变量随之消失 不会自动消失:是一份借来的内存资源,必须显式归还给分配器

把这张表收束成在本章模型里使用的三条判断:

  1. 直接放在栈局部变量槽位上的值,大小必须在编译期已知;需要在运行时动态申请或动态增长的数据,通常把数据本体放在堆上。(后续第 4 章还会见到住在可执行文件只读数据段里的字符串字面量,以及借用栈或堆上连续内存的切片视图。)
  2. 在本章的局部变量模型里,堆上的数据靠栈帧里的一个固定大小的控制结构(保存着它的起始地址)来访问和管理。
  3. 栈帧随函数返回自动收回;本章要解决的「内存回收」,核心是谁在什么时候归还堆上那块动态申请的资源。

看清了这张底图,也能理解为什么垃圾回收(GC)会成为主流的工程方案:既然栈帧随调用来去、堆上的资源却留在原地,很自然的思路就是在运行时派一个回收器,从当前还活着的栈变量出发,顺着指针去标记——还有变量指着的堆就留着,没有任何变量指着的堆就收走。

但如果我们不想承担运行时扫描或引用计数的成本呢?

那就回到上面第 2 条和第 3 条的联系里找线索:堆上的资源自己不知道什么时候该释放,但持有它的那个栈变量,有确定的退场时刻(离开作用域 })。下一节,我们就顺着这条线索,搭出一套不靠 GC 的最简自动回收模型。