内存与智能指针
这一课你将能做到
- 区分栈、堆、静态区三块内存,并解释为什么
new/delete 手动配对迟早出错
- 用
unique_ptr 与 shared_ptr 托管堆对象,说清各自的所有权模型与适用场景
- 解释移动语义:为什么
std::move 之后原对象「被掏空」,以及 vector 扩容如何靠移动省去复制
为什么要学
Java 程序员从入职第一天就被 GC 托底:忘了置 null?没关系。C++ 没有 GC,new 出来的内存必须有人负责 delete——忘了就泄漏,删两次就崩,删了还用就是野指针。判题沙箱一天评测上千份提交,每份都要分配用户代码的输入缓冲、输出缓冲,任何一处泄漏累积起来就是内存耗尽、全站评测卡死。
现代 C++ 的答案是:不裸写 new/delete,用智能指针把「释放时机」编码进对象所有权里。这本质上是用 RAII(上一课)管理内存本身。学完这课你会明白:C++ 没有 GC,但有比 GC 更早、更确定性的资源回收方式——代价是你必须想清楚「这块内存归谁管」。
先建立图像
一个进程的内存布局:
高地址 ┌──────────────┐
│ 栈 Stack │ ← 局部变量、函数参数;自动分配自动释放,MB 级
│ ↓ │
│ (空闲) │
│ ↑ │
│ 堆 Heap │ ← new/delete 的地盘;手动管理,GB 级
├──────────────┤
│ 静态/全局区 │ ← 全局变量、static;程序全程存活
│ 代码区 │ ← 机器指令
低地址 └──────────────┘
Java 对比:JVM 把堆再切成新生代/老年代跑 GC;
C++ 的堆就是一块裸内存池,谁来 new 谁负责——或者交给智能指针。
跟着做
步骤 1:感受 new/delete 的脆弱
新建 memory.cpp,先看裸写法的三个死法:
#include <iostream>
void caseA_leak() { // 死法一:泄漏
int* p = new int(42);
// 函数结束 p 消失,堆上那 4 字节再也没人能释放
delete p; // 补上这行才对;真实工程里常在分支/异常路径漏掉
}
void caseA_leak2() {
int* p = new int(42);
delete p;
// delete p; // 死法二:双重释放 → 立即崩溃或堆损坏
// p 是野指针,又指向已释放内存。delete 后应 p = nullptr;
}
int* factory() { // 死法三:所有权不明
return new int(7); // 调用方必须记得 delete,谁来删?文档说了不算
}
int main() {
int* q = factory();
std::cout << *q << "\n";
delete q; // 忘了这行 → 泄漏;删两次 → 崩溃
return 0;
}
编译能过、逻辑看着也对——内存 bug 的阴险就在「平时正常,压测爆炸」。
步骤 2:unique_ptr——独占所有权
#include <iostream>
#include <memory>
struct Report {
int score;
explicit Report(int s) : score(s) { std::cout << "Report 构造\n"; }
~Report() { std::cout << "Report 析构\n"; }
};
void process(Report* raw) { std::cout << "处理: " << raw->score << "\n"; }
int main() {
// 创建:make_unique 优先(C++14),比直接 new 更安全
std::unique_ptr<Report> r = std::make_unique<Report>(95);
// 用起来像指针
std::cout << r->score << "\n";
process(r.get()); // get():借出裸指针给只用的函数,不转移所有权
// r2 = r; // 编译错误!unique_ptr 禁止拷贝:独占就是独占
std::unique_ptr<Report> r2 = std::move(r); // 转移所有权:r 变为空
if (!r) std::cout << "r 已交出所有权\n";
return 0;
} // r2 离开作用域 → 自动 delete → 打印「Report 析构」
Report 构造
95
处理: 95
r 已交出所有权
Report 析构
Java 对照:unique_ptr 之于裸指针,就像把「必须记得 close()」换成 try-with-resources——释放时机由语言机制保证,而且早于 GC,确定性更强。
步骤 3:shared_ptr——共享所有权
多个对象都要引用同一个评测报告、且谁也不能确定「自己是不是最后一个用的人」时:
#include <iostream>
#include <memory>
int main() {
auto a = std::make_shared<int>(42);
std::cout << "引用计数: " << a.use_count() << "\n"; // 1
auto b = a; // 拷贝:计数 +1
std::cout << "引用计数: " << a.use_count() << "\n"; // 2
b.reset(); // b 放手:计数 -1
std::cout << "引用计数: " << a.use_count() << "\n"; // 1
return 0;
} // a 离开作用域,计数归 0 → 对象被销毁
引用计数不是免费的:每次拷贝/析构都是原子操作,比 unique_ptr 慢。工程默认 unique_ptr,确有共享需求才升级 shared_ptr。
struct Node {
std::shared_ptr<Node> next; // 两个节点互指 → 计数永远 ≥1 → 泄漏
// std::weak_ptr<Node> next; // 解法:一方用弱引用,不增加计数
};
步骤 4:移动语义——把「复制」换成「搬运」
#include <iostream>
#include <string>
#include <utility>
std::string makeSource() {
std::string s = "这是一段可能非常长的评测输出……";
return s; // 返回值优化(RVO):直接在调用方的内存上构造,零拷贝
}
int main() {
std::string output = makeSource();
std::string output2 = std::move(output); // 移动构造:把 output 的内部缓冲区「过户」
std::cout << "output2: " << output2 << "\n";
// output 现在处于「有效但未定义」状态:能赋新值、能析构,别再读它的内容
std::cout << "output 长度: " << output.length() << "\n"; // 通常为 0
return 0;
}
移动 = 转移内部资源的所有权(偷指针),复制 = 深拷贝全部内容。对持大缓冲区的对象,移动是 O(1),复制是 O(n)。std::move 本身不移动任何东西,只是把表达式标记为「可被移动」(绑定到右值引用 T&&),真正干活的是移动构造/移动赋值函数。
步骤 5:vector 扩容里的移动语义
为什么 vector 能又快又安全?扩容时靠移动:
#include <iostream>
#include <string>
#include <vector>
class Log {
public:
explicit Log(std::string s) : data_(std::move(s)) {}
Log(const Log& o) : data_(o.data_) { std::cout << "复制\n"; } // 拷贝构造
Log(Log&& o) noexcept : data_(std::move(o.data_)) { std::cout << "移动\n"; }
private:
std::string data_;
};
int main() {
std::vector<Log> v;
v.reserve(4); // 预留容量,避免反复扩容——性能敏感代码的标配
for (int i = 0; i < 4; ++i) {
v.emplace_back("log-" + std::to_string(i)); // 原地构造,不产生临时对象
}
return 0;
}
vector 扩容(2 倍增长)要把旧元素搬到新内存:元素支持移动构造时就逐个「移动」(O(n) 次廉价搬运),不支持就只能「复制」。这也是为什么要求移动构造标 noexcept——vector 只有确认移动不会抛异常,才敢用移动。
讲清楚要点
- 所有权的三个归属:裸指针时代「谁 new 谁 delete」靠纪律;现代 C++ 把所有权分成独占(
unique_ptr)、共享(shared_ptr + 计数)、观察(weak_ptr 或裸指针只读不删)三种,代码即文档。
- 默认 unique_ptr,慎用 shared_ptr:
unique_ptr 零开销(大小 = 裸指针)、编译期强制独占;shared_ptr 有原子计数开销,还可能循环引用。能用栈对象就不上堆,能 unique 就不 shared。
- make_unique / make_shared 优先于 new:一次分配、异常安全、代码里不出现裸 new。
make_shared 还能把对象和控制块合并成一次内存分配。
- 移动是复制的廉价替代:右值(临时对象、被
std::move 标记的对象)触发移动构造。函数返回局部对象靠 RVO 连移动都省了,别画蛇添足写 return std::move(local);——反而阻止优化。
- const 正确性贯穿内存管理:
const std::string& 参数、const 成员函数、unique_ptr<const T>——把「只读」写进类型系统,让编译器替你审内存安全。
你可能会懵的地方
-
C++ 没有 GC,那内存泄漏怎么发现?
工具链:AddressSanitizer(-fsanitize=address)编译即可在运行时抓泄漏与越界;Valgrind 事后分析;CI 里跑长测观察 RSS。判题沙箱上线前必过 ASan 这关。
-
unique_ptr 能放进容器吗?能作为函数返回值吗?
都能。std::vector<std::unique_ptr<Task>> 是存储多态对象的标配;从函数 return up; 会自动移动(隐式 move),不是拷贝。
-
什么对象直接放栈上,什么才上堆?
小而短命 → 栈(连 new 都不用);需要跨函数存活、体积大、数量运行期才定、需要多态容器 → 堆 + 智能指针。Java 世界「万物 new」的直觉在 C++ 里要倒过来:默认栈,确实需要才堆。
-
shared_ptr 线程安全吗?
控制块计数是原子的(多线程拷贝/析构安全),但指向的对象本身不自动线程安全——多线程读写对象仍要自己加锁(第 9 课)。
-
delete 一个 void* 或未定义析构的基类指针会怎样?
UB。前者不知道怎么析构,后者(基类析构非虚)只析构基类部分。用智能指针后这类错误基本绝迹,也是「禁裸 new」的理由之一。
错法对照
| 错法 | 问题 | 正解 |
|---|
unique_ptr<T> p = new T(); | 构造函数是 explicit,编译不过 | auto p = std::make_unique<T>(); |
拷贝 unique_ptr | 编译错误:独占不可拷贝 | std::move 转移,或改用 shared_ptr |
shared_ptr 互相引用 | 计数永不归零 → 泄漏 | 一方改用 weak_ptr,用时 lock() |
return std::move(local); | 阻止 RVO,反而更慢 | 直接 return local; |
| 移动后继续读原对象内容 | 「有效但未定义」状态,读到空/旧数据 | move 只用来「交出」资源,之后不再读取 |
卡点急救
- 程序退出时报
double free detected:同一块内存删了两次。排查对象是否被两个容器/智能指针共同持有,统一改成 unique_ptr 独占。
use_count 比预期大,对象迟迟不析构:有循环引用或某处还持着 shared_ptr。用 weak_ptr 打破环,或梳理所有权图。
- 编译报
no matching function for call to 'make_unique':构造函数是 private 或参数不匹配。检查类是否把 make 需要的构造函数设为 public,或用 std::unique_ptr<T>(new T(args)) 兜底。
面试常问
-
unique_ptr 和 shared_ptr 的区别与选择?
unique_ptr 独占所有权、零开销、不可拷贝可移动;shared_ptr 引用计数共享所有权、有原子操作开销。默认 unique_ptr,仅当所有权确实多方共享且释放时机不确定时用 shared_ptr;观察场景用 weak_ptr 防循环引用。
-
什么是移动语义?解决了什么问题?
C++11 引入,通过右值引用 T&& 把「深拷贝临时对象」变成「接管其内部资源」,使容器扩容、对象返回、智能指针转移都变 O(1)。std::move 只是无条件转为右值的 cast,真正转移由移动构造/赋值完成,后者应标 noexcept。
-
智能指针是 GC 吗?和 Java 的 GC 区别?
不是。智能指针基于 RAII 与所有权:unique_ptr 作用域结束即析构(确定性),shared_ptr 计数归零即析构(准确定)。GC 在不可预知的未来批量回收且带停顿;智能指针没有扫描开销,但要求程序员显式表达所有权,且不能自动处理环引用。
自测三问
r.get() 和 std::move(r) 分别对 unique_ptr 做了什么?
make_shared 相比 shared_ptr<T>(new T) 多了什么优势?
- 为什么 vector 扩容要求元素的移动构造函数是 noexcept?
小结一句话
没有 GC 的 C++ 用「所有权」替代「回收器」:unique_ptr 独占、shared_ptr 共享、weak_ptr 观察,配合移动语义让资源转移零拷贝——想清楚「归谁管」,内存问题就少九成。
延伸