std::weak_ptr 的 lock()操作到底做了什么?如果对象已被回收,是返回nullptr?他如何知道对象是否被回收的?
这个问题问到了 weak_ptr 的核心实现,是 Senior 面试里少有人能完整回答的一道题。
先看内存布局
理解 lock() 之前,必须先知道 shared_ptr 和 weak_ptr 共享的控制块长什么样:
make_shared<T>() 之后的内存布局:
┌─────────────────────────────────────────┐
│ Control Block │
│ ┌─────────────────────────────────┐ │
│ │ use_count (atomic<long>) = 1 │ │ ← 强引用计数
│ │ weak_count (atomic<long>) = 1 │ │ ← 弱引用计数(初始为1,含义下面解释)
│ │ deleter │ │
│ │ allocator │ │
│ └─────────────────────────────────┘ │
│ Object Data (T) │ ← make_shared 时和控制块在一起
└─────────────────────────────────────────┘
↑ ↑
weak_ptr 持有 shared_ptr 持有
(指向控制块) (指向控制块 + 对象)
关键设计:weak_ptr 只持有指向控制块的指针,不持有对象指针。控制块的生命周期独立于对象本身。
use_count 和 weak_count 的语义
两个计数器的精确含义:
use_count = 当前存活的 shared_ptr 数量
weak_count = 当前存活的 weak_ptr 数量 + 1
(这个 +1 代表"至少还有一个 shared_ptr 存在"这件事本身)
触发条件:
use_count 降为 0 → 销毁对象(调用析构函数,释放对象内存)
weak_count 降为 0 → 销毁控制块(释放控制块内存)
为什么 weak_count 初始是 1 而不是 0?因为要把”有 shared_ptr 存在”这件事也计入 weak_count,当最后一个 shared_ptr 被销毁时,use_count 降为 0,同时把 weak_count 减 1。这样只要还有 weak_ptr 存在,weak_count 就不会到 0,控制块就不会被释放。
用时间线来看:
初始: shared_ptr a = make_shared<Player>(); use_count=1, weak_count=1 加一个 weak_ptr: weak_ptr<Player> w = a; use_count=1, weak_count=2 shared_ptr 销毁: a.reset(); 或 a 出作用域 → use_count 降为 0 → 销毁 Player 对象(析构函数 + 释放内存) → weak_count 减 1,变为 1 → 控制块还活着! 此时 w 还持有一个指向控制块的指针: w 知道 use_count=0,对象已死 weak_ptr 也销毁: w 出作用域 → weak_count 减 1,变为 0 → 释放控制块内存
lock() 的完整实现逻辑
现在可以精确解释 lock() 做了什么:
cpp
// 伪代码,贴近 libstdc++ 实际实现
shared_ptr<T> weak_ptr<T>::lock() const noexcept {
// 关键操作:原子地检查 use_count 并尝试加 1
// 必须是原子操作,否则有 TOCTOU 竞争条件(下面详细说)
long count = control_block->use_count.load(memory_order_relaxed);
while (true) {
if (count == 0) {
// 对象已被销毁,返回空的 shared_ptr(等同于 nullptr)
return shared_ptr<T>();
}
// 尝试把 use_count 从 count 改为 count+1(原子 CAS)
if (control_block->use_count.compare_exchange_weak(
count, // expected
count + 1, // desired
memory_order_acquire,
memory_order_relaxed)) {
// CAS 成功:我们成功"复活"了一个强引用
// 此刻对象一定存活,因为 use_count 已经 >= 1
return shared_ptr<T>(ptr, control_block);
}
// CAS 失败:use_count 被其他线程改了,用新值重试
// count 已被 compare_exchange_weak 更新为当前值
}
}
它如何知道对象是否被回收:直接读控制块里的 use_count。use_count == 0 就意味着对象已销毁。控制块在 weak_ptr 存活期间永远不会被释放,所以这个读操作是安全的。
为什么必须用 CAS 而不是普通读写:这是防止如下竞争条件:
// 危险的非原子实现(假设):
线程1 (lock): 线程2 (reset shared_ptr):
1. 读 use_count,看到值为 1
2. use_count-- → 变为 0
3. 销毁 Player 对象,释放内存
4. 看到 use_count > 0,决定创建 shared_ptr
5. 返回一个指向已释放内存的 shared_ptr ← 悬空指针!
// CAS 的原子保证:
// 如果 lock() 的 CAS 成功,use_count 就从 N 变成了 N+1
// 线程2 在这之后执行 use_count--,只会让它变成 N
// 对象不会被销毁,因为 use_count 仍然 >= 1
make_shared 带来的一个隐蔽副作用
cpp
// 用 new 分配:对象内存和控制块内存是分离的 std::shared_ptr<BigObject> sp(new BigObject()); // 内存布局: // [Control Block] (独立分配) // [BigObject] (独立分配) // 当 use_count=0 时: // → BigObject 析构 + BigObject 内存立刻释放 // → 控制块继续存在(等 weak_count=0 再释放)
cpp
// 用 make_shared:对象和控制块在同一块内存 std::shared_ptr<BigObject> sp = std::make_shared<BigObject>(); // 内存布局: // [Control Block | BigObject] (一次分配,连续内存) // 当 use_count=0 时: // → BigObject 析构(析构函数被调用) // → 但!内存不能释放!因为控制块还在同一块内存里 // → 必须等 weak_count=0,整块内存才一起释放 // 结论:如果 weak_ptr 的生命周期很长,make_shared 反而会让 // BigObject 的内存在"逻辑死亡"后继续占用,直到最后一个 weak_ptr 消失
实际影响:
cpp
// 场景:资源管理器用 weak_ptr 做缓存,用 make_shared 创建资源
class ResourceCache {
std::unordered_map<std::string, std::weak_ptr<Texture>> cache_;
std::shared_ptr<Texture> get(const std::string& name) {
auto& weak = cache_[name];
if (auto sp = weak.lock()) return sp;
// make_shared:如果 cache_ 长期持有 weak_ptr,
// 即使外部没有人用这张贴图(use_count=0),
// 贴图的内存(GPU数据)不会被释放,直到 cache_ 清理 weak_ptr
auto tex = std::make_shared<Texture>(name);
weak = tex;
return tex;
}
};
// 对内存敏感的场景,改用 new 分配:
// auto tex = std::shared_ptr<Texture>(new Texture(name));
// 这样 use_count=0 时,Texture 的大块 GPU 数据立刻释放
// 控制块只是几十字节,晚一点释放无所谓
一图总结整个生命周期
创建:make_shared<Player>()
┌──────────────────────────────┐
│ use_count = 1 │
│ weak_count = 1 │
│ [Player data] │
└──────────────────────────────┘
↑ shared_ptr 指向这里
加 weak_ptr:
use_count = 1
weak_count = 2 (多了一个 weak_ptr)
↑ weak_ptr 也指向控制块
shared_ptr 全部销毁(use_count → 0):
┌──────────────────────────────┐
│ use_count = 0 ✗ │ Player 析构函数被调用
│ weak_count = 1 │ 内存是否释放取决于分配方式
│ [Player data - 已析构] │
└──────────────────────────────┘
控制块还活着,weak_ptr 可以安全读 use_count
lock() 被调用:
读到 use_count = 0
→ 返回空 shared_ptr(nullptr)
weak_ptr 销毁(weak_count → 0):
整块内存最终释放
面试里这道题的完整回答就是这三句话的展开:weak_ptr 持有指向控制块的指针,控制块的生命周期由 weak_count 控制,独立于对象本身;lock() 用原子 CAS 检查 use_count 是否为 0 并尝试加 1,保证线程安全的同时不存在 TOCTOU 竞争;use_count == 0 就是”对象已被回收”的唯一判据,因为控制块在 weak_ptr 存活期间永远不会被释放,读它是安全的。
