Skip to content
Go back

堆和栈的区别——不只是"一个快一个慢

堆和栈:远不止”自动释放 vs 手动释放”

一句话结论(30s)

栈和堆的根本区别在”谁管理内存”,因为栈由编译器 + CPU 硬件自动压栈弹栈、分配只需一条指令,而堆由用户态分配器手动管理、需搜索空闲块,所以栈快、无碎片但小,堆慢、有碎片但大而灵活。

核心原理(2min)

栈是 LIFO 的连续内存,函数调用压栈帧、返回自动恢复,分配仅 sub rsp, N 一条指令;堆由 malloc 分配,小内存走 brk、大内存走 mmap,需分配器管理空闲块。栈比堆快的原因:分配快、释放快、缓存友好、无碎片。

底层深入(5-10min)

程序的内存布局

一个运行中的程序,其虚拟地址空间从低到高大致是:

低地址
  .text    (代码段)    ← 只读,存放机器指令
  .rodata  (只读数据)  ← 字符串常量等
  .data    (已初始化数据) ← 全局变量/静态变量(已赋值)
  .bss     (未初始化数据) ← 全局变量/静态变量(未赋值,零填充)
  heap     (堆)        ← 向上增长,malloc/free 管理
  ↓  ↓  ↓
  ...
  ↑  ↑  ↑
  stack    (栈)        ← 向下增长,编译器自动管理
  kernel   (内核空间)  ← 用户态不可访问
高地址

栈和堆在地址空间的两端,相向而行,最大化利用中间的空闲区域。为什么会这样设计? 因为栈要连续、堆要灵活,两者增长方向相反才能共享同一块空闲内存——各自往中间长,谁先用完中间空间谁先触顶,比固定分割更省内存。

栈:自动管理,有严格的 LIFO 顺序

每次函数调用,CPU 在栈上压入一个新的栈帧(Stack Frame),包含:

栈帧结构:
  [参数 N]
  [参数 N-1]
  ...
  [参数 1]
  [返回地址]         ← call 指令自动压入
  [旧的 EBP/RBP]    ← 保存调用者的栈基址
  [局部变量]
  [被调用者保存的寄存器]

函数返回时,leave + ret 指令自动恢复调用者的栈帧。整个过程完全由编译器生成的代码 + CPU 硬件协作完成,没有任何”分配器”的参与。

💭 思考:为什么函数返回能”一条指令”恢复现场,不需要记住”是谁调用的我”?——因为调用时 call 指令已经把返回地址压进栈、编译器把旧栈基址(EBP/RBP)也压进栈,LIFO 保证栈顶躺着的永远就是”上一个栈帧的恢复信息”。返回时 leave + ret 直接从栈顶弹出即可,根本不用查表、不用搜索。这就是”编译器 + CPU 自动管理”的物理来源:正是栈的严格 LIFO,才让”恢复”退化成”弹栈”,而堆上做不到——堆里没有这种”最近使用的恢复信息一定在顶部”的确定性。

栈的限制:大小有限(Linux 默认 8MB,ulimit -s 查看),因为栈必须是连续内存。递归过深 → StackOverflowError。为什么栈一定要连续、不能按需扩容? 因为栈依赖 sub rsp, N 这一条指令移动栈指针来”瞬间”分配,只有连续内存 + 单调的 LIFO 才能保证指针移动即分配;一旦允许碎片,分配就退化成了堆那套搜索逻辑。

堆:灵活分配,需要运行时管理

malloc/new 从堆上分配内存,大小和生命周期完全由程序员控制。但”灵活”是有代价的——需要内存分配器在用户态管理空闲块:

// glibc malloc 的两种底层系统调用:
// 小内存 (< 128KB): brk() — 移动堆顶指针
// 大内存 (> 128KB): mmap() — 在文件映射区分配独立内存块

brk vs mmap

malloc(1KB) vs malloc(1MB):前者走 brk 从堆分配,后者走 mmap 从文件映射区分配。这就是为什么申请大内存和申请小内存的底层路径完全不同。

💭 思考:为什么大内存要走 mmap、小内存走 brk,而不是统一用一套?——brk 只能整体移动堆顶:如果中间夹着还没释放的块,堆顶就下不去,那块内存永远还不了 OS,表现为”程序内存只增不减”。大块用 brk 一旦夹在中间,后果尤其严重。mmap 是独立的页映射,munmap 后立即归还 OS,适合临时大块。反过来,小块若用 mmap,每次都要系统调用 + 按页对齐浪费,远不如 brk 移动指针便宜。两条路径各管一段,本质是”连续堆顶”与”独立映射”两种内存模型的互补。

为什么栈比堆快?

  1. 分配:栈上分配只需一条 CPU 指令(sub rsp, N 移动栈指针),堆分配需要分配器搜索空闲块
  2. 释放:栈上释放只需 add rsp, N(恢复栈指针),堆释放需要分配器合并相邻空闲块
  3. 缓存友好:栈内存是连续访问的(LIFO),L1/L2 Cache 命中率极高;堆内存随机分配导致缓存频繁失效
  4. 无碎片:栈是连续区域的单调进出,永远不会有碎片;堆需要维护空闲链表和碎片整理

💭 思考:为什么栈比堆快,除了”分配释放快”还有一层隐藏原因?——看缓存:栈是 LIFO 连续访问,最常用的数据都聚在栈顶附近,L1/L2 缓存命中率极高;堆上对象随机散布,访问在内存里跳来跳去,缓存频繁失效。所以”快”不只是分配那一刻快,而是整个生命周期里读它也快。反过来说,堆的”慢”也有一部分是缓存失效带来的,而不只是分配器的搜索开销——这也是为什么局部变量访问比堆上字段访问更便宜。

总结

管理方式编译器自动程序员手动 / GC
分配速度1 条 CPU 指令分配器搜索 + 系统调用
大小限制固定(~8MB)可达物理内存上限
生命周期函数返回即释放程序员控制
碎片问题
适用场景局部变量、函数调用动态大小的对象、长生命周期数据

章末提问

  1. 栈为什么比堆快,快在哪一步? 结论:快在”分配和释放都只是一条移动栈指针的 CPU 指令”(sub rsp, N / add rsp, N),而堆要走用户态分配器搜索空闲块、还要处理合并;所以栈没有搜索开销和碎片问题。

  2. 为什么栈会有 8MB 这种固定大小限制,堆却能很大? 结论:因为栈靠连续内存和单调 LIFO 保证”指针移动即分配”,无法按需任意扩容;而堆由分配器动态管理,可按需向 OS 申请,上限接近物理内存。

  3. malloc 申请 1KB 和 1MB,底层走的路径一样吗? 结论:不一样,小内存(<128KB)走 brk 移动堆顶指针、大内存走 mmap 映射独立内存块;因为大块用 brk 会导致中间碎片无法归还,mmap 可以 munmap 立即释放。


Share this post on:

Previous Post
操作系统内存不足处理:从kswapd到OOM Killer的完整链条
Next Post
中断机制——从缺页中断到系统调用