为什么栈内存这么快?
栈内存分配只需移动指针,访问局部数据连续且缓存友好,且每线程独享,避免锁竞争,速度极快。
为什么栈内存这么快?
为什么栈内存这么快?
- 本文基于 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 后继续执行位置 |
参数 b | 4 |
参数 a | 3 |
foo 的局部变量 z | x + 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 进行授权