栈为什么比堆快:从分配方式到「批发-零售」链条

在同一个进程内,栈和堆使用相同的内存硬件,访问速度本身没有区别。真正的性能差异来自内核在分配和管理内存时为两者采取的不同策略。本文从分配方式、物理内存管理、缓存友好性三个角度说明原因,并借 sbrk、Slab、malloc 梳理从内核到用户态的内存「批发-零售」链条;最后讨论「栈比堆快」这一经验法则的适用边界。

1. 内存分配方式

栈:近乎零成本

栈上分配只需修改栈指针寄存器。在 x86-64 上,函数序言用 sub rsp, N 预留空间(如 sub rsp, 0x10 即 16 字节),一条 CPU 指令、不涉及内核,成本极低12。需要澄清:sub rsp, N 本身不会触发任何异常,只是寄存器算术;触发缺页的是后续对该新栈空间的首次访问(见下节)。

; x86-64 函数序言示例:分配 0x20 字节栈帧
    push    rbp
    mov     rbp, rsp
    sub     rsp, 0x20

堆:系统调用的开销

通过 malloc 申请内存时,若分配器内部池子不足,会通过 brkmmap系统调用向内核申请。用户态/内核态切换带来微秒级开销,相比一条 sub rsp 可高出数百倍甚至更多3

2. 物理内存管理

栈:缺页异常与按需映射

修改指针(sub rsp, N):只是“账面上的分配”。执行 sub rsp, 0x1000 时,CPU 只做寄存器运算,内核对此一无所知。进程虚拟地址空间中这段新栈区在页表里尚未映射到物理页(或映射到只读零页),只是被“预留”出一个地址范围,成本就是一条指令。

