C++ 的变量都住在哪

局部变量、new 出来的对象和全局变量,分别放在内存哪里,又是什么时候没的。

目录
  1. 一次运行,各自住在哪
  2. 跟着两次调用走一遍
  3. 栈:快,但小
  4. 字节还在,对象已经没了
  5. static 和 const 管的不是一回事
  6. delete 之后,RSS 为什么不降
  7. 写代码时,先问谁拥有它
  8. 参考资料
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 改小一点,调用两次,一步一步看:

地址来自一次真实运行(GCC -O0)

调用 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 -O042,然后是 32571,每次运行第二个数都不一样
Clang -O2两个看不出规律的大数,比如 1460311720、1460310976

Clang -O0 那一行最有意思:第一次居然读到了 42。单步看它是怎么来的:

Clang -O0 的一次真实运行;为了拿到地址,程序末尾多打印了一行

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]; // 没写初值,进 .bss
int 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 字节的块,全部写一遍,再用不同的顺序释放其中一半。

示意:一页画了 8 个块(实际一页能放 36 个);RSS 按实测值换算
释放哪一半释放后 RSS再调 malloc_trim(0)
隔一个释放一个118 MiB118 MiB
前一半118 MiB65 MiB
后一半65 MiB65 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() 指针照样悬空。

参考资料