Skip to content
Published on

栈 vs 堆:内存的真相

分享
Authors

引言 — 变量住在哪里

学编程学到某个阶段,总会听到"这个东西在栈上,那个东西在堆上"这样的话。一开始只是听过就算了,但它却总是莫名其妙地绊住你。为什么有些值在函数结束后就消失了,有些却还留着?为什么把递归写深了程序就会挂掉?为什么在 C 里 malloc 出来的东西必须 free,而在 JavaScript 里却不用?为什么在 Python 里明明只是复制了一份列表,原始的那份却也跟着变了?

所有这些问题的根源,都在栈和堆身上。这两者是程序处理内存的两种根本方式,与具体用什么语言无关。名字虽然来自数据结构,但这里讲的是运行时的故事——"值存在哪里、什么时候消失"。本文从底层出发,把这个真相彻底剖开。

进程的内存地图

程序运行起来后,操作系统会给这个进程分配一段虚拟地址空间。这段空间被划分成好几个区域。

  高地址
  +---------------------------+
  |            栈             |  <- 向下增长
  |            |              |
  |            v              |
  |                           |
  |            ^              |
  |            |              |
  |            堆             |  <- 向上增长
  +---------------------------+
  |   BSS(未初始化的全局变量)|
  +---------------------------+
  |   Data(已初始化的全局变量)|
  +---------------------------+
  |   Text(代码)            |
  +---------------------------+
  低地址

有趣的地方在于,栈和堆是从同一段地址空间的两端相向而生的。栈从高地址往低地址长,堆从低地址往高地址长。这样安排之后,两者各自朝自己的方向增长,只有在内存真正枯竭的极端情况下才会撞在一起。本文的主角,正是这两个区域——栈和堆。

栈 — 帧、LIFO 与速度

栈是管理函数调用的内存。每调用一次函数,栈上就会压入一个栈帧(stack frame)。这个帧里装着该函数的局部变量、参数,以及返回地址(return address)。

关键在于,这种压入与弹出严格遵循后入先出(LIFO,Last In First Out)。最后被调用的函数最先结束,它的帧也最先被弹出。这就跟叠盘子、再从最上面一个个拿下来一模一样。

  函数调用流程:  main() -> a() -> b()

  栈(向上堆叠):
  +-----------+
  |   b()     |  <- 正在执行(最上层)
  +-----------+
  |   a()     |
  +-----------+
  |  main()   |
  +-----------+

  b() 结束后,只有 b 的帧被弹出,控制权回到 a()

正是这种结构让栈快得惊人。分配和释放内存实际上就只是移动一个指针而已。CPU 内部有一个指向栈顶的栈指针(stack pointer)寄存器。压入一个帧时,把这个指针往下移相应的量(因为栈是向下增长的);弹出时再把它移回去。既没有复杂的管理逻辑,也不需要搜索空闲空间。只是一次算术运算而已。

void b() {
    int y = 20;   // 存入 b 的帧
}

void a() {
    int x = 10;   // 存入 a 的帧
    b();          // b 的帧被压入栈顶
    // b() 返回后,y 立刻消失
}

栈的另一个特点是大小固定,而且通常很小。操作系统会为每个线程预先分配一段固定大小的栈空间(常见的是 1~8MB)。这个上限正是后面要讲的栈溢出的成因。归纳一下:栈快、自动管理、生命周期与函数的生命周期精确对应、大小有限。

堆 — 动态而自由的空间

如果说栈被绑定在函数的生命周期里,那堆就是挣脱了这种束缚的空间。堆上的值即使函数结束了也能继续存活,程序也可以在运行时按需决定要分配多大的空间。不过,这份自由是有代价的。

要从堆上拿到内存,必须显式地提出请求。在 C 里,扮演这个角色的是 malloc

#include <stdlib.h>

int *make_array(int n) {
    // 向堆申请可容纳 n 个整数的空间
    int *arr = malloc(n * sizeof(int));
    for (int i = 0; i < n; i++) {
        arr[i] = i * i;
    }
    return arr;   // 函数结束后,这块内存依然存活
}

这里体现出与栈决定性的差异。make_array 返回后,局部变量 arr(指针本身)会从栈上消失。但 arr 曾经指向的那个堆上的数组依然留在原地。所以调用方可以拿到那个地址,继续使用它。

堆分配比栈慢,原因也在这里。堆管理器(allocator)必须去找出"哪里有一块大小合适的空闲空间"。随着内存被反复申请和归还,堆会像一块到处是洞的奶酪一样产生碎片(fragment),管理器不得不翻查自由列表(free list)或按大小分区之类的数据结构,才能挑出一块合适的位置。这种搜索和记账(bookkeeping)的开销,跟栈上单纯移动一次指针相比完全不是一个量级。

  栈分配:  移动栈指针(1 次运算)        -> 非常快
  堆分配:  搜索空闲空间 + 更新元数据      -> 相对较慢

归纳一下:堆是灵活的(大小和生命周期都自由),能让值跨越函数边界被共享,但分配慢、会产生碎片,而且迟早必须被清理。至于这个"清理"由谁来做,正是区分各门语言性格的一大分岔口,后面会讲到。

指针与引用 — 连接两个世界的桥梁

栈和堆是怎么连接起来的呢?答案是指针(pointer)或者引用(reference)。堆上的值是没有名字的。malloc 只是把一个地址交还给你。而装着这个地址的变量——通常就位于栈上——正是所谓的指针。

  栈                        堆
  +-----------+           +---------------------+
  | ptr       | --------> | 实际数据 [42]        |
  | (地址值)  |           | (由 malloc 分配)      |
  +-----------+           +---------------------+
    这个变量               这份数据
    住在栈上               住在堆上

换句话说,典型的格局是这样的:指针这个小小的值(通常是 8 字节的地址)待在栈帧里,而它指向的大块数据则待在堆上。这就好比用栈上的一个小把手,抓着堆上的一个大箱子。

这个概念到处都是,只是各门语言的表达方式不同罢了。C 的指针、C++ 的引用和智能指针、Java 的对象引用、Python 里的每一个变量、JavaScript 的对象——它们无一例外,共享着同一副骨架:"真正的数据待在堆上的某个地方,变量只是指向那个位置"。高级语言只是把这个事实藏起来了,并没有把它消灭掉。

理解了指针之后,那些长期让人头疼的现象一下子就全说通了。如果两个变量指向同一个堆对象,通过其中一个改动这个对象,另一个也会看到变化。这不是因为数据被复制了,而是因为只有地址被复制了。这一点,正是接下来要讲的值语义与引用语义的核心。

值语义 vs 引用语义

把一个变量赋值给另一个变量,或者把它传给函数的时候,究竟复制的是什么?这个问题的答案,正好把值语义(value semantics)和引用语义(reference semantics)区分开来。

在值语义下,被复制的是值本身。副本与原件完全独立,改动其中一个,另一个纹丝不动。像整数、浮点数这样的原始类型通常遵循值语义,它们一般直接存放在栈上。

在引用语义下,被复制的不是值,而是引用(地址)。于是副本和原件指向了同一个堆对象,一边的改动会在另一边显现出来。

# Python:整数的行为像值,列表的行为像引用
a = 5
b = a
b += 1
print(a, b)   # 5 6 — a 没变,两者相互独立

x = [1, 2, 3]
y = x          # 复制的是引用(指向同一个列表)
y.append(4)
print(x)       # [1, 2, 3, 4] — x 也跟着变了!

这种差异在各门语言里的规则各不相同,因而成了常见 bug 的来源。Java 把原始类型(int、double 等)按值传递,把对象按引用传递。JavaScript 也是把数字、布尔值当值处理,把对象、数组当引用处理。C++ 默认是按值复制,但可以通过引用(&)和指针(*)显式选择引用语义。

在实际工作中一旦漏掉这个概念,就会碰上"明明复制过了,原件却也变了"这种令人困惑的局面。解决办法是把意图讲清楚:如果真正需要一份独立的副本,就显式地做深拷贝(deep copy);如果本意就是共享,那就让引用保持原样。知道自己要的是哪一种,才是关键。

栈溢出 vs OOM — 两种死法

栈和堆耗尽的方式不同,程序因此死亡的方式也不同。

栈溢出(stack overflow)发生在栈空间被耗尽的时候。因为栈的大小是固定的(通常只有几 MB),压入的帧一多,就会突破这个上限。最常见的成因,就是停不下来的递归。

def recurse(n):
    return recurse(n + 1)   # 没有终止条件

recurse(0)
# RecursionError: maximum recursion depth exceeded(Python)
# 换成别的语言,也可能以 segmentation fault 的方式挂掉

每调用一次函数就压入一个帧,却始终没有被弹出,栈自然就撞上了上限。在 C 这类底层语言里,这正是那个恶名昭著的"stack overflow",也是段错误的常见成因。Python 则设了一道安全阀:在真正的栈溢出发生之前,自己先数递归深度,抢先抛出 RecursionError

OOM(Out Of Memory)发生在堆资源枯竭的时候。如果一直在堆上申请却不归还(内存泄漏),或者真的请求了一份大到系统扛不住的数据,堆就会见底。

  栈溢出:
    成因 - 调用/递归过深,帧数过多
    上限 - 线程栈大小(通常 1~8MB)
    症状 - 立即崩溃、RecursionError、segfault

  OOM(内存不足):
    成因 - 堆分配累积、内存泄漏、数据过于庞大
    上限 - 可用的物理/虚拟内存(通常数 GB 以上)
    症状 - 分配失败、OOM Killer、逐渐变慢直至死亡

这两种死法的性格并不一样。栈溢出通常瞬间、决定性地爆掉,也很容易复现,成因(大多是递归)一目了然。OOM 却往往是慢慢逼近的。内存泄漏可能要积攒好几个小时,才会在某一刻让系统陷入交换而变得步履蹒跚,或者被操作系统的 OOM Killer 强制杀掉进程。所以 OOM 的诊断更棘手,需要用到分析器(profiler)、堆转储(heap dump)之类的工具。

为什么递归深度很重要

递归很优雅,但它是一种正面撞上栈上限的技巧。每一次递归调用都会压入一个栈帧,所以递归的深度,就是栈的使用量。深度一旦超过栈的上限,程序就会挂掉。

# 这段递归会随着列表长度一路变深
def sum_list(items):
    if not items:
        return 0
    return items[0] + sum_list(items[1:])   # 深度 = len(items)

sum_list(list(range(100000)))   # 会把栈撑爆

列表里有 10 万个元素,递归深度就是 10 万,这早已远远超出多数语言默认的栈上限。解决方法有两个方向。

第一,改写成迭代(iteration)。循环不会压入新帧,全程都在同一个帧里运转,因此不消耗栈。把上面那个函数换成一个简单的 for 循环,无论列表多长都是安全的。

第二,尾调用优化(tail call optimization,TCO)。如果递归调用是函数里最后执行的动作(返回值之上不再叠加任何其他运算),编译器就可以复用当前的帧,而不是压入一个新帧。这样一来,递归实际上就表现得像迭代一样,栈不会增长。

  普通递归:  每次调用都压入一个帧        -> 栈消耗随深度增长
  尾递归:    最后一次调用复用当前帧      -> 栈保持不变(若支持 TCO)
  迭代:      不压入任何帧                -> 栈始终不变

要注意的是,并不是所有语言都支持 TCO。Scheme 以及一些函数式语言在标准里保证了它,但 Python 是刻意不支持的(为了保留可读的堆栈跟踪),Java 和大多数主流语言默认也不做。所以,如果不先确认"这门语言到底做不做 TCO",就依赖深层尾递归,是很危险的。安全的默认姿态是:"可能会变深的递归,要么改写成迭代,要么使用显式的栈数据结构"。

堆的清理 — 所有权、GC、手动管理

在堆上分配出去的内存,迟早必须被归还。不归还的话,泄漏就会不断累积,最终演变成 OOM。这个"什么时候、由谁来归还"的问题,各门语言用三种方式来解决。这个选择,很大程度上决定了每门语言的性格。

1. 手动管理(manual,C 系列)。 由程序员亲自分配、亲自释放。用 malloc 拿到的东西,必须用 free 还回去。

char *buf = malloc(1024);
// ... 使用 buf ...
free(buf);   // 忘记释放会泄漏,释放两次会崩溃

这种方式给了程序员最大的控制权和性能。内存究竟在什么时候被归还,完全由程序员掌控。代价是出错的空间很大。忘记释放就是泄漏(leak);用已经释放过的东西就是 use-after-free;释放两次就是 double-free。这类 bug 也是安全漏洞的常客。

2. 垃圾回收(GC,Java·Python·JavaScript·Go)。 运行时会自动找出"已经没有任何人引用"的堆对象并回收它们。程序员完全不用操心释放这件事。

let obj = { data: [1, 2, 3] };
obj = null;   // 现在没有任何东西再指向那个对象了
// GC 迟早会自己把它回收掉,这里没有 free() 这种东西

GC 大幅提高了内存安全性。use-after-free、double-free 这一整类 bug 从根源上就消失了。但代价也是有的。GC 运行期间,程序可能会短暂停顿(stop-the-world 暂停)或变慢,而且程序员很难精确控制回收究竟发生在哪一刻。此外,GC 本身也要消耗 CPU 和内存。在对实时性要求很高的系统里,这种不可预测性有时会成为问题。

3. 所有权(ownership,Rust)。 Rust 选择了第三条路。既没有 GC,也没有手动的 free。取而代之的是,编译器通过所有权规则在编译期追踪每个值的生命周期,并在所有者离开作用域的那一刻,自动插入归还内存的代码。

{
    let s = String::from("hello");   // s 是这个堆字符串的所有者
    // ... 使用 s ...
}   // s 在这里离开作用域 -> 堆内存自动归还 (drop)

核心规则是:"一个值只能有一个所有者,所有者一旦消失,值也随之被释放"。再加上借用(borrowing)规则,编译器就能在编译期抓出 use-after-free 和数据竞争。结果就是,Rust 不靠运行时 GC 也能获得内存安全。代价是学习曲线——你得花时间跟编译器较劲,才能满足所有权和借用规则。

三种方式一览对比如下。

项目手动 (C)GC (Java/Python/JS)所有权 (Rust)
释放时机程序员显式指定运行时事后决定作用域结束时自动
性能最高,可预测GC 开销/停顿接近最高,可预测
安全性低(泄漏、UAF)高(编译期保证)
负担手动管理易出错难以控制回收时机学习曲线,需与编译器较劲
代表语言C,部分 C++Java、Python、JS、GoRust

没有唯一正确的答案。需要极致性能和控制力的嵌入式、系统级代码,会选手动或所有权;重视生产力和安全性的应用程序,会选 GC;想要安全和性能兼得,就选所有权。归根结底是一个"放弃什么、换来什么"的取舍问题。

实践中会遇到的陷阱

来看看到目前为止讲的这些概念,在实际代码里会以什么样的问题冒出来。

悬空指针与 use-after-free。 如果把栈上局部变量的地址返回到函数外面,由于那个帧早就已经被弹出了,返回的地址会指向一堆垃圾。这是 C 里很常见的失误。正确做法是在堆上分配后再返回,或者返回值的一份拷贝。

GC 语言里也会有内存泄漏。 有 GC 并不代表泄漏就不可能发生。只要某处还攥着一个引用,GC 就会认定那个对象还活着,不会回收它。把对象塞进缓存却从不清空,或者只注册事件监听器却从不移除,都会让引用一直留着,最终变成泄漏。

把大值按值复制的代价。 值语义虽然安全,但把巨大的数组或结构体按值传给函数,会整个复制一遍,从而拖慢速度。这正是 C++ 里把大对象用 const 引用传递的原因——既避免了复制,又阻止了修改。

不要在栈上放大数组。 栈很小。光是把一个几 MB 大小的数组当作局部变量放在栈上,就足以把栈撑爆。原则是,大数据要放在堆上。

无意间的共享。 在引用语义的语言里,如果传递了一个对象,还想着"这是份副本,随便改都没事"而去修改它,原件也会跟着变。你必须时刻清楚,自己面对的到底是共享还是复制。

结语

栈和堆不只是教科书上的两个词,而是程序在每一个瞬间,决定把值放在哪里、什么时候丢弃它的真实机制。栈是绑定在函数生命周期上的空间——快、自动,但小而固定;堆则是挣脱了这种束缚的空间——灵活,但慢,而且不会自己清理自己。指针把这两者连接起来,值语义和引用语义定下了复制的规则,递归深度考验着栈的极限,而所有权、GC、手动管理则负责堆的清理善后。

一旦这幅图景在脑子里扎下根,开头抛出的那些问题就都不再是谜了。函数结束后局部变量为什么会消失,递归写深了为什么会挂掉,"复制"了一份列表原件为什么却也变了,有的语言为什么需要 free、有的却不需要——这一切说到底都是栈和堆、以及穿梭其间的那些规则的故事。理解内存,说到底,就是理解这两个空间以及它们的规则。

参考资料