首次访问(例如 mov [rsp-8], rax):才是真正的“物理分配”。当第一次使用这片新栈空间时:(1) CPU 尝试写入该虚拟地址;(2) MMU 查页表发现该页无有效物理页框,无法完成转换;(3) MMU 触发缺页异常(#PF,x86-64 上为中断 14),CPU 转去执行内核的缺页处理(如 do_page_fault());(4) 内核从 CR2 读出故障地址,检查是否在进程合法栈区内(如 mm_structstart_stackulimit -s 限制),若合法则分配物理页、在页表中建立映射并标为可读写;(5) 返回用户态后,原指令重试,此时已有映射,写入成功。这一过程对开发者透明,且每个页只在首次触及该页时发生一次,即惰性分配(Lazy Allocation):只为实际使用的栈页分配物理内存,若函数分配了大数组但从未访问,就不会占用物理页2

读与零页:若首次操作是,内核可先将该虚拟页映射到全局只读的零页;只有后续发生时才触发写时拷贝(COW),分配真正的物理页并清零,与匿名堆区的零页机制一致。

主线程栈与线程栈:主线程栈可在合法范围内按需增长(访问新区域触发合法缺页即可);通过 pthread_create 创建的线程,其栈通常在创建时用 mmap 一次性映射固定大小(如 8MB),虚拟范围固定,不会像主线程那样向低地址方向动态增长,访问未映射区域仍会触发缺页并分配物理页。

需要强调的是:在发生缺页的那一刻,栈和堆走的是同一条内核路径(#PF → 分配物理页 → 建立映射,必要时清零),单看这一次缺页本身,栈并不比堆快。栈的「快」体现在:分配虚拟空间无需系统调用(§1);缺页通常只在首次触及该页时发生一次,成本被摊薄;一旦物理页已常驻,栈与堆的访问就是普通内存访问,没有差别。

类比sub rsp, N 像在借书卡(页表)上登记一个新书名(虚拟地址),只是记录;首次访问像第一次去书架上取书——管理员发现书(物理页)还在仓库,于是取书、上架、更新借书卡,你才能拿到;若该地址不在进程合法地址空间内,则相当于“查无此书”,会引发 SIGSEGV 等错误。

堆:mmap 与安全清零

通过 mmap 获取匿名内存时,内核会保证进程看到的是「零填充」:要么在缺页时分配并清零,要么先映射到全局零页,写时再分配(copy-on-write),避免读到其他进程残留数据45。Gorman《Understanding the Linux Virtual Memory Manager》Ch4 对用户态区段的描述5

With a process, space is simply reserved in the linear address space by pointing a page table entry to a read-only globally visible page filled with zeros. On writing, a page fault is triggered which results in a new page being allocated, filled with zeros, placed in the page table entry and marked writable.

无论哪种方式都会在首次写时产生分配/清零或 COW 开销。malloc 往往通过 mmapsbrk 拿到大块后再在用户态切分、复用,以摊薄这类成本。

3. 缓存友好性

栈:局部性更好

栈的访问模式是典型的 LIFO,当前活跃的局部变量多集中在栈顶附近,容易落在 CPU 的 L1/L2 缓存中,命中率高。

堆:访问模式更分散

堆上对象由程序显式管理,链表、树等结构容易在地址空间内分散,导致缓存行利用率低、更多访问主存。


4. 从内核到用户态:「批发-零售」链条

结合 sbrkSlabmalloc,可以把内存分配看成一条从内核到 CPU 的链条;栈之所以「快」,是因为它处在链条末端,几乎不经中间层。

4.1 一级批发:内核 Buddy(伙伴系统)

物理内存以(通常 4KB)为最小单位管理,由伙伴系统负责分配和回收:按 2^order 页块管理,不足时分裂大块、释放时与伙伴合并。粒度较粗,不适合直接满足「几十字节」的小请求65

4.2 二级批发:内核 Slab

Slab 分配器从伙伴系统拿到整页,再切成固定大小的对象并缓存,主要服务内核自身(如 task_structinode 等)。对象用完后可留在 Slab 中复用,减少对伙伴系统的调用,并缓解内碎片、提高缓存利用率65

4.3 用户态代理:malloc 与 sbrk

用户程序通过 malloc 获取堆内存。当内部池不足时,malloc 会调用 sbrkmmap

4.4 栈:无中间商的「自家后院」

栈不经过上述任何一层:分配就是改栈指针,无需系统调用;物理页在首次访问时按需分配(§2),LIFO 访问模式又利于缓存。因此处在链条最末端,面向 CPU,成本最低。

4.5 开销大致顺序(从慢到快)

层级 机制 特点
最慢 系统调用(sbrk/mmap) 用户态/内核态切换,微秒级
中等 用户态堆管理(malloc/free) 无模式切换,但有锁与查找
较快 内核 Slab(kmem_cache_alloc) 内核内复用,无系统调用
最快 栈指针调整(sub rsp) 纯用户态指令,纳秒级

5. 「栈比堆快」的边界

单纯比较「栈和堆谁快」容易误导,因为两者不在同一维度:栈更多是「使用已就绪内存」,堆还涉及「获取」和「管理」。

5.1 分配模式才是关键

事先在堆上分配好一块内存,再反复读写,其访问速度与栈上同规模数据可以非常接近——此时差异主要在「分配方式」,而非「存储介质」。

// 栈:分配 + 使用
void stack_func(void) {
    int arr[1000];   // 分配:改栈指针
    arr[0] = 42;     // 使用:普通内存访问
}

// 堆:一次性分配,反复使用
static int *heap_arr;

void heap_init(void) {
    heap_arr = malloc(1000 * sizeof(int));  // 仅此一次有系统调用/分配器开销
}

void heap_func(void) {
    heap_arr[0] = 42;   // 使用:与栈上访问同属「已就绪内存」
}

5.2 堆可以模拟栈的分配模式

Arena、pool 等分配器本质是在堆上模拟栈:一次性向系统要一大块,用指针顺序分配,最后整体释放。在这种模式下,堆上的「分配」成本可以接近栈。

5.3 值得关注的维度

维度
分配速度 固定、极快 视是否命中缓存、是否触发系统调用而定
可预测性 可能受碎片、锁竞争影响
适用场景 小数据、生命周期与调用栈一致 大数据、生命周期动态

栈的「快」是用约束换来的:大小有限、生命周期必须 LIFO。堆的灵活则伴随分配与管理开销。工程上更值得关心的是:在给定场景下,应优先用栈、对象池还是堆。

5.4 缺页路径上栈与堆等价

若只比较「第一次访问某页、触发缺页」的那条路径,栈和堆没有区别:都是 #PF → 内核分配物理页 → 映射(堆上匿名区还可能多一步清零或 COW)。因此在缺页场景下,栈并不比堆快。「栈比堆快」指的是分配虚拟空间的成本(栈几乎为零、堆可能涉及系统调用)以及缺页发生频率的摊薄(栈往往早已 fault in),而不是指单次缺页处理本身。

5.5 用户态申请堆内存是否一定触发缺页?

不一定。 内核源码可以验证两点:

  1. 默认情况:用户态通过 brk/sbrkmmap(MAP_ANONYMOUS)「申请」堆内存时,内核只建立或扩展 VMA(虚拟区间),并不立刻分配物理页。mm/vma.c 中的 do_brk_flags() 仅做 vm_area_alloc、设置区间与 flags、挂入红黑树,没有任何 alloc_pagesmm_populate。因此物理页要等到首次访问该区间时由缺页处理程序分配,那时才会触发一次 #PF。

  2. 会预填页、从而首次访问不触发缺页的情况

    • mmap(..., MAP_POPULATE)mm/mmap.cdo_mmap 在成功建立映射后,若 flags 含 MAP_POPULATE(且非 MAP_NONBLOCK),会设置 *populate = len,返回用户态前由 mm_populate(ret, populate) 在内核里把页 fault in,所以用户第一次访问时页已在,不会 #PF。
    • 扩展 brk 且进程曾 mlockallmm->def_flags & VM_LOCKEDmm/mmap.cSYSCALL_DEFINE1(brk, ...)do_brk_flags() 成功后若 mm->def_flags & VM_LOCKED,会调用 mm_populate(oldbrk, newbrk - oldbrk),在 brk 返回前就预填新堆区间的页,用户首次访问同样不会触发缺页。

因此:「申请」堆内存本身通常不触发缺页;缺页发生在首次访问新区间时。 只有在使用 MAP_POPULATEVM_LOCKED 时,内核会在申请路径上预填页,此时首次访问不再触发缺页(代价是 brk/mmap 变慢、可能失败)。


总结

  1. 同一进程内,栈和堆的「访问」速度无本质差别;差异主要来自分配方式物理页的建立方式(栈按需缺页,堆常伴随清零或 COW)。
  2. 在缺页发生的那一刻,栈与堆走同一条内核路径,栈并不比堆快;栈的快体现在分配虚拟空间几乎零成本(改栈指针)、缺页通常只发生一次且易被摊薄、以及 LIFO 带来的良好局部性。
  3. 从内核 Buddy → Slab → sbrk/mmap → malloc 到栈,是一条「批发-零售」链;栈在末端、无中间层,分配成本最低。
  4. 「栈比堆快」是有用的经验法则,但不是普适真理;工程上更值得关心的是「为什么快」和「在什么情况下快」,再按场景选择栈、池或堆。从选型与系统视角看,「谁快」往往不是唯一维度,I/O、并发与内存同内核的交互方式同样关键,可参见本博客《为什么「语言速度」是伪命题》8

扩展阅读

Intel SDM Vol.3A 第 6 章2

§6.14.2「64-Bit Mode Stack Frame」原文:

In IA-32e mode, the RSP is aligned to a 16-byte boundary before pushing the stack frame. The stack frame itself is aligned on a 16-byte boundary when the interrupt handler is called.

§6.15「Exception and Interrupt Reference」中 Interrupt 14—Page-Fault Exception (#PF):Exception Class 为 Fault;P=0、权限/写/保留位等触发。SDM 原文:

The exception handler can recover from page-not-present conditions and restart the program or task without any loss of program continuity.

Mel Gorman《Understanding the Linux Virtual Memory Manager》5

Linux 内核源码(代码片段与文件说明)

1. 进程地址空间:堆与栈的起止include/linux/mm_types.h

mm_struct 中描述堆与栈的字段;sys_brk 通过 mm->brkmm->start_brk 管理堆顶75

// 简化自 include/linux/mm_types.h(约 1100 行起)
struct mm_struct {
    // ...
    unsigned long start_code, end_code, start_data, end_data;
    unsigned long start_brk, brk, start_stack;   /* 堆起止、栈底 */
    unsigned long arg_start, arg_end, env_start, env_end;
    // ...
};

2. Buddy:zone 与 free_areainclude/linux/mmzone.hmm/page_alloc.c

每 zone 有 free_area[NR_PAGE_ORDERS],按 2^order 页块管理;分配入口为 __alloc_pages()9

// 简化自 include/linux/mmzone.h(约 133 行)
struct free_area {
    struct list_head free_list[MIGRATE_TYPES];
    unsigned long    nr_free;
};

// 每个 zone 含(同文件约 980 行):
// struct free_area free_area[NR_PAGE_ORDERS];

3. sys_brk 系统调用mm/mmap.c

用户态 brk/sbrk 的内核入口;通过 mm->brkmm->start_brk 与 VMA 扩展堆。默认只调 do_brk_flags() 扩展 VMA,不分配物理页;仅当 mm->def_flags & VM_LOCKED(如进程曾 mlockall)时才在返回前调用 mm_populate(oldbrk, newbrk - oldbrk) 预填页,此时用户首次访问新区间不会触发缺页7

// 简化自 mm/mmap.c(约 115 行起)
SYSCALL_DEFINE1(brk, unsigned long, brk)
{
    struct mm_struct *mm = current->mm;
    bool populate = false;
    // ...
    if (do_brk_flags(&vmi, brkvma, oldbrk, newbrk - oldbrk, 0) < 0)
        goto out;
    mm->brk = brk;
    if (mm->def_flags & VM_LOCKED)
        populate = true;
success:
    mmap_write_unlock(mm);
    if (populate)
        mm_populate(oldbrk, newbrk - oldbrk);   /* 仅 VM_LOCKED 时预填页 */
    return brk;
}

4. do_brk_flags 只建 VMAmm/vma.c

扩展堆时仅创建/扩展匿名 VMA,不分配物理页;物理页在首次访问时由缺页处理分配。

// 简化自 mm/vma.c(约 2714 行)— do_brk_flags 仅做 VMA 分配与合并,无 alloc_pages/mm_populate
int do_brk_flags(struct vma_iterator *vmi, struct vm_area_struct *vma,
                 unsigned long addr, unsigned long len, unsigned long flags)
{
    // ... may_expand_vm, security_vm_enough_memory_mm ...
    vma = vm_area_alloc(mm);   /* 只分配 VMA 结构 */
    vma_set_anonymous(vma);
    vma_set_range(vma, addr, addr + len, ...);
    vm_flags_init(vma, flags);
    // ... vma_iter_store_gfp, vma_link ... 无 mm_populate
    return 0;
}

5. Slab 分配接口mm/slub.c

当前默认 Slab 实现;kmem_cache_alloc 从指定 cache 取对象(如 task_structvm_area_struct 等)10

// 简化自 mm/slub.c(约 4202 行)
void *kmem_cache_alloc_noprof(struct kmem_cache *s, gfp_t gfpflags)
{
    void *ret = slab_alloc_node(s, NULL, gfpflags, NUMA_NO_NODE, _RET_IP_,
                                s->object_size);
    trace_kmem_cache_alloc(_RET_IP_, ret, s, gfpflags, NUMA_NO_NODE);
    return ret;
}
EXPORT_SYMBOL(kmem_cache_alloc_noprof);

6. mmap 与 MAP_POPULATEmm/mmap.c

默认 mmap(MAP_ANONYMOUS) 只建立 VMA,不预填页;若带 MAP_POPULATEdo_mmap 成功后会设 *populate = len(约 562–565 行:(flags & (MAP_POPULATE | MAP_NONBLOCK)) == MAP_POPULATE),返回前在 vm_mmap_pgoff 里调 mm_populate(ret, populate),在内核内把页 fault in,用户首次访问不再触发缺页。

本文引用已用 pdftotext 与本地 kernel 源码校对。

References

  1. System V ABI - AMD64 - Register and Stack Layout - x86-64 调用约定与栈布局(RSP、red zone、16 字节对齐) 

  2. Intel® 64 and IA-32 Architectures Software Developer’s Manual, Vol. 3A - 第 6 章 Interrupt and Exception Handling、§6.14.2/§6.15 #PF  2 3

  3. mmap(2) - Linux manual page - mmap 系统调用;brk(2) - 堆顶与 sbrk/brk 

  4. What is the purpose of MAP_ANONYMOUS in mmap? - 匿名映射与零填充语义;匿名区采用 demand paging,读时映射零页或分配并清零,写时 COW/分配 

  5. Mel Gorman, Understanding the Linux® Virtual Memory Managerkernel.org PDFHTML 目录。Ch4/6/8 见扩展阅读  2 3 4 5 6 7

  6. Memory Management - The Linux Kernel documentation - Slab 分配器;Understanding the Linux Virtual Memory Manager - Slab 附录 - Buddy 与 Slab 概述  2

  7. Linux 内核 mm/mmap.cSYSCALL_DEFINE1(brk,...)mm->brk/mm->start_brk)。Bootlin - mmap.c  2 3

  8. 本博客 为什么「语言速度」是伪命题:I/O、并发、内存与内核 - 系统调用成本、内存池与 I/O 对实际性能的影响  2

  9. Linux 内核 mm/page_alloc.c__alloc_pageszone->free_area)、include/linux/mmzone.hstruct free_area)。Bootlin - page_alloc.c 

  10. Linux 内核 mm/slub.ckmem_cache_alloc)、mm/slab.cinclude/linux/sched.hBootlin - slub.c 

My Github Page: https://github.com/liweinan

Powered by Jekyll and Theme by solid

If you have any question want to ask or find bugs regarding with my blog posts, please report it here:
https://github.com/liweinan/liweinan.github.io/issues