⚡C++ 内存管理

智能指针与循环引用

面试回答

常见问法

  • unique_ptrshared_ptrweak_ptr 分别适用于什么场景?
  • 为什么说 shared_ptr 不是“万能内存管理方案”?
  • 循环引用为什么会导致内存泄漏?
  • weak_ptr 为什么能打破循环引用?它本身为什么不拥有对象?
  • shared_ptr 的控制块里通常放什么?
  • 为什么 unique_ptr 不能拷贝但可以移动?
  • make_shared 和直接 new 再交给 shared_ptr 有什么区别?
  • 为什么不能从同一个裸指针构造多个 shared_ptr

回答

C++ 智能指针本质上是在表达所有权语义,不是单纯替代 new/delete

  • unique_ptr 表示独占所有权:同一时刻只能有一个拥有者,不能拷贝、可以移动,开销最小,语义最清晰,默认优先使用。
  • shared_ptr 表示共享所有权:多个对象共同拥有同一资源,通过引用计数控制生命周期,适合确实无法明确唯一拥有者的场景。
  • weak_ptr 表示非拥有的观察关系:它不参与对象生命周期管理,不增加强引用计数,只能通过 lock() 临时拿到 shared_ptr 使用。

循环引用泄漏的根因是: shared_ptr 依赖强引用计数归零来析构对象。如果两个对象互相持有 shared_ptr,即使外部已经不再使用它们,它们彼此之间仍然让对方的强引用计数大于 0,导致析构永远不会发生,这就是循环引用。

所以工程上要先区分清楚关系:

  • 拥有关系:用 unique_ptr 或必要时用 shared_ptr
  • 观察关系 / 回指关系 / 父指针 / 缓存弱引用:优先用 weak_ptr

一句话总结: 先用 unique_ptr 表达独占,确实需要共享才用 shared_ptr,凡是“不拥有、只观察”的关系就考虑 weak_ptr

追问

  • 为什么 unique_ptr 不能拷贝? 因为它语义上代表唯一拥有者,拷贝会破坏独占性;但可以移动,把所有权转移给别人。

  • weak_ptr::lock() 的作用是什么? 它会检查对象是否还活着;如果对象还在,返回一个新的 shared_ptr;如果已经销毁,返回空对象。这样可以安全访问资源。

  • shared_ptr 控制块里通常有什么? 一般包含强引用计数、弱引用计数、删除器、分配器等元数据,有些实现还会管理对象本体或指向对象的指针。

  • 为什么 shared_ptr 有额外开销? 因为要维护控制块、引用计数,而且引用计数通常涉及原子操作,多线程下成本更明显。

  • 为什么不能从同一个裸指针构造多个 shared_ptr 因为会生成多个独立控制块,最后每个控制块都认为自己该释放对象,导致双重释放。


原理展开

1. 智能指针的核心不是“自动释放”,而是“所有权建模”

很多人把智能指针理解成“帮我自动 delete 的工具”,这是不够的。 面试更好的说法是:

智能指针首先解决的是资源所有权表达问题,其次才是自动释放。

比如:

  • 资源只能有一个主人unique_ptr
  • 资源需要多方共同持有shared_ptr
  • 只是看一眼,不负责释放weak_ptr

这也是为什么工程实践中常说: 默认 unique_ptr,谨慎 shared_ptr,非拥有关系用 weak_ptr


2. unique_ptr:独占所有权,默认首选

unique_ptr 是最接近“RAII + 零额外共享成本”的方案。

特点

  • 独占所有权
  • 不能拷贝
  • 可以移动
  • 几乎没有 shared_ptr 那种引用计数成本
  • 非常适合明确生命周期边界的对象

典型场景

  • 工厂函数返回对象
  • 类成员独占资源
  • 容器里存放多态对象
  • 文件句柄、socket、锁对象等独占资源封装

示例

#include <memory>

class Widget {};

std::unique_ptr<Widget> create_widget() {
    return std::make_unique<Widget>();
}

void demo() {
    auto p1 = create_widget();

    // auto p2 = p1;            // 错误:不能拷贝
    auto p2 = std::move(p1);    // 正确:转移所有权
}

面试加一句

如果对象生命周期非常清晰,就不要为了“省事”上来就用 shared_ptr,因为那是在用更复杂的语义解决更简单的问题。


3. shared_ptr:共享所有权,但共享一定要有理由

shared_ptr 通过控制块 + 引用计数管理对象生命周期。

基本机制

  • 每拷贝一个 shared_ptr,强引用计数加一
  • 每销毁一个 shared_ptr,强引用计数减一
  • 当强引用计数变成 0 时,对象析构
  • 控制块何时彻底释放,还取决于弱引用计数是否也归零

示例

#include <memory>

struct Node {};

void demo() {
    std::shared_ptr<Node> p1 = std::make_shared<Node>();
    std::shared_ptr<Node> p2 = p1; // 共享所有权
}

控制块通常包含

  • 强引用计数(shared count)
  • 弱引用计数(weak count)
  • 删除器(deleter)
  • 分配器(allocator)
  • 对象本体或对象地址

为什么 shared_ptr 不能滥用

因为它带来的不是“更高级”,而是“更重的生命周期语义”:

  • 有控制块开销
  • 有引用计数维护成本
  • 多线程下常涉及原子操作
  • 容易隐藏真实所有权,导致设计模糊
  • 容易形成循环引用

什么时候该用 shared_ptr

只有在下面这类问题中才真有必要:

  • 对象确实有多个平级拥有者
  • 无法自然选出唯一生命周期管理者
  • 生命周期跨多个模块共享

比如:

  • 共享缓存对象
  • 异步任务之间共享上下文
  • 图结构中被多个节点共同引用的公共数据

4. weak_ptr:不拥有对象,只观察对象

weak_ptr 的价值不在“省一次拷贝”,而在于表达非拥有关系

它解决什么问题

  • 打破 shared_ptr
  • 表达“我知道你存在,但我不负责延长你生命周期”
  • 避免“为了访问对象而误用共享所有权”

使用方式

weak_ptr 不能直接解引用,必须先 lock()

#include <memory>
#include <iostream>

struct Node {
    int value = 42;
};

void demo() {
    auto sp = std::make_shared<Node>();
    std::weak_ptr<Node> wp = sp;

    if (auto locked = wp.lock()) {
        std::cout << locked->value << '\n';
    }
}

典型场景

  • 观察者模式
  • 缓存中的弱引用
  • 父子结构中的父指针 / 回指关系
  • 事件订阅回调里避免对象被“意外续命”

一个常见追问

为什么 weak_ptr 不增加对象强引用计数? 因为它的设计目标就是不拥有。如果它也增加强引用计数,那就无法打破循环引用了。


5. 循环引用为什么会泄漏

这类题面试经常要求你从“现象”讲到“根因”。

错误示例

#include <memory>
#include <string>
#include <vector>

struct Person {
    std::string name;
    std::shared_ptr<Person> partner;

    Person(const std::string& n) : name(n) {}
    ~Person() { /* 期望被调用 */ }
};

void demo() {
    auto a = std::make_shared<Person>("A");
    auto b = std::make_shared<Person>("B");

    a->partner = b;
    b->partner = a;
} // 退出后 a 和 b 都不会析构

根因分析

退出 demo() 后:

  • 局部变量 a 销毁,A 的强引用计数减一
  • 局部变量 b 销毁,B 的强引用计数减一
  • 但 A 还被 B 的 partner 持有
  • B 还被 A 的 partner 持有

所以:

  • A 的强引用计数不是 0
  • B 的强引用计数也不是 0

结果就是两个对象都无法析构,形成泄漏。

正确做法

非拥有的回指关系改成 weak_ptr

#include <memory>
#include <string>
#include <vector>

struct TreeNode {
    std::string name;
    std::weak_ptr<TreeNode> parent;                   // 不拥有父节点
    std::vector<std::shared_ptr<TreeNode>> children;  // 拥有子节点

    TreeNode(const std::string& n) : name(n) {}
};

面试里的高分表达

不要只说“用 weak_ptr 打破循环”,还要说明:

本质上不是“哪里循环就硬改成 weak_ptr”,而是先识别真实所有权。 父子结构里通常是父拥有子,子只回看父,所以父到子用拥有指针,子到父用非拥有指针。

这体现的是设计能力,而不是 API 背诵。


6. make_sharedmake_unique 为什么推荐优先使用

make_unique

  • 语义清晰
  • 避免显式 new
  • 异常安全更好
auto p = std::make_unique<Widget>();

make_shared

通常优先于 shared_ptr<T>(new T(...)),因为:

  • 一般只需要一次内存分配(对象和控制块可能一起分配)
  • 性能和局部性通常更好
  • 异常安全更自然
auto p = std::make_shared<Widget>();

但也要知道边界

不是所有场景都适合 make_shared

  • 需要自定义删除器时,通常更常见的是直接构造 shared_ptr
  • 对象很大且弱引用存在时间可能很长时,控制块与对象同块分配的策略有时会影响内存回收粒度,需要结合实现和场景判断
  • 需要和特殊资源管理方式配合时,要看接口约束

7. 工程实践里的选择原则

这部分最像真实工作经验,也是面试加分点。

原则一:默认从 unique_ptr 开始设计

因为它最清楚地表达“谁负责销毁”。

原则二:只有当共享是业务事实时,才升级为 shared_ptr

不是“可能以后会共享”,而是“现在确实有多个拥有者”。

原则三:回指、观察、订阅关系优先考虑 weak_ptr

特别是:

  • 父子节点
  • 发布订阅
  • 回调持有对象
  • 缓存观察

原则四:尽量减少共享所有权的扩散

一旦全链路都传 shared_ptr,对象生命周期会变得模糊,很难定位谁真正负责资源释放。

原则五:接口设计尽量区分“使用对象”和“拥有对象”

比如:

  • 只读访问:传 const T&const T*
  • 临时使用但不接管所有权:传裸指针 / 引用
  • 要接管所有权:传 unique_ptr
  • 要共享拥有:传 shared_ptr

这说明你不只是会用智能指针,而是理解接口语义设计。


8. 常见陷阱与边界问题

8.1 从裸指针构造多个 shared_ptr

错误示例:

Widget* raw = new Widget();

std::shared_ptr<Widget> p1(raw);
std::shared_ptr<Widget> p2(raw); // 严重错误:两个独立控制块

这会导致最终双重释放。

8.2 在对象内部错误使用 shared_from_this

如果一个对象想安全地拿到“指向自己的 shared_ptr”,应该继承 std::enable_shared_from_this<T>,并确保对象本身已经被 shared_ptr 托管。

#include <memory>

struct Session : std::enable_shared_from_this<Session> {
    std::shared_ptr<Session> get_self() {
        return shared_from_this();
    }
};

如果对象不是由 shared_ptr 管理,直接调用 shared_from_this() 会出问题。

8.3 误以为 shared_ptr 能解决所有悬空问题

它只能解决共享拥有对象的生命周期管理,解决不了:

  • 非法并发访问
  • 循环依赖设计混乱
  • 引用逃逸
  • 逻辑层面的资源管理错误

8.4 把 weak_ptr 当成“更轻量的 shared_ptr

weak_ptr 不是“低配拥有指针”,它是非拥有语义。 它不能直接访问对象,必须 lock(),而且使用前必须接受对象可能已经释放的事实。


对比总结

概念所有权语义是否可拷贝是否可移动生命周期管理方式典型场景优点缺点
unique_ptr独占所有权单一拥有者离开作用域即释放工厂返回值、类成员资源、容器中的独占对象开销低、语义清晰、默认首选不能共享
shared_ptr共享所有权控制块 + 强引用计数共享缓存、异步共享上下文、复杂图结构中的公共对象使用灵活,适合多拥有者有额外开销,容易滥用,可能循环引用
weak_ptr非拥有观察关系依附于 shared_ptr 控制块,不增加强引用计数父指针、观察者、缓存弱引用、打破环能表达观察关系,能打破循环引用不能直接访问对象,需要 lock()

unique_ptr vs shared_ptr

对比项unique_ptrshared_ptr
语义唯一拥有者多个拥有者
成本
是否适合作为默认选择
适用判断生命周期边界清晰生命周期天然共享

shared_ptr vs weak_ptr

对比项shared_ptrweak_ptr
是否拥有对象
是否增加强引用计数
能否直接访问对象否,需要 lock()
用途共享拥有观察 / 回指 / 打破环

make_shared vs shared_ptr(new T)

对比项make_sharedshared_ptr(new T)
分配次数通常更少通常更多
异常安全更自然相对容易写出不够优雅的代码
是否推荐优先使用特殊场景才考虑

易错点

  • 以为智能指针的目的只是“代替 delete”,忽略了真正重要的是所有权语义
  • 上来就用 shared_ptr,把它当成通用解法
  • 无法区分“拥有关系”和“观察关系”
  • 只会背“循环引用用 weak_ptr”,但说不清为什么
  • 从同一个裸指针构造多个 shared_ptr,导致双重释放
  • 不知道 weak_ptr 不能直接解引用,必须先 lock()
  • 误以为有了 shared_ptr 就不会有生命周期问题
  • 在接口上传来传去都是 shared_ptr,导致所有权边界越来越模糊
  • 不清楚 unique_ptr 不能拷贝的根因是语义而不是语法限制
  • 不知道 make_shared / make_unique 一般优于显式 new

记忆技巧

  • unique_ptr独占 关键词:唯一主人、能移动不能拷贝、默认首选

  • shared_ptr共管 关键词:多人负责、引用计数、确实共享才用

  • weak_ptr旁观 关键词:只观察、不负责、打破循环

一句口诀

能独占就独占,必须共享才共享,只观察就别拥有。

场景联想法

  • 工厂函数返回对象:unique_ptr
  • 多模块共享缓存:shared_ptr
  • 子节点回看父节点:weak_ptr

面试速答版

智能指针核心是表达所有权。unique_ptr 表示独占所有权,不能拷贝只能移动,开销最小,工程里默认优先用;shared_ptr 表示共享所有权,通过控制块里的引用计数管理生命周期,适合确实有多个拥有者的场景;weak_ptr 不拥有对象,只是观察 shared_ptr 管理的对象,常用来打破循环引用。

循环引用泄漏的原因是两个对象互相持有 shared_ptr,即使外部引用都释放了,它们内部的强引用计数也不会归零,所以析构不会发生。解决办法不是机械地“把一个改成 weak_ptr”,而是先识别真实所有权:拥有关系用 shared_ptrunique_ptr,观察或回指关系改成 weak_ptr


面试加分版

我通常把智能指针分成三类语义来理解:独占、共享、观察。 unique_ptr 表达独占所有权,语义最清晰、成本最低,所以在工程里应该作为默认选择;shared_ptr 表达共享所有权,底层依赖控制块维护强弱引用计数,适合对象确实存在多个平级拥有者的场景;weak_ptr 则不拥有对象,只是观察者,它不会增加强引用计数,所以很适合父指针、订阅关系、缓存弱引用这类非拥有关系。

循环引用的问题本质上是 shared_ptr 依赖强引用计数归零来销毁对象,而一旦两个对象互相持有 shared_ptr,哪怕外部已经不再使用,它们也会让彼此的强引用计数始终大于 0,导致析构永远不发生。比如双向链表、父子节点、观察者关系里,如果两边都用 shared_ptr,就很容易形成闭环。

所以我在设计上会先问一个问题:谁真正拥有谁? 如果是拥有关系,就用 unique_ptr 或在必要时用 shared_ptr;如果只是回指、观察、回调引用,就应该用 weak_ptr。这比单纯记 API 更重要,因为它反映的是对象生命周期设计是否清晰。

工程实践里,我的选择原则一般是: 第一,默认 unique_ptr;第二,只有共享是业务事实时才用 shared_ptr;第三,凡是非拥有关系优先考虑 weak_ptr;第四,尽量避免接口到处传 shared_ptr,否则所有权边界会越来越模糊。 另外还要注意两个典型坑:一是不能从同一个裸指针构造多个 shared_ptr,否则会双重释放;二是一般优先用 make_uniquemake_shared,因为写法更安全、也更符合现代 C++ 风格。