文章

为什么栈内存这么快?

栈内存分配只需移动指针,访问局部数据连续且缓存友好,且每线程独享,避免锁竞争,速度极快。

为什么栈内存这么快?

为什么栈内存这么快?

  • 本文基于 YouTube 频道 Core Dumped 视频《WHY IS THE STACK SO FAST?》整理

背景介绍

低级语言(如 C/C++)需要程序员明确指定变量大小。无法定义动态大小的数组,除非给定固定大小。

1
2
int arr[10];  // 大小固定,编译期已知
int arr[];    // 编译器报错,不允许

这样限制导致数组大小在程序生命周期内不可变。

栈是什么?

  • 栈是一种 后进先出(LIFO) 数据结构,最后压入的元素最先弹出。
  • 在程序中,栈用来管理函数调用、局部变量。
  • 栈空间大小有限,超出则会产生“栈溢出”。

操作系统与内存管理基础

  • 程序执行时不能随意读写内存,必须由操作系统分配。
  • 操作系统为每个进程分配一块连续的内存空间。
    • 如果访问未分配内存,程序崩溃,报“段错误”(Segmentation Fault)
  • 操作系统分配内存时,并不会精确按照程序请求的字节数分配,而是以较大的连续内存块(chunk)为单位分配。
    • 这块内存块在程序内部可以被进一步管理和细分,但如果程序没有合理组织内存,可能会产生内存碎片。
  • 如果数据没有连续存储,即使总空闲内存足够,也无法分配称为“外部碎片”。

虚拟内存与缓存

  • 操作系统借助磁盘空间(swap)实现虚拟内存,扩展可用内存,但速度远慢于物理内存。
  • CPU 内置高速缓存(Cache)存储最近使用的数据,提高访问速度。
  • 数据越紧凑,越容易放入缓存,提高缓存命中率,提升程序性能。

栈为何快速?

  • 程序启动时,操作系统为栈预先分配一块固定大小内存区域。
  • 栈由 栈指针(Stack Pointer, SP) 管理,通常存储于 CPU 寄存器。
  • 内存分配只需简单调整指针,无需复杂计算或系统调用。

栈结构示意(内存地址由高到底排列):

+--------------------------+ ← 高地址
|          栈空间           | ← 存储函数局部变量、返回地址等
|                          |
|       ...                |
|                          |
|    栈顶指针 (SP) 指向这里   | ← 当前栈顶位置
+--------------------------+ ← 低地址
  • 入栈时,SP 向低地址方向移动,分配内存。
  • 出栈时,SP 向高地址方向移动,释放内存。
  • 这种顺序保证了高效且无碎片。

函数调用与栈帧

什么是栈帧?

栈帧,也称为活动记录(Activation Record),是函数调用时在栈上为该函数调用分配的一块内存区域。

它存储了函数执行期间所需的所有信息,包括:

  • 函数参数(传入的参数)
  • 返回地址(函数执行完后,程序返回到的指令地址)
    • 具体来说,它存的是一个“内存地址”,即函数调用后,程序执行应继续的指令所在内存的地址(PC,程序计数器)。
    • 这是一个机器码指令的地址,CPU 跳转到这里开始执行后续代码。
  • 局部变量(函数内部定义的变量)
  • 保存的寄存器状态(部分架构下)
  • 调试信息、对齐填充等(具体视编译器和架构而定)

栈帧的结构示意

高地址
+--------------------------+
| 上一个栈帧的内容(调用者)   |
+--------------------------+
|        返回地址           | ← 函数执行完跳转回调用点的地址
+--------------------------+
|        函数参数           | ← 传给函数的参数
+--------------------------+
|      保存的寄存器值        | ← 用于恢复调用前CPU状态(部分架构)
+--------------------------+
|        局部变量           | ← 函数内定义的变量
+--------------------------+
|        临时变量           | ← 计算时产生的临时数据
+--------------------------+
|       栈顶指针 (SP)       | ← 当前函数栈帧底部(栈顶)
+--------------------------+
低地址

栈帧的生命周期

函数调用时
  • CPU 将返回地址压入栈中,保存调用位置。
  • 将函数参数按照调用约定压入栈中。
  • 栈指针(SP)移动,给局部变量留出空间。
  • 保存需要保护的寄存器值(调用者保存寄存器)到栈中。
函数执行期间
  • 函数可以访问其局部变量和参数。
  • 使用栈空间存储临时计算结果。
函数返回时
  • 将返回值写入规定的位置(寄存器或栈,小型放寄存器,大型放调用者分配的内存空间)。
  • 恢复寄存器状态。
  • 将栈指针复位到调用前位置,弹出当前栈帧。
  • 跳转到返回地址继续执行调用者代码。

函数调用的示意流程

假设主函数调用 foo(a, b)

调用前栈状态(SP指向栈顶)
+-----------------+

调用 foo:

1. 先把返回地址压入栈(执行完foo要回来的地方)
2. 将参数a, b按约定顺序压入栈
3. 调整栈指针,为foo的局部变量分配空间
4. 进入foo函数体,使用局部变量

执行完foo:

5. foo将返回值放入指定寄存器或栈位置
6. 恢复栈指针,弹出foo的栈帧
7. 跳转到返回地址,继续主函数执行

为什么使用栈帧?

  • 实现函数的调用和返回机制:保存调用现场和返回地址。
  • 管理函数参数和局部变量:让函数内部变量隔离,防止干扰。
  • 支持递归调用:每次递归生成独立栈帧,互不影响。
  • 节省空间和提升性能:通过栈指针移动快速分配和释放内存。

多线程与栈帧

  • 每个线程有自己的独立栈空间和栈帧链。
  • 栈帧结构相同,但栈顶指针独立,互不干扰。

典型调用约定影响

  • 调用约定(Calling Convention)规定参数传递方式(寄存器或栈)、返回值位置、栈清理责任等。
  • 常见约定有 cdecl、stdcall、fastcall 等。
  • 不同约定会影响栈帧布局和大小。

代码示例

1
2
3
4
5
6
7
8
9
int foo(int x, int y) {
    int z = x + y;        // 局部变量 z
    return z * 2;
}

int main() {
    int a = 3, b = 4;
    int c = foo(a, b);
}

main 调用 foo 时的栈帧布局可能如下:

地址内容
main 的栈帧
返回地址main 中调用 foo 后继续执行位置
参数 b4
参数 a3
foo 的局部变量 zx + y = 7
foo 栈帧底部

栈帧错误示例

  • 栈溢出:递归调用过深,栈帧层层叠加,超出预分配空间。
  • 访问非法栈地址:指针越界或释放后访问导致程序崩溃。

内存分配示例对比

栈内存分配流程

1
2
3
allocate(size):
    SP = SP - size
    return SP
  • 操作简单,仅移动栈顶指针。
  • 无需搜索空闲块,无碎片问题。
  • 分配速度快。

堆内存分配流程(简化)

1
2
3
4
allocate(size):
    查找合适空闲块
    分割空闲块
    返回地址
  • 需要复杂数据结构维护空闲块。
  • 可能触发系统调用分配新内存(chunk)。
  • 分配速度慢。

栈的局限性

  • 大小固定,通常几 MB,不能动态增长。
  • 不支持运行时动态大小数组。
  • 深度递归可能导致栈溢出。
  • 多线程程序中,每个线程有独立栈,不能共享。

栈和堆在多线程中的关系与影响

栈(Stack)与多线程

  • 每个线程都有自己的独立栈空间 操作系统为每个新线程分配独立的栈空间,用于存放该线程的函数调用栈帧、局部变量、返回地址等。 → 线程间栈空间相互隔离,避免竞争。
  • 栈空间大小固定且较小 线程栈通常是有限的(比如几MB),过多线程或深度递归会导致栈溢出(Stack Overflow)。 → 需要合理控制线程数量和递归深度。
  • 栈上的数据只在线程内部有效 栈是线程私有的,局部变量在函数结束后释放,不能跨线程访问。
  • 栈空间增长/缩小通常受限 栈大小在创建线程时确定,不同系统/环境有不同默认大小,可设置。

堆(Heap)与多线程

  • 所有线程共享同一个堆 堆是进程级资源,所有线程共同使用,用于动态分配内存(new/malloc)。
  • 堆分配是线程安全的(但开销较大) 现代内存分配器(如glibc malloc、jemalloc、tcmalloc)通常实现了多线程安全机制,避免多线程同时操作堆时出现数据竞争。 → 这可能导致锁竞争或性能下降。
  • 并发分配/释放带来的碎片和锁竞争问题 多线程频繁分配释放堆内存可能导致碎片化加剧,内存分配器需设计高效算法减少锁粒度和竞争。
  • 线程局部存储(Thread Local Storage, TLS) 为减少堆上的锁竞争,有些程序会采用 TLS,给每个线程维护独立的内存池或缓存。

多线程下栈和堆的协作示意

线程1:       线程2:       线程3:
+---------+  +---------+  +---------+
| 栈空间1  |  | 栈空间2  |  | 栈空间3  |  <-- 独立
+---------+  +---------+  +---------+

     \           |           /
      \          |          /
       \         |         /
         +----------------+
         |    堆(Heap)   |  <-- 共享
         +----------------+

主要影响与建议

方面说明建议
栈大小栈空间小,线程多时容易溢出适当调整线程栈大小,避免深度递归
堆内存分配多线程访问堆存在锁竞争,可能影响性能使用高效的多线程内存分配器,或 TLS
数据安全栈数据线程私有,堆数据共享需同步跨线程访问堆数据需做好同步和线程安全
内存碎片多线程频繁堆分配/释放可能导致内存碎片减少频繁小内存分配,使用对象池等技术
线程局部缓存为减少堆锁竞争,分配器常用线程局部缓存提高性能利用 TLS 或专用内存池

总结

  • 栈因预分配固定内存和简单指针操作而非常快速。
  • 紧凑内存布局提高 CPU 缓存命中率,进一步提升性能。
  • 适合存储局部变量和短生命周期数据。
  • 堆适合动态、生命周期复杂的数据,分配更慢,且涉及系统调用分配大块chunk。
本文由作者按照 CC BY 4.0 进行授权