「这个变量什么时候被销毁?」答案不取决于它在哪一行写的,而取决于它的存储期(storage duration)。C++ 里有四种存储期:自动(automatic)、静态(static)、动态(dynamic)、线程(thread_local)。它们常和作用域(scope)被混为一谈——但作用域只决定「名字在哪段代码里看得见」,存储期才决定「对象活到什么时候」。本文用构造/析构的真实输出把四种存储期钉死。
1. 引子:存储期与作用域是正交的两件事
最容易踩的误解是认为「函数里的变量就是栈上的、全局的就是静态的」。其实两者独立:
作用域(名字可见范围) 块/局部 全局/命名空间 自动存储期 int x; —— 静态存储期 static int x; int g;(全局) 动态存储期 new 出来的 (谁 new 谁负责释放) 线程存储期 thread_local 局部 thread_local 全局关键例子:static int x;写在函数里——它有着静态存储期(程序整个运行期都在),却只有块作用域(只能在函数内访问)。所以「静态」修饰的是寿命,不是可见范围。
先给一张总表,后面每一节都在填这张表的细节:
| 存储期 | 对象放在哪 | 初始化时机 | 销毁时机 | 跨线程可见性 |
|---|---|---|---|---|
| 自动 automatic | 栈(函数帧) | 执行到声明处,每次进入块都构造 | 离开作用域的右大括号 | 各线程各自栈帧,天然不共享 |
| 静态 static | .data / .bss 段 | 程序启动前;局部静态例外——首次执行到声明处,且只构造一次 | 程序退出时,按构造的逆序 | 只有一份,读写要自己同步 |
| 动态 dynamic | 堆 | 执行到new/make_unique那一刻 | delete,或被智能指针随作用域释放 | 只有一份,读写要自己同步 |
| 线程 thread_local | 线程私有 TLS 区 | 本线程首次执行到声明处 | 本线程退出时,按构造的逆序 | 每线程一份,天然不共享 |
表里最该盯的是「初始化时机」和「销毁时机」两列——按标准定义,存储期说的是这两件事,跟名字在哪可见(作用域)没有关系。而「跨线程可见性」一列是选型时的决策依据:需要每线程独立状态就选thread_local,需要共享同一份就得自己加锁。下面逐个用输出证明。
官方文档:Storage duration (cppreference)
2. 自动存储期(automatic):栈上的局部变量
进入块时构造、离开块时析构,典型就是函数里的普通局部变量。
#include<cstdio>structBox{intid;Box(inti):id(i){std::printf("Box(%d) 构造 @栈\n",id);}~Box(){std::printf("Box(%d) 析构\n",id);}};voidf(){Boxa(1);// 自动存储期std::printf("f 执行中\n");}// a 在这里析构intmain(){f();std::printf("main 继续\n");}Box(1) 构造 @栈 f 执行中 Box(1) 析构 main 继续a在f()结束的右大括号处被自动析构——不需要、也不能手动delete。这就是 RAII 的基石。
3. 静态存储期(static):程序启动到结束都在
分三类:全局对象、命名空间作用域的静态对象、函数内的局部静态对象。它们都在程序启动前构造、程序结束时析构;局部静态额外保证「第一次执行到那一行才构造,且只构造一次」。
#include<cstdio>structWidget{intid;Widget(inti):id(i){std::printf("Widget(%d) 局部静态 构造\n",id);}~Widget(){std::printf("Widget(%d) 局部静态 析构(程序结束)\n",id);}};voidlazy(){staticWidgetw(7);// 首次进入 lazy 才构造,程序结束时才析构std::printf("lazy 用到 w(%d)\n",w.id);}intmain(){std::printf("main 开始\n");lazy();lazy();// w 已在第一次就构造好,这次不再构造std::printf("main 结束(注意 w 还没析构)\n");}main 开始 Widget(7) 局部静态 构造 lazy 用到 w(7) lazy 用到 w(7) main 结束(注意 w 还没析构) Widget(7) 局部静态 析构(程序结束)这直接实证了「局部静态变量在程序结束时才析构」:即便lazy()早已返回,w一直活着,直到main结束、整个程序退出时才调用~Widget()。而两次lazy()调用只触发一次构造,说明局部静态只初始化一次——所以它是实现「单例 / 懒初始化」的天然工具。
4. 动态存储期(dynamic):new 出来的堆对象
动态存储期的对象由new分配在堆上,寿命完全由程序员掌控:显式delete(或智能指针离开作用域)才销毁,否则一直活到程序结束(泄漏)。现代 C++ 用std::unique_ptr接管释放:
#include<cstdio>#include<memory>structNode{intv;Node(intx):v(x){std::printf("Node(%d) 堆上 构造(动态存储期)\n",v);}~Node(){std::printf("Node(%d) 堆对象 析构\n",v);}};intmain(){std::printf("main 开始\n");autop=std::make_unique<Node>(100);// 堆上分配,unique_ptr 管理std::printf("用 p->v = %d\n",p->v);std::printf("main 即将结束\n");// p 离开作用域 → 自动 delete → 触发 ~Node()}main 开始 Node(100) 堆上 构造(动态存储期) 用 p->v = 100 main 即将结束 Node(100) 堆对象 析构注意Node活在堆上(动态存储期),而p这个unique_ptr本身是栈上的自动对象。析构顺序说明:堆对象活到p析构那一刻才被delete释放——把释放和p的生命周期绑在一起,正是 RAII 消灭手动delete漏写的办法(裸new/delete的反例请见 Core Guidelines 一节)。
官方文档:new-expression / dynamic storage duration
5. 线程存储期(thread_local,C++11)
thread_local的对象每个线程各有一份,寿命与该线程等长:线程启动时构造本线程那份,线程结束时析构。下面用单线程演示语义(多线程下每条线程各自构造/析构自己的那份):
#include<cstdio>structTObj{TObj(){std::printf("TObj 线程局部 构造\n");}~TObj(){std::printf("TObj 线程局部 析构\n");}};thread_localTObj t;// 主线程的这份:启动构造、线程结束析构intmain(){(void)&t;// 首次接触 → 触发本线程的 thread_local 对象构造std::printf("main 开始\n");std::printf("main 结束(本线程的 t 随之销毁)\n");}TObj 线程局部 构造 main 开始 main 结束(本线程的 t 随之销毁) TObj 线程局部 析构单线程下「线程结束」就是「程序结束」,所以输出看起来和静态存储期很像;差别在于多线程时:线程 A 改t不会影响线程 B 的t,二者互不干扰。thread_local常用于「每线程独立计数器」「每线程缓冲」等无锁场景(C++ Core Guidelines 的并发章节也建议尽量用线程局部状态避免共享)。
官方文档:thread_local storage duration (C++11)
6. 程序内存分区图
四种存储期在进程的虚拟地址空间里大致落在这些区域(概念图,具体段名因平台而异):
高地址 ┌──────────────┐ │ 栈 stack │ ← 自动存储期对象(局部变量),向低地址增长 │ ↓ │ ├──────────────┤ │ ↑ │ │ 堆 heap │ ← 动态存储期对象(new),向高地址增长 ├──────────────┤ │ BSS 段 │ ← 未初始化的静态/全局变量(零初始化) ├──────────────┤ │ 数据段 .data│ ← 已初始化的静态/全局变量(静态存储期) ├──────────────┤ │ 代码段 .text │ ← 程序指令、字符串字面量(只读) 低地址 └──────────────┘ thread_local 由实现为每个线程单独安排(通常在线程私有的 TLS 区/独立栈旁)- 自动对象随函数调用压栈、出栈;
- 静态对象在数据段 / BSS,整个程序运行期都在;
- 动态对象在堆,靠
delete/ 智能指针回收; thread_local每个线程一份,线程退出时清掉自己的那份。
7. 静态初始化顺序问题(SIOF):最真实的静态存储期陷阱
规则先说清:同一个翻译单元内,静态对象按定义顺序初始化;但跨翻译单元的静态对象,初始化顺序是未指定的。所以一个全局对象如果在构造时读取了另一个.cpp里的全局对象,就可能读到「还没被构造、只有零初始化结果」的值——这就是静态初始化顺序问题(Static Initialization Order Fiasco,SIOF)。
// 反例,不要这么写:a.cpp 的全局对象依赖 b.cpp 的全局对象// ---- b.cpp ----intg_limit=10;// 动态初始化(值不是编译期常量)// ---- a.cpp ----externintg_limit;// 只是声明,构造先后没有保证structTable{intcap;Table():cap(g_limit){}// 若 a.cpp 先初始化,cap 可能拿到 0};Table g_table;// 全局对象:跨 TU 的初始化顺序不确定两条修法都比「赌顺序」靠谱:能让对象在编译期就定下来,就用constexpr(C++17 起还能配constinit语义);否则把全局对象换成函数内静态局部对象,也就是常说的 Meyers 单例:
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<cstdio>int&global_count(){// 需要时才构造,天然规避 SIOFstaticintcount=42;// 静态局部:首次调用才初始化returncount;}structConsumer{intsnapshot;Consumer():snapshot(global_count()){}// 构造时 count 一定已就绪};intmain(){Consumer c;std::printf("snapshot = %d\n",c.snapshot);global_count()+=1;std::printf("count 现在 = %d\n",global_count());}snapshot = 42 count 现在 = 43关键差别:g_table那版的初始化时机由链接顺序决定,而global_count()里那个count是第一次调用时才构造,既然Consumer的构造必须先调到它,次序就被语言规则强制排好了——顺序问题变成了「按需构造」问题。顺带一提,C++11 起函数内静态局部对象的初始化带线程安全保护(编译器插一个初始化守卫),所以它同时解决了顺序和并发两个问题,代价是每次访问都要过一遍守卫判断。
官方文档:isocpp.org FAQ — Constructors(含 static initialization order fiasco)
8. thread_local 的使用场景与代价
thread_local的典型场景有三类:每线程计数器 / 统计(如每线程请求数)、每线程缓冲(避免频繁加锁的内存池、格式化缓冲)、替代线程局部错误状态(类似errno那种「每次调用会被覆盖、但不该跨线程共享」的值)。它们的共同点是「本来就该每线程独立」,所以用thread_local不是绕过同步,而是让数据天然无需同步。
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<cstdio>structCounter{intn=0;Counter(){std::printf("Counter 构造(本线程首用)\n");}~Counter(){std::printf("Counter 析构(本线程退出)\n");}};intbump(){thread_localCounter c;// 函数内 thread_local:本线程首用时构造return++c.n;}intmain(){std::printf("第一次 = %d\n",bump());std::printf("第二次 = %d\n",bump());std::printf("main 即将结束\n");}Counter 构造(本线程首用) 第一次 = 1 第二次 = 2 main 即将结束 Counter 析构(本线程退出)单线程下看不出特别之处,把多线程的时间轴摊开就清楚了:
时间轴 ───────────────────────────────────────────────────────► 主线程 : 构造 c(首用)── bump=1 ── bump=2 ── 线程退出 ──► 析构 c 线程 A : 构造 A 自己的 c ── A 的计数从 1 开始 ──► A 退出,析构 A 的 c 线程 B : 构造 B 自己的 c ── B 的计数独立 ──► B 退出,析构 B 的 c 要点:N 条线程就有 N 份 c;谁构造的谁在退出时逆序析构,互不干扰但它不是白拿的,代价主要有三条:
- 访问变贵:TLS 变量的寻址要走 TLS 模型(local-exec / initial-exec / general-dynamic)。同一可执行文件内通常只是一次偏移寻址,但一旦跨动态库访问,就可能退化成一次
__tls_get_addr调用——比普通全局变量多一层开销。 - 内存按线程数放大:每线程一份,所以
对象大小 × 线程数。把「每线程缓存」开得很大,几十条线程时内存吃得很快。 - 线程创建 / 退出变贵:实现需要为每条线程登记 TLS 析构栈,线程退出时逐个逆序析构;析构里再抛异常会直接
std::terminate。
还有一条容易踩的:thread_local只保证「每线程独立」,不保证初始化与使用都在同一线程——把它的引用或指针传出去给别的线程用,问题立刻回来。真需要共享状态,还是老实加锁或用原子。
9. Core Guidelines 怎么看
- R.3:A raw pointer is a non-owning reference.裸指针只表示「借用」,不表示拥有。动态对象的所有权要用
std::unique_ptr/std::shared_ptr表达。 - R.11 / C.149:避免使用裸
new/delete。用std::make_unique代替;需要共享所有权才用std::make_shared。 - ES.28 / 常量与静态:局部静态适合「懒初始化单例」,但注意它的析构顺序在程序退出阶段,别在它的析构里依赖其他可能已销毁的静态对象。
- Con.4 / 并发:能用
thread_local就别用跨线程共享的可变状态,减少加锁。
一句话:默认让对象活在自动存储期(栈上、RAII);需要跨函数长期存在又只有一处拥有就用static/局部静态;需要堆上动态大小或跨作用域寿命就用std::unique_ptr;每线程独立状态用thread_local。
10. 完整示例(C++17,四种存储期同台)
一个程序同时放四种存储期,用构造/析构顺序亲眼看到它们各自的寿命边界:
// demo.cpp — 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo#include<cstdio>#include<memory>structTracer{constchar*name;Tracer(constchar*n):name(n){std::printf("%s 构造\n",name);}~Tracer(){std::printf("%s 析构\n",name);}};Tracer g{"全局静态"};// 静态存储期intmain(){Tracer a{"自动-栈"};// 自动存储期staticTracer s{"局部静态"};// 静态存储期(首用构造,程序结束析构)autod=std::make_unique<Tracer>("动态-堆");// 动态存储期std::printf("main 结束前\n");}// a、d 在此析构;s、g 在程序退出时析构全局静态 构造 自动-栈 构造 局部静态 构造 动态-堆 构造 main 结束前 动态-堆 析构 自动-栈 析构 局部静态 析构 全局静态 析构析构顺序读起来很顺:先d(堆对象,unique_ptr离开main作用域自动delete),再a(栈对象出作用域);等main返回、程序退出,才轮到两个静态对象按「后构造先析构」销毁s再g。这一行输出把「存储期决定寿命」讲得明明白白。
11. 延伸阅读
- cppreference — Storage duration:四种存储期的权威定义与初始化时机。
- cppreference — Storage class specifiers:
static/thread_local/extern的语义。 - Core Guidelines — R.3 / R.11:动态对象的所有权交给智能指针。
- cppreference — std::unique_ptr:管理动态存储期对象、零裸
delete的标准做法。
12. 一句话总结
存储期管「对象活多久」、作用域管「名字看得见多远」,两者正交;自动存储期随块生灭(栈),静态存储期从程序启动活到结束(含「程序结束时才析构」的局部静态),动态存储期由new/智能指针掌控(堆),线程存储期每线程一份随线程生灭——记住这张表,对象的生命周期就再也不会算错。