返回文章索引

C++ 每日一题:栈空间是一开始就分配好的吗?

区分栈的虚拟地址空间与物理内存,说明主线程栈、pthread 栈和函数栈帧具体在什么时候分配。

GitHub 原文

问:栈空间是一开始就全部分配好的吗?

不会一开始就把栈的最大容量全部分配成物理内存。

程序或线程创建时,系统先建立栈的虚拟地址空间;函数调用时通过移动栈指针划出栈帧;真正访问新的栈页面时,才通过缺页异常按需分配物理页。

建立栈的虚拟地址空间
          ↓
函数移动栈指针,划出栈帧
          ↓
第一次访问新的栈页面
          ↓
缺页异常,内核分配物理页

一、栈大小不等于物理内存占用

Linux 下执行:

ulimit -s

经常会看到 8192,表示主线程栈的软限制通常为 8 MiB。

这个数值表示栈最多可以增长到多大,不代表程序启动时已经占用了 8 MiB 物理内存。

概念 含义
虚拟地址空间 线程可以使用的一段地址范围
物理内存 当前真正驻留在 RAM 中的页面

栈页面通常只有在实际读写时,才会获得物理内存。

二、函数调用时如何分配栈帧?

编译器在编译阶段确定函数需要的栈帧大小。函数进入时,通常只需要移动栈指针。

void function()
{
    int value = 10;
    char buffer[64];
}

在 x86-64 上,可能生成类似指令:

sub rsp, 80

这只是把栈指针向下移动,为当前函数划出空间,不是调用 malloc(),通常也不需要系统调用。

函数返回时恢复栈指针,原栈帧就失效了:

add rsp, 80
ret

局部变量也不一定真的位于栈上,编译器可能把它放进寄存器或直接优化掉。

三、物理内存什么时候分配?

当程序第一次访问尚未映射的栈页面时:

  1. CPU 发现页表中没有有效映射。
  2. 触发缺页异常,进入内核。
  3. 内核判断地址是否位于合法栈范围。
  4. 合法则分配物理页、更新页表并继续执行。
  5. 超出栈限制则通常触发 SIGSEGV

因此,移动栈指针主要消耗虚拟地址空间,触碰页面才会逐步占用物理内存。

四、主线程栈和普通线程栈

对比项 主线程栈 pthread / std::thread 栈
创建时机 程序启动时建立 创建线程时建立
虚拟空间 可以在限制范围内按需扩展 通常在线程创建时确定固定范围
大小控制 主要受 RLIMIT_STACK 限制 可用 pthread_attr_setstacksize() 设置
物理内存 访问页面时按需分配 同样按需分配
越界保护 超过合法范围会异常 通常带有不可访问的保护区

主线程的初始栈还会保存 argcargv、环境变量和辅助向量等程序启动信息。

普通线程虽然可能预留数 MiB 的栈虚拟地址空间,但不会立刻占用等量物理内存。不过线程数量过多仍会增加虚拟地址空间、页表和实际内存开销。

五、为什么递归过深会栈溢出?

每次函数调用都可能产生新的栈帧。递归层数持续增加时,栈指针会不断向边界移动。

void recursion()
{
    char data[1024];
    recursion();
}

当栈超过大小限制或触碰保护区域时,通常会收到 SIGSEGV

常见原因包括:

  • 无限递归或递归层数过深。
  • 在栈上创建很大的数组或对象。
  • 使用 alloca() 分配大量空间。
  • 创建线程时设置的栈空间过小。

大型缓冲区通常更适合使用 std::vector 在堆上分配。

六、如何观察?

查看栈大小限制:

ulimit -s

查看进程地址空间:

cat /proc/<pid>/maps

[stack] 表示主线程栈。

查看虚拟映射大小和实际驻留内存:

cat /proc/<pid>/smaps
  • Size:虚拟内存映射大小。
  • Rss:当前驻留在物理内存中的大小。

面试总结

面试时可以直接回答:

栈不会一开始就把最大容量全部分配成物理内存。程序或线程创建时先建立栈的虚拟地址空间,函数调用通过移动栈指针创建栈帧,不会每次调用 malloc。第一次访问新的栈页面时会触发缺页异常,由内核按需分配物理页。主线程栈可以在限制范围内扩展,pthread 栈通常在线程创建时确定固定的虚拟地址范围。

记住四点:

  1. 栈大小限制不等于物理内存占用。
  2. 创建栈帧通常只是移动栈指针。
  3. 第一次触碰新页面时才分配物理页。
  4. 主线程栈与 pthread 栈的虚拟空间分配方式不同。

参考资料