C++ 的变量都住在哪
局部变量、new 出来的对象和全局变量,分别放在内存哪里,又是什么时候没的。
std::vector<int> values(1'000'000, 7);std::printf("%zu\n", sizeof(values));输出 24。
一百万个 int 是 400 万字节,sizeof 只算了 24。这 24 字节是三个指针:元素从哪开始、用到哪、分配的空间到哪为止。那 400 万字节在别处。
所以“values 在栈上还是堆上”,问的其实是两样东西:values 这个对象在栈上,它管着的元素在另一块动态分配的内存里。两句话同时成立。
C++ 标准里其实没有“栈”和“堆”,它讲的是存储期,也就是一个对象从什么时候活到什么时候:
| 存储期 | 谁 | 活多久 | Linux 上通常在 |
|---|---|---|---|
| 自动 | 局部变量、函数参数 | 到所在作用域结束 | 栈 |
| 静态 | 全局变量、static 局部变量 | 整个程序 | .data、.bss |
| 动态 | new 出来的对象 | 直到 delete | 堆,大块单独映射 |
| 线程 | thread_local 变量 | 所在线程 | 每个线程一份 |
最右边一列是实现,不是规定。编译器可以把局部变量放进寄存器,甚至整个优化掉。下面说的都是真要占内存的情况,环境是 Linux x86-64、glibc 2.44,编译器是 GCC 16 和 Clang 23。
一次运行,各自住在哪
写个小程序,把各种东西的地址打出来,再读一遍 /proc/self/maps,看这些地址各落在哪块映射里:
int counter = 7; // 有初值的全局变量int zeroed; // 没写初值的全局变量
void work() { int count = 3; static int calls = 0; ++calls; std::vector<int> values(1'000'000, 7); int* one = new int(42); const char* text = "hello"; // 打印 &count、values.data()、one、text、&work 等地址, // 再把 /proc/self/maps 整个打出来 delete one;}按地址从高到低排:
地址最高的是栈。count、values、one、text 四个局部变量挤在一起,前后只差几十个字节。后三个其实都是“把手”:values 管着的 400 万字节在下面的匿名映射里,one 指向堆上的那个 int,text 指向只读段里的 "hello"。
values 的元素没进 [heap],而是单独占了一块匿名映射。glibc 的 malloc 默认碰到超过 128 KiB 的请求,就直接向内核要一块独立的内存(mmap),释放时也直接还回去;小块才从 [heap] 里切。
地址最低的是程序本身,按权限分段:r-x 是代码,能读能执行;r-- 是只读数据,字符串字面量在这儿;rw- 是能读写的全局数据。calls 写在函数里,却和全局变量住在一起,因为它是静态存储期。counter 在 .data,zeroed 和 calls 在 .bss,区别后面说。
还有那几段空着的:堆和程序之间隔了快 1 GiB,堆和匿名映射之间隔了 42 TiB。这些都是虚拟地址,只是编号,没映射就不占物理内存。x86-64 上一个进程有 128 TiB 的用户地址空间,这个程序映射出来的加起来才 11 MiB。每次运行,地址的高位都不一样,这是 ASLR 在随机化布局,各段的先后顺序不变。
跟着两次调用走一遍
把 work 改小一点,调用两次,一步一步看:
调用 work 时,整个栈帧是一次划出来的:栈顶往下一挪,count 和 values 的位置就都有了,只是里面的值还说不准。分配就是挪一下指针,所以快。
static int calls = 0; 这一行执行到时什么也不做。calls 在程序启动前就已经是 0 了,所以它能跨调用累加。
values 自己在栈帧里,元素在堆上。函数结束时,values 先析构,把元素还给分配器,然后栈帧才弹出。这就是 RAII:资源跟着拥有它的对象走,对象一死,资源跟着释放。中途抛异常、栈展开时也一样。
第二次调用,栈帧和元素块落在了一模一样的地址上。这不是巧合:栈本来就是同一段内存反复用,分配器也会把刚收回的块先给下一个请求。
栈:快,但小
栈的代价是只能后进先出,而且总量有限。Linux 主线程的栈默认 8 MiB(ulimit -s 是 8192),新线程默认也是 8 MiB。放一个 300 万个 int 的局部数组,12 MB,直接段错误:
int main() { int a[3'000'000]; for (int i = 0; i < 3'000'000; ++i) a[i] = i; std::printf("%d\n", a[2'999'999]);}GCC、Clang,开不开优化都一样崩。换成 std::vector<int> a(3'000'000); 就没事:栈上只剩 24 字节的把手,元素在动态存储里。
动态存储管的就是栈管不了的三种情况:太大;大小到运行时才知道;寿命和调用顺序对不上,比如 A、B、C 依次创建,却按 B、A、C 的顺序销毁,或者要比创建它的函数活得更久。
反过来,“堆”不等于“手动释放”,std::vector、std::unique_ptr 都会在析构时替你释放;“栈”也不等于“读写更快”,分配好以后,访问速度主要看缓存。
字节还在,对象已经没了
int* bad() { int value = 42; return &value;}
int main() { int* p = bad(); std::printf("%d\n", *p); std::printf("%d\n", *p);}value 在 bad 返回时就死了,p 成了悬空指针,读 *p 是未定义行为。实际跑一下:
| 编译 | 输出 |
|---|---|
| GCC,-O0 或 -O2 | 段错误 |
| Clang -O0 | 42,然后是 32571,每次运行第二个数都不一样 |
| Clang -O2 | 两个看不出规律的大数,比如 1460311720、1460310976 |
Clang -O0 那一行最有意思:第一次居然读到了 42。单步看它是怎么来的:
bad 返回后,栈顶移了回去,可 value 那 4 个字节没人清,42 还在。第一次 printf 的参数 *p 在调用之前就读好了,读到的是旧值。可 printf 一进来,就在同一片栈上建自己的栈帧,正好把那几个字节盖掉。第二次再读,读到的是 printf 留在那儿的一个指针的高半截。
GCC 更干脆:它在编译时就看出你返回了局部变量的地址,给个警告,然后把返回值直接换成空指针,第一次读就崩。这反而是最好的结果。
三种结果都“合法”。未定义行为的意思是标准不管了,编译器怎么处理都行。偶尔读到 42,只说明那块内存还没被别人用上。
static 和 const 管的不是一回事
static 管的是存储期。函数里的 static int calls 名字只在函数里能用,存储却从程序开始活到结束。需要运行构造函数的局部 static 对象,在第一次执行到声明那行时才构造,之后不再重来;calls = 0 这种常量初始化,启动前就做完了。
.data 和 .bss 的区别,看文件大小就知道:
int a[25'000'000]; // 没写初值,进 .bssint b[25'000'000] = {1}; // 有初值,进 .data两种写法各编译一个程序,可执行文件一个 15,808 字节,一个 100,015,824 字节。.data 要把 1 亿字节的初值原样写进文件;.bss 只记一句“要 1 亿字节,全是 0”,装载时由内核给一片清零的页。标准规定静态存储期的对象先清零,全局的 int n; 一定是 0,.bss 就是这条规定的落地方式;函数里的 int n; 可没有这待遇,它的值说不准。
const 管的是能不能改,不决定住在哪。函数里的 const int x = 3; 照样是栈上的自动变量。字符串字面量是另一回事,它本身是静态存储期,放在只读段里:
const char* p = "hello"; // p 在栈上,"hello" 在只读段char a[] = "hello"; // a 在栈上,是一份 6 字节的副本a[0] = 'H'; // 改的是副本,没问题const_cast<char*>(p)[0] = 'H'; // 写只读页,段错误最后一行编译能过,强转把 const 去掉了;运行时往 r-- 的页上写,内核直接发段错误。
delete 之后,RSS 为什么不降
delete 完再看 top 的 RES 列(也就是 RSS,进程实际占着的物理内存),常常纹丝不动。这不一定是泄漏。
中间隔着一层分配器。delete 只是把块还给 glibc 的 malloc;分配器再往内核还,得按页来(一页 4 KiB),而且要一整页都空了才还得掉。
实测:new 一百万个 100 字节的块,全部写一遍,再用不同的顺序释放其中一半。
| 释放哪一半 | 释放后 RSS | 再调 malloc_trim(0) |
|---|---|---|
| 隔一个释放一个 | 118 MiB | 118 MiB |
| 前一半 | 118 MiB | 65 MiB |
| 后一半 | 65 MiB | 65 MiB |
释放前是 118 MiB。三种释放还回去的字节数一模一样,都是 53.5 MiB,结果却差了一截。
隔一个释放,每一页上都还留着活着的块,一页也还不回去,malloc_trim 也没辙。释放前一半,页是空出来了,可它们在堆的底部;free 只会顺手收缩堆顶,中间的空页得等 malloc_trim 一页页去还。释放后一半,空出来的正好是堆顶,堆当场就缩回去了。
大块走的是另一条路。new char[100'000'000] 单独占一块 mmap,delete 时直接 munmap:实测 RSS 从 3.7 MiB 涨到 99.3 MiB,释放后马上回到 3.9 MiB。
所以 RSS 没降,先分清两个问题:对象是不是已经销毁了,物理内存是不是已经还给系统了。前者要去找谁还拿着数据,后者要看分配器能不能还、愿不愿意还。
写代码时,先问谁拥有它
| 需求 | 先选 | 存储关系 |
|---|---|---|
| 作用域内的小对象 | 普通局部变量、std::array | 数据就在对象里,随作用域结束 |
| 很大,或者运行时才知道大小 | std::vector | 对象管着另一块存储,析构时释放 |
| 单个对象要独立存活、转交所有权 | std::unique_ptr | 拥有者可以移动,负责销毁 |
| 多次调用之间保留状态 | 全局变量或局部 static | 存储一直在,多线程下要考虑共享 |
先想清楚谁拥有这份数据、它要活多久,放哪儿自然就定了。放对了地方,也挡不住悬空指针:vector 扩容时会把元素整体搬到新地方,之前拿到的 data() 指针照样悬空。
参考资料
- C++ 工作草案:存储期、对象生命周期、局部变量与初始化。对象能活多久,以及作用域退出时发生什么;本文示例按 C++20 理解。
- C++ 工作草案:new 表达式、delete 表达式、vector 的容量与数据访问。区分对象自身和它管理的存储。
- ELF gABI:程序装载。文件里的数据怎样进入进程,零初始化的空间为什么不占文件体积。
- Linux 文档:/proc 文件系统、malloc_trim(3)、mallopt(3)。进程的内存映射,分配器什么时候归还空闲页,
mmap的阈值。