
本文不使用任何项目代码,所有例子都是独立的简化示例。目标读者是熟悉 JavaScript / TypeScript / React / Vue,但较少接触 C++ 内存模型的前端同学。
C++ 指针可以简单理解为“某个对象所在的地址”。
User user;
User* p = &user;这里:
user 是对象;&user 是对象地址;p 保存这个地址;p->name 表示“去这个地址上访问 User 的 name”。前端类比:指针有点像对象引用,但更底层。JS 引用由 GC 管理,C++ 指针经常需要开发者自己保证“它指向的对象还活着”。
User* user = new User(); // 创建
user->SayHello(); // 使用
delete user; // 销毁delete user 之后,User 对象已经不存在了。
危险的是:
delete user;
user->SayHello(); // 错误:对象已经销毁user 这个变量里还保存着旧地址,但这个地址上的对象已经没了。
对象生命周期已经结束,但仍有指针指向原来的内存地址,这类指针更正式地称为悬垂指针(dangling pointer),也常被口语化地称为野指针。
User* user = new User();
delete user;
user->SayHello(); // use-after-freeUse-After-Free,简称 UAF:
对象释放之后,又继续使用它。
这是 C++ 里非常常见、也非常危险的一类问题。
C++ 对 UAF 这类错误不保证固定结果。它可能:
所以,C++ 悬垂指针问题天然可能是“概率性”的。
#include <iostream>
class User {
public:
void SayHello() {
std::cout << "hello" << std::endl;
}
};
int main() {
User* user = new User();
delete user;
user->SayHello(); // 错误,但不一定每次都崩
return 0;
}这段代码一定是错的,但不一定每次 Crash。
原因是:delete user 后,这块内存只是“归还给内存分配器”。它可能暂时还没被别人复用,旧内容还在,所以看起来还能跑。
但只要这块内存被复用,结果就不可预测:
User* user = new User();
delete user;
std::string* text = new std::string("abc");
user->SayHello(); // user 指向的地址可能已经不是 User 了可能的结果包括:
这就是线上概率性 Crash 的常见原因。
前端里常见写法:
useEffect(() => {
window.addEventListener('resize', onResize)
return () => window.removeEventListener('resize', onResize)
}, [])如果组件卸载时忘记 removeEventListener,可能出现内存泄漏或访问已卸载组件的问题。
C++ 里类似问题更危险,因为监听列表里可能保存的是原始指针(raw pointer)。如果它只是观察对象、不负责对象生命周期,更准确地说就是非拥有型原始指针(non-owning raw pointer)。
#include <vector>
#include <algorithm>
#include <iostream>
class ClickListener {
public:
virtual void OnClick() = 0;
virtual ~ClickListener() = default;
};
class Button {
public:
void AddListener(ClickListener* listener) {
listeners_.push_back(listener);
}
void RemoveListener(ClickListener* listener) {
listeners_.erase(
std::remove(listeners_.begin(), listeners_.end(), listener),
listeners_.end()
);
}
void Click() {
for (auto* listener : listeners_) {
listener->OnClick();
}
}
private:
std::vector<ClickListener*> listeners_;
};页面监听按钮点击:
class Page : public ClickListener {
public:
explicit Page(Button& button) : button_(button) {
button_.AddListener(this);
}
~Page() {
button_.RemoveListener(this);
}
void OnClick() override {
std::cout << "clicked" << std::endl;
}
private:
Button& button_;
};这是安全的,因为注册和反注册成对。
错误版本:
class Page : public ClickListener {
public:
explicit Page(Button& button) : button_(button) {
button_.AddListener(this);
}
~Page() {
// 忘记 RemoveListener(this)
}
void OnClick() override {}
private:
Button& button_;
};触发问题:
Button button;
{
Page page(button);
} // page 已经销毁,但 button 里还保存着 page 的地址
button.Click(); // 访问已销毁对象,可能 Crash这就是一个非常典型的 UAF。
实际问题里,代码往往不是完全忘记解绑,而是某个条件导致解绑没有执行。
class Page : public ClickListener {
public:
explicit Page(Button* button) : button_(button) {
if (button_) {
button_->AddListener(this);
}
}
~Page() {
if (button_ == nullptr) {
return; // 这里跳过了 RemoveListener
}
button_->RemoveListener(this);
}
void DetachButton() {
button_ = nullptr;
}
void OnClick() override {}
private:
Button* button_ = nullptr;
};问题路径:
Button button;
{
Page page(&button); // 注册 listener
page.DetachButton(); // button_ 被置空
} // 析构时提前 return,没有反注册
button.Click(); // button 里残留 Page*,可能 Crash这类代码看起来“有清理逻辑”,但清理依赖的状态已经变了。
更稳妥的原则是:
注册到谁,就明确记录谁;反注册时不要重新猜。
class Page : public ClickListener {
public:
explicit Page(Button* button) : registeredButton_(button) {
if (registeredButton_) {
registeredButton_->AddListener(this);
}
}
~Page() {
Unregister();
}
void Unregister() {
if (registeredButton_) {
registeredButton_->RemoveListener(this);
registeredButton_ = nullptr;
}
}
void OnClick() override {}
private:
Button* registeredButton_ = nullptr;
};再看一个和 UI 完全无关的学生系统例子。
#include <unordered_map>
#include <vector>
#include <string>
struct Student {
int id;
std::string name;
};
struct ClassRoom {
std::vector<int> studentIds;
};
std::unordered_map<int, Student*> studentsById;这里同一份关系被保存了两次:
studentsById:通过 id 找学生;ClassRoom::studentIds:班级里有哪些学生 id。错误写法:只更新了 map,忘了更新班级里的 id。
void ChangeStudentIdWrong(ClassRoom& room, int oldId, int newId) {
auto student = studentsById[oldId];
studentsById.erase(oldId);
studentsById[newId] = student;
student->id = newId;
// 漏了:room.studentIds 里还保存 oldId
}后续遍历班级:
for (int id : room.studentIds) {
Student* student = studentsById[id];
// id 还是旧的,可能查不到对象
}正确做法:所有相关结构一起更新。
void ChangeStudentId(ClassRoom& room, int oldId, int newId) {
auto it = studentsById.find(oldId);
if (it == studentsById.end()) {
return;
}
Student* student = it->second;
studentsById.erase(it);
studentsById[newId] = student;
student->id = newId;
for (int& id : room.studentIds) {
if (id == oldId) {
id = newId;
}
}
}这个例子说明:
如果同一份关系被多个数据结构记录,更新时必须全部同步。
不要只看 Crash 的那一行。Crash 行通常只是“最后踩雷的地方”,不是“埋雷的地方”。
可以按这几个问题往回查:
1. Crash 是否发生在回调、事件、observer、listener、虚函数附近?
2. 是否有 `vector<Listener*>` 这类原始指针容器?
3. 谁把这个指针放进去的?
4. 对象销毁时,是否一定会把指针移除?
5. 清理逻辑里是否有提前 return?
6. 是否有多份 id / parent / child / map / list 需要同步?尤其要警惕这种代码:
void Cleanup() {
if (!parent) {
return;
}
parent->RemoveListener(this);
}要问:
parent不存在,是否真的代表“没有注册过 listener”?
很多问题就藏在这个假设里。
button.AddListener(this);
button.RemoveListener(this);有注册,就必须能证明一定会反注册。
幂等就是调用多次也安全。
void Unregister() {
if (!registeredButton_) {
return;
}
registeredButton_->RemoveListener(this);
registeredButton_ = nullptr;
}这样析构、手动 detach、异常路径都可以调用它。
RAII 的意思是:资源在对象构造时获取,在析构时释放。
简化示例:
class Subscription {
public:
explicit Subscription(std::function<void()> cancel)
: cancel_(std::move(cancel)) {}
~Subscription() {
Cancel();
}
void Cancel() {
if (cancel_) {
cancel_();
cancel_ = nullptr;
}
}
private:
std::function<void()> cancel_;
};组件持有 Subscription,组件销毁时自动取消订阅。
写 C++ 时经常要问:
谁拥有这个对象?
谁负责销毁它?
别人保存的是拥有关系,还是观察关系?
对象销毁前,所有观察关系是否会被清理?常见工具:
std::unique_ptr:唯一拥有;std::shared_ptr:共享拥有;std::weak_ptr:只观察,不延长生命周期;C++ 悬垂指针问题之所以难,是因为它不一定稳定复现。
但不稳定不代表没问题。只要满足:
对象已经销毁
仍有地方保存它的地址
未来可能继续访问这个地址这就是确定的代码风险。
给前端同学的最终心智模型:
C++ 里的指针只是一个地址。地址还在,不代表对象还活着。
排查时重点看三件事:
做到这三点,大部分概率性 UAF Crash 都能更快定位和规避。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。