「这个变量什么时候被销毁?」答案不取决于它在哪一行写的,而取决于它的存储期(storage duration)。C++ 里有四种存储期:自动(automatic)、静态(static)、动态(dynamic)、线程(thread_local)。它们常和作用域(scope)被混为一谈------但作用域只决定「名字在哪段代码里看得见」,存储期才决定「对象活到什么时候」。本文用构造/析构的真实输出把四种存储期钉死。
1. 引子:存储期与作用域是正交的两件事
最容易踩的误解是认为「函数里的变量就是栈上的、全局的就是静态的」。其实两者独立:
text
作用域(名字可见范围)
块/局部 全局/命名空间
自动存储期 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,需要共享同一份就得自己加锁。下面逐个用输出证明。
2. 自动存储期(automatic):栈上的局部变量
进入块时构造、离开块时析构,典型就是函数里的普通局部变量。
cpp
#include <cstdio>
struct Box {
int id;
Box(int i) : id(i) { std::printf("Box(%d) 构造 @栈\n", id); }
~Box() { std::printf("Box(%d) 析构\n", id); }
};
void f() {
Box a(1); // 自动存储期
std::printf("f 执行中\n");
} // a 在这里析构
int main() {
f();
std::printf("main 继续\n");
}
text
Box(1) 构造 @栈
f 执行中
Box(1) 析构
main 继续
a 在 f() 结束的右大括号处被自动析构------不需要、也不能手动 delete。这就是 RAII 的基石。
3. 静态存储期(static):程序启动到结束都在
分三类:全局对象、命名空间作用域的静态对象、函数内的局部静态对象。它们都在程序启动前构造、程序结束时析构;局部静态额外保证「第一次执行到那一行才构造,且只构造一次」。
cpp
#include <cstdio>
struct Widget {
int id;
Widget(int i) : id(i) { std::printf("Widget(%d) 局部静态 构造\n", id); }
~Widget() { std::printf("Widget(%d) 局部静态 析构(程序结束)\n", id); }
};
void lazy() {
static Widget w(7); // 首次进入 lazy 才构造,程序结束时才析构
std::printf("lazy 用到 w(%d)\n", w.id);
}
int main() {
std::printf("main 开始\n");
lazy();
lazy(); // w 已在第一次就构造好,这次不再构造
std::printf("main 结束(注意 w 还没析构)\n");
}
text
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 接管释放:
cpp
#include <cstdio>
#include <memory>
struct Node {
int v;
Node(int x) : v(x) { std::printf("Node(%d) 堆上 构造(动态存储期)\n", v); }
~Node() { std::printf("Node(%d) 堆对象 析构\n", v); }
};
int main() {
std::printf("main 开始\n");
auto p = std::make_unique<Node>(100); // 堆上分配,unique_ptr 管理
std::printf("用 p->v = %d\n", p->v);
std::printf("main 即将结束\n");
// p 离开作用域 → 自动 delete → 触发 ~Node()
}
text
main 开始
Node(100) 堆上 构造(动态存储期)
用 p->v = 100
main 即将结束
Node(100) 堆对象 析构
注意 Node 活在堆上(动态存储期),而 p 这个 unique_ptr 本身是栈上的自动对象。析构顺序说明:堆对象活到 p 析构那一刻才被 delete 释放------把释放和 p 的生命周期绑在一起,正是 RAII 消灭手动 delete 漏写的办法(裸 new/delete 的反例请见 Core Guidelines 一节)。
5. 线程存储期(thread_local,C++11)
thread_local 的对象每个线程各有一份,寿命与该线程等长:线程启动时构造本线程那份,线程结束时析构。下面用单线程演示语义(多线程下每条线程各自构造/析构自己的那份):
cpp
#include <cstdio>
struct TObj {
TObj() { std::printf("TObj 线程局部 构造\n"); }
~TObj() { std::printf("TObj 线程局部 析构\n"); }
};
thread_local TObj t; // 主线程的这份:启动构造、线程结束析构
int main() {
(void)&t; // 首次接触 → 触发本线程的 thread_local 对象构造
std::printf("main 开始\n");
std::printf("main 结束(本线程的 t 随之销毁)\n");
}
text
TObj 线程局部 构造
main 开始
main 结束(本线程的 t 随之销毁)
TObj 线程局部 析构
单线程下「线程结束」就是「程序结束」,所以输出看起来和静态存储期很像;差别在于多线程时:线程 A 改 t 不会影响线程 B 的 t,二者互不干扰。thread_local 常用于「每线程独立计数器」「每线程缓冲」等无锁场景(C++ Core Guidelines 的并发章节也建议尽量用线程局部状态避免共享)。
6. 程序内存分区图
四种存储期在进程的虚拟地址空间里大致落在这些区域(概念图,具体段名因平台而异):
text
高地址 ┌──────────────┐
│ 栈 stack │ ← 自动存储期对象(局部变量),向低地址增长
│ ↓ │
├──────────────┤
│ ↑ │
│ 堆 heap │ ← 动态存储期对象(new),向高地址增长
├──────────────┤
│ BSS 段 │ ← 未初始化的静态/全局变量(零初始化)
├──────────────┤
│ 数据段 .data│ ← 已初始化的静态/全局变量(静态存储期)
├──────────────┤
│ 代码段 .text │ ← 程序指令、字符串字面量(只读)
低地址 └──────────────┘
thread_local 由实现为每个线程单独安排(通常在线程私有的 TLS 区/独立栈旁)
- 自动对象随函数调用压栈、出栈;
- 静态对象在数据段 / BSS,整个程序运行期都在;
- 动态对象在堆,靠
delete/ 智能指针回收; thread_local每个线程一份,线程退出时清掉自己的那份。
7. 静态初始化顺序问题(SIOF):最真实的静态存储期陷阱
规则先说清:同一个翻译单元内,静态对象按定义顺序初始化;但跨翻译单元的静态对象,初始化顺序是未指定的 。所以一个全局对象如果在构造时读取了另一个 .cpp 里的全局对象,就可能读到「还没被构造、只有零初始化结果」的值------这就是静态初始化顺序问题(Static Initialization Order Fiasco,SIOF)。
cpp
// 反例,不要这么写:a.cpp 的全局对象依赖 b.cpp 的全局对象
// ---- b.cpp ----
int g_limit = 10; // 动态初始化(值不是编译期常量)
// ---- a.cpp ----
extern int g_limit; // 只是声明,构造先后没有保证
struct Table {
int cap;
Table() : cap(g_limit) {} // 若 a.cpp 先初始化,cap 可能拿到 0
};
Table g_table; // 全局对象:跨 TU 的初始化顺序不确定
两条修法都比「赌顺序」靠谱:能让对象在编译期就定下来,就用 constexpr(C++17 起还能配 constinit 语义);否则把全局对象换成函数内静态局部对象,也就是常说的 Meyers 单例:
cpp
// demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo
#include <cstdio>
int& global_count() { // 需要时才构造,天然规避 SIOF
static int count = 42; // 静态局部:首次调用才初始化
return count;
}
struct Consumer {
int snapshot;
Consumer() : snapshot(global_count()) {} // 构造时 count 一定已就绪
};
int main() {
Consumer c;
std::printf("snapshot = %d\n", c.snapshot);
global_count() += 1;
std::printf("count 现在 = %d\n", global_count());
}
text
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 不是绕过同步,而是让数据天然无需同步。
cpp
// demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo
#include <cstdio>
struct Counter {
int n = 0;
Counter() { std::printf("Counter 构造(本线程首用)\n"); }
~Counter() { std::printf("Counter 析构(本线程退出)\n"); }
};
int bump() {
thread_local Counter c; // 函数内 thread_local:本线程首用时构造
return ++c.n;
}
int main() {
std::printf("第一次 = %d\n", bump());
std::printf("第二次 = %d\n", bump());
std::printf("main 即将结束\n");
}
text
Counter 构造(本线程首用)
第一次 = 1
第二次 = 2
main 即将结束
Counter 析构(本线程退出)
单线程下看不出特别之处,把多线程的时间轴摊开就清楚了:
text
时间轴 ───────────────────────────────────────────────────────►
主线程 : 构造 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,四种存储期同台)
一个程序同时放四种存储期,用构造/析构顺序亲眼看到它们各自的寿命边界:
cpp
// demo.cpp --- 编译: g++ -std=c++17 -Wall -O2 demo.cpp -o demo
#include <cstdio>
#include <memory>
struct Tracer {
const char* name;
Tracer(const char* n) : name(n) { std::printf("%s 构造\n", name); }
~Tracer() { std::printf("%s 析构\n", name); }
};
Tracer g{"全局静态"}; // 静态存储期
int main() {
Tracer a{"自动-栈"}; // 自动存储期
static Tracer s{"局部静态"}; // 静态存储期(首用构造,程序结束析构)
auto d = std::make_unique<Tracer>("动态-堆"); // 动态存储期
std::printf("main 结束前\n");
} // a、d 在此析构;s、g 在程序退出时析构
text
全局静态 构造
自动-栈 构造
局部静态 构造
动态-堆 构造
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/智能指针掌控(堆),线程存储期每线程一份随线程生灭------记住这张表,对象的生命周期就再也不会算错。