unique_ptr 还是 shared_ptr?C++ 智能指针选型与内存泄漏实战分析
很多人学完智能指针会有一种错觉:
"用了智能指针就不会内存泄漏了。"
现实是:智能指针只是把"谁负责释放"写进了类型里 ,如果你所有权设计错了,照样泄漏。shared_ptr 甚至会因为"太好用",让你把本该独占的资源搞成共享,再把共享搞成环。
一、先记住一句话
默认用
unique_ptr;只有"真的有多个所有者"才用shared_ptr;weak_ptr不是用来替代它们的,是用来打破共享关系的。
智能指针的本质不是"自动 GC",而是 RAII + 所有权语义。
二、unique_ptr:独占所有权
语义
-
一个对象,同一时刻只有一个主人
-
不能拷贝,只能
std::move -
离开作用域自动析构
-
开销约等于裸指针
auto res = std::make_unique
(42);
res->do_something();
// 函数结束自动 delete
适合什么场景
-
工厂函数返回值
-
类成员持有资源
-
树形结构:父拥有子
-
容器里放"独占对象"
class Session {
std::unique_ptrbuf_;
};
传递规则
| 目的 | 写法 |
|---|---|
| 调用方继续拥有 | void f(const T&) / T* |
| 函数内部借用 | void f(const std::unique_ptr<T>&) 不常见,通常用引用 |
| 把所有权转出去 | std::unique_ptr<T> f() |
| 接收所有权 | void f(std::unique_ptr<T>) |
| 转移所有权 | f(std::move(p)) |
如果你发现自己在到处拷贝资源,先别用
shared_ptr,先问:"这东西到底该谁拥有?"
三、shared_ptr:共享所有权,但有代价
语义
-
多个
shared_ptr共同拥有一个对象 -
拷贝时强引用计数 +1
-
最后一个
shared_ptr销毁时,对象才析构 -
控制块里有:强引用计数、弱引用计数、deleter、allocator
auto a = std::make_shared
();
auto b = a; // 引用计数 = 2
代价是什么
- 原子引用计数操作
- 额外控制块
- 对象析构时机不再"一眼看懂"
- 容易写出隐藏的共享关系
所以:shared_ptr 不是"更安全的 unique_ptr",而是另一套所有权模型。
四、怎么选:一张决策表
| 场景 | 选谁 |
|---|---|
| 一个所有者 | unique_ptr |
| 函数返回动态对象 | unique_ptr |
| 类成员拥有资源 | unique_ptr |
| 父子/树/组合关系 | 父用 unique_ptr,子回指父用 weak_ptr 或裸指针 |
| 缓存、全局配置、多模块共享 | shared_ptr |
| 观察者模式 | 被观察用 shared_ptr,观察者用 weak_ptr |
| 图形/节点双向引用 | 一边 shared_ptr,一边 weak_ptr |
| 只借来看一眼 | 裸指针 / weak_ptr,不要抢所有权 |
经验法则:
能 unique 就 unique
unique 写不下去,再考虑 shared
shared 出现环,再用 weak 破环
五、最经典的坑:shared_ptr 循环引用
错误示例
struct Node {
std::string name;
std::shared_ptr<Node> next;
std::shared_ptr<Node> prev;
};
auto a = std::make_shared<Node>("A");
auto b = std::make_shared<Node>("B");
a->next = b;
b->prev = a;
引用计数变化:
创建 a:a.use_count() = 1
创建 b:b.use_count() = 1
a->next = b :b = 2
b->prev = a :a = 2
离开作用域:
局部变量 a 销毁:a 计数 2 -> 1
局部变量 b 销毁:b 计数 2 -> 1
两个计数都还剩 1,谁都不肯死。
结果:
- 析构函数不调用
- 内存泄漏
- 长时间运行服务内存慢慢涨
这就是 shared_ptr 最讽刺的地方:
你用了"智能指针",反而更难发现泄漏,因为你以为自己已经安全了。
六、正确做法:一强一弱
struct Node {
std::string name;
std::shared_ptr<Node> next; // 强引用:我拥有下一个
std::weak_ptr<Node> prev; // 弱引用:我只是知道上一个
};
使用:
auto a = std::make_shared<Node>("A");
auto b = std::make_shared<Node>("B");
a->next = b;
b->prev = a; // weak_ptr,不增加引用计数
离开作用域时:
b 的强引用计数:1 -> 0,b 析构
a 的强引用计数:1 -> 0,a 析构
weak_ptr 不拥有对象,只是"观察"。要用的时候必须 lock():
if (auto p = node->prev.lock()) {
std::cout << p->name << "\n";
} else {
// 前驱已经没了
}
不要先 expired() 再 lock() ,中间可能瞬间失效。直接用 lock() 判空就行。
七、实战场景 1:父子对象
class Child;
class Parent {
public:
std::unique_ptr<Child> child;
~Parent() { std::cout << "Parent destroyed\n"; }
};
class Child {
public:
std::weak_ptr<Parent> parent; // 回指父对象
~Child() { std::cout << "Child destroyed\n"; }
};
如果 Child::parent 也用 shared_ptr:
Parent 拥有 Child
Child 又拥有 Parent
直接环死。
正确模型:
Parent 拥有 Child
Child 观察 Parent
八、实战场景 2:观察者模式
struct Subject {
std::vector<std::weak_ptr<Observer>> observers;
void notify() {
for (auto it = observers.begin(); it != observers.end(); ) {
if (auto ob = it->lock()) {
ob->on_event();
++it;
} else {
it = observers.erase(it); // 观察者已死,清理
}
}
}
};
如果用 shared_ptr 存观察者:
- 观察者想死也死不了
- Subject 偷偷"保活"了所有订阅者
- 长连接系统里非常常见
九、实战场景 3:缓存系统
class Cache {
std::map<int, std::weak_ptr<BigObject>> items_;
public:
std::shared_ptr<BigObject> get(int id) {
if (auto it = items_.find(id); it != items_.end()) {
if (auto obj = it->second.lock()) {
return obj; // 还活着,直接返回
}
items_.erase(it); // 已经释放,清理脏条目
}
auto obj = std::make_shared<BigObject>(id);
items_[id] = obj;
return obj;
}
};
缓存如果用 shared_ptr:
- 对象永远被缓存按住
- 缓存越大,泄漏越像"正常内存增长"
十、还有这些坑,比循环引用更隐蔽
1. 同一个裸指针构造两个智能指针
Resource* raw = new Resource;
std::shared_ptr<Resource> a(raw);
std::shared_ptr<Resource> b(raw); // 双重释放
✅ 永远用:
auto a = std::make_shared<Resource>();
2. get() 出来的裸指针又被别人管理
auto p = std::make_unique<Resource>();
Resource* raw = p.get();
std::shared_ptr<Resource> q(raw); // 崩
规则:
get()只借不转手,永远不要用它再去构造智能指针。
3. lambda 里捕获 shared_ptr 导致对象死不了
auto self = shared_from_this();
timer.async_wait([self](auto) {
self->do_something();
});
如果这个回调一直没触发,或者线程池长期持有:
- 对象永远不析构
- 看起来像业务对象"该释放却还在"
异步代码里这是高频事故。
4. make_shared 的隐藏副作用
auto p = std::make_shared<BigObject>();
std::weak_ptr<BigObject> w = p;
p.reset();
你以为 BigObject 内存马上归还:
- 对象析构函数会跑
- 但整块内存(对象 + 控制块)要等最后一个
weak_ptr也死掉才释放
如果 weak_ptr 长期存活,大对象内存不会立刻回收。
这种场景可以考虑:
std::shared_ptr<BigObject> p(new BigObject);
对象和控件块分开分配,对象内存可以更早归还。
十一、调试内存泄漏时看什么
1. 看析构有没有跑
~Node() {
std::cout << "Node destroyed\n";
}
如果程序结束没打印,基本就是:
- 还有
shared_ptr活着 - 或者循环引用
- 或者回调/容器持有了
2. 看 use_count()
std::cout << p.use_count() << "\n";
注意:
use_count()只能辅助调试,不能拿来写业务逻辑。
3. 工具
- Valgrind / massif
- heaptrack
- AddressSanitizer
- Visual Studio CRT leak detection
- 长期服务:监控 RSS / 常驻内存曲线
循环引用型泄漏的特征是:
分配只增不减
析构日志越来越少
use_count 永远 > 0
十二、现代 C++ 的"铁律"
1. 不写裸 new / delete
2. 默认 unique_ptr
3. 真共享才 shared_ptr
4. 回指 / 观察 / 缓存用 weak_ptr
5. 用 make_unique / make_shared
6. 不要从 get() 再造智能指针
7. 双向关系先画所有权,再写代码
十三、一句话收尾
unique_ptr说的是:这东西归我,我死了它就死。
shared_ptr说的是:大家一起养,最后一个人走才收拾。
weak_ptr说的是:我只是看看,不负责养,也不拦你死。
大多数 C++ 项目的真实问题,不是"不会用 shared_ptr",而是"太早用 shared_ptr"。
如果你愿意,我可以把这篇文章再整理成一版「面试速答版」:
unique_ptr / shared_ptr / weak_ptr 三句话讲清 + 5 个高频泄漏 case + 面试官最爱追问的点。