对象存储期全解:automatic / static / dynamic / thread

「这个变量什么时候被销毁?」答案不取决于它在哪一行写的,而取决于它的存储期(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,需要共享同一份就得自己加锁。下面逐个用输出证明。

官方文档:Storage duration (cppreference)

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 一节)。

官方文档:new-expression / dynamic storage duration

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 的并发章节也建议尽量用线程局部状态避免共享)。

官方文档:thread_local storage duration (C++11)

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. 延伸阅读

12. 一句话总结

存储期管「对象活多久」、作用域管「名字看得见多远」,两者正交;自动存储期随块生灭(栈),静态存储期从程序启动活到结束(含「程序结束时才析构」的局部静态),动态存储期由 new/智能指针掌控(堆),线程存储期每线程一份随线程生灭------记住这张表,对象的生命周期就再也不会算错。

相关推荐
今天要早睡_1 小时前
C++入门到精通:类和对象(下)全方位解析
android·c++
艾莉丝努力练剑1 小时前
【AI大模型接入SDK】ChatSDK架构设计与实现
网络·c++·人工智能·学习·语言模型
重生之小比特1 小时前
【C++进阶】红黑树的实现
java·开发语言·c++
纪念 2291 小时前
C++算法(一)
开发语言·c++·算法
All for pursuit.2 小时前
【二叉树-10】114.二叉树展开为链表
数据结构·c++·算法·leetcode·链表
多弗朗皮卡丘2 小时前
C++string类
c++
Yunovian3 小时前
AI时代,针对模型与应用,浅谈一下各编程语言
开发语言·c++·人工智能·python·ai·rust·ai编程
UIU1148 小时前
递归算法与汉诺塔
c++·学习·算法·c#·递归
Fruit_Caller8 小时前
static
开发语言·c++