03 · 指针引用与内存管理
定位:C++ 面试最高频话题,没有之一。智能指针、循环引用、内存泄漏是必考。 学完标准:能手写 unique_ptr 的核心逻辑;能把 shared_ptr 控制块画出来;能说清循环引用与 weak_ptr 解法。
1. 指针 vs 引用(背熟这张表)
Section titled “1. 指针 vs 引用(背熟这张表)”| 对比项 | 指针 | 引用 |
|---|---|---|
| 本质 | 存放地址的变量 | 变量的别名(绑定到对象) |
| 是否占空间 | 是(8 字节/64 位) | 概念上不占(实现上通常是常量指针,编译器优化后常为 0) |
| 必须初始化 | 否(但最好置空) | 必须初始化 |
| 能否改变指向 | 能(指针变量可赋值) | 不能(绑定后不可换) |
| 能否为空 | 能(nullptr) | 不能(没有 NULL 引用) |
| 多级 | 支持 int** |
不支持(引用到引用就是引用本身) |
| 传参语义 | 传值(拷贝指针本身) | 绑定到实参(等价传地址,但用起来像值) |
| 函数重载 | f(int*) 和 f(int&) 可重载 |
同上,两者是不同函数 |
| sizeof | 恒为指针宽度 | 等于所引用对象的大小(sizeof(ref)) |
使用建议:能引用就不用指针;必须为空/可换绑/要存进容器时用指针;函数参数想修改外部对象用引用,想“可能不传”用指针(配合 nullptr 判断)。
必考题:为什么引用比指针“安全”?→ 引用天然非空、不可换绑,少一类 bug;但引用不能重绑定,语义更受限。
2. new / delete vs malloc / free(必背)
Section titled “2. new / delete vs malloc / free(必背)”| malloc / free | new / delete | |
|---|---|---|
| 分配 | 只分配裸内存 | 分配内存 + 调用构造函数 |
| 释放 | 只释放内存 | 调用析构函数 + 释放内存 |
| 返回类型 | void*(需强转) |
类型化指针 T* |
| 大小参数 | 需自己算字节数 | 编译器按类型算 |
| 失败处理 | 返回 NULL |
抛 std::bad_alloc 异常 |
| 运算符重载 | 不支持(库函数) | 可重载 operator new/delete(游戏引擎会自定义分配器) |
| 数组 | malloc(n*sizeof(T)) |
new T[n] / delete[] |
两个经典坑:
- new[] 必须配 delete[]:常见实现会在数组头记录元素个数,
delete[]才知道要析构几个(这是实现细节,标准未规定)。new[]配delete是未定义行为,常见表现是只析构一个元素、其余资源泄漏甚至堆损坏。 - 为什么 C++ 用 new 不用 malloc? 因为 malloc 不调构造、不建对象,类类型用 malloc 得到的是“一块没构造的内存”,行为未定义;且失败方式(返回 NULL vs 抛异常)在 RAII 体系里 new 更配合。
底层关系:new 内部通常调用 operator new(全局或类的),它内部再调用 malloc。所以 new = malloc + 构造。
3. 内存泄漏
Section titled “3. 内存泄漏”定义:堆上分配的内存不再使用,却没有释放,且失去了释放它的手段(指针丢了/覆盖了)。
void leak() { int* p = new int(5); // 忘记 delete p; —— 且函数返回后 p 没了,永远无法释放 → 泄漏}泄漏的危害:长时间运行(游戏跑几小时)内存持续增长 → 卡顿、OOM 崩溃。
检测手段:
- Linux:
valgrind --leak-check=full ./app - Windows:Visual Studio 的 CRT 调试堆(
_CrtDumpMemoryLeaks);或 VS 自带的诊断工具 - 通用:ASAN(AddressSanitizer,编译加
-fsanitize=address) - 游戏引擎:Unreal 的 LLM、自己的内存追踪器
避免手段(金句):RAII + 智能指针,让“资源随作用域自动释放”;绝不裸 new 裸 delete 长期持有;配对检查(每个 new 想清楚谁释放)。
追问:什么是 RAII? RAII(Resource Acquisition Is Initialization)= 资源获取即初始化:把资源(内存、文件、锁、句柄)的获取放进构造函数,释放放进析构函数。对象离开作用域 → 析构自动执行 → 资源自动释放。异常安全、防泄漏。
class File { // 简化示例 FILE* f;public: File(const char* path) { f = fopen(path, "r"); } ~File() { if (f) fclose(f); } // 析构保证释放};4. 智能指针(三大件,必考)
Section titled “4. 智能指针(三大件,必考)”C++11 起在 <memory>:
4.1 unique_ptr(独占所有权)
Section titled “4.1 unique_ptr(独占所有权)”- 独占资源,不允许拷贝,允许移动(move 转移所有权)。
- 默认删除器下通常与裸指针同大小、零额外内存开销,游戏/引擎里默认首选。
- 自定义删除器:可释放非 new 资源(如文件句柄、贴图 ID)。
std::unique_ptr<Monster> m = std::make_unique<Monster>(100);// auto m2 = m; // 编译错误:拷贝被 deleteauto m2 = std::move(m); // 所有权转移,m 变空if (m) { /* 可以判空 */ }4.2 shared_ptr(共享所有权)
Section titled “4.2 shared_ptr(共享所有权)”- 多个指针共享同一对象,引用计数:最后一个销毁时释放对象。
- 引用计数存在控制块(control block)里,在堆上。
- 线程安全:引用计数的增减是原子的;但对象本身不是线程安全的(读要加锁或各用各的)。
std::shared_ptr<Monster> a = std::make_shared<Monster>(100);{ std::shared_ptr<Monster> b = a; // 引用计数 1 → 2} // b 销毁,计数 2 → 1// a 也销毁时,计数 → 0,释放 Monster控制块长这样(必画):
shared_ptr a ──→ ┌───────────────┐ │ 对象(Monster) │ └───────────────┘ ┌───────────────┐ │ 控制块(堆上) │ │ 引用计数: 2 │ │ 弱引用计数: 1 │ │ 删除器 │ │ 分配器 │ └───────────────┘4.3 weak_ptr(弱引用 / 观察者)
Section titled “4.3 weak_ptr(弱引用 / 观察者)”- 不增加强引用计数(只增加弱引用计数),只“观察”对象还在不在。
- 通过
lock()提升为 shared_ptr(对象已销毁 → 返回空)。
💡 lock() 会临时增加强引用计数。lock() 内部:① 原子地检查原强计数是否 > 0,等于 0(对象已销毁)→ 返回空 shared_ptr;② 还活着 → 强计数 +1 并返回一个持有该对象的 shared_ptr(它销毁时计数再 -1)。为什么必须 +1?保证“提升期间对象不会被销毁”——否则访问中途对象被释放就悬垂了。区分:weak_ptr 本身只加弱计数,lock() 返回值是 shared_ptr,加的是强计数。
- 作用:打破循环引用;缓存/观察者模式中“弱持有”。
std::weak_ptr<Monster> w;{ auto s = std::make_shared<Monster>(1); w = s; // 计数仍是 1 if (auto sp = w.lock()) { // 提升成功:对象还活着 sp->hp -= 10; }} // 对象已释放if (w.expired()) { /* 对象没了 */ }4.4 循环引用(必考必考必考)
Section titled “4.4 循环引用(必考必考必考)”struct B; // 前置声明struct A { std::shared_ptr<B> b; ~A(){ printf("A\n"); } };struct B { std::shared_ptr<A> a; ~B(){ printf("B\n"); } };
int main() { auto pa = std::make_shared<A>(); auto pb = std::make_shared<B>(); pa->b = pb; // pa 持有 pb pb->a = pa; // pb 持有 pa → 互相持有 return 0; // main 里 pa/pb 销毁,但计数仍为 1 → 永不释放 → 泄漏}// 打印:什么都不打印(A、B 析构都没被调用)解法:把其中一个方向改成 weak_ptr(谁的引用是非强依赖,谁是“观察者”,谁就 weak):
struct A { std::shared_ptr<B> b; }; // 强struct B { std::weak_ptr<A> a; }; // 弱 —— 破坏循环追问链(面试官最爱一路问下去):
- 循环引用怎么产生的?→ 两个 shared_ptr 互相持强引用。
- 怎么破?→ 一方改 weak_ptr。
- weak_ptr 原理?→ 只加弱引用计数不加引用计数,lock() 提升时检查强计数是否为 0。
- 用 make_shared 还是 shared_ptr(new)?→ 见下节。
- shared_ptr 线程安全吗?→ 计数安全(原子操作),所指对象不安全。
💡 分两层看: ① 引用计数:多个线程各自拷贝/销毁同一批 shared_ptr(共享同一控制块)时,计数增减是原子操作,不会数错 → 不会 double free / 泄漏。这是“计数安全”。 ② 所指对象:shared_ptr 只保证“指针的共享”安全,不保证对象内容的读写安全。两个线程同时改
monster->hp,是数据竞争 → UB,需要自己加锁或用 atomic。 一句话:shared_ptr 管“指针”不管“对象”。它只替你把“谁最后离开谁释放”做对,对象内部的状态同步是你自己的事。
- shared_ptr 性能如何?→ 原子计数有开销,比裸指针慢;游戏高频对象慎用,多用值类型/unique_ptr/裸指针+所有权约定。
4.5 make_shared vs shared_ptr(new T)(高频追问)
Section titled “4.5 make_shared vs shared_ptr(new T)(高频追问)”auto sp1 = std::make_shared<T>(); // 推荐auto sp2 = std::shared_ptr<T>(new T); // 不推荐| make_shared | shared_ptr(new T) | |
|---|---|---|
| 内存分配次数 | 1 次(对象+控制块一起) | 2 次(对象一次、控制块一次) |
| 缓存友好 | 好(对象和控制块挨着) | 差(可能相距很远) |
| 异常安全 | 安全:参数求值顺序问题不存在 | 危险:f(shared_ptr(new T), g()) 若 g 先抛异常,new 的内存泄漏 |
| 删除器 | 用默认 | 可自定义 |
| 特殊需求 | 对象+控制块同一块内存:强计数归零时对象析构,但整块内存要等弱计数也归零才释放 | 对象内存与控制块分离:强计数归零即释放对象,弱引用只保留小块控制块 |
💡 后两行展开: 删除器(deleter):make_shared 只能用默认删除器(
delete);shared_ptr(new T)能传自定义删除器shared_ptr<T>(p, customDeleter),用于释放非 new 资源(如FILE*、贴图 IDGLuint)。游戏里贴图/音效/句柄类资源常用。 特殊需求 / 生命周期分离:make_shared 把“对象 + 控制块”放同一块内存,强计数归零 → 对象析构,但这整块内存(含对象那部分)要等弱计数也归零才还给系统。所以对象很大(如 100MB 贴图数据)且还有长期存活的 weak_ptr 在观察它 → 对象虽析构但内存一直被占。改用shared_ptr(new T):对象内存与控制块分开,强计数归零 → 对象内存立即释放,weak_ptr 只留小块控制块。→ 结论:对象大 + 有 weak_ptr + 想尽早释放对象内存 → 用 new;否则用 make_shared。
结论金句:能用 make_shared 就用 make_shared(省一次分配 + 异常安全);需要自定义删除器,或对象很大且存在 weak_ptr、希望对象内存尽早释放时,用直接 new。
5. 悬垂指针 / 野指针 / 空指针
Section titled “5. 悬垂指针 / 野指针 / 空指针”- 野指针(uninitialized):声明后没初始化,指向任意垃圾地址。
- 悬垂指针(dangling):曾经有效,指向的对象已被释放(delete 后没置空、函数返回了局部变量地址)。
- 空指针(nullptr):指向空,访问会崩溃但可判空。
int* p1; // 野指针int* p2 = new int(1);delete p2;// p2 此时是悬垂指针(dangling)p2 = nullptr; // 释放后立即置空if (p2) { /* 安全 */ }
int* bad() { int x = 42; return &x; } // 返回局部变量地址 → 悬垂避免三件套:① 初始化即置空或绑定;② delete 后置空;③ 优先智能指针。
追问:访问悬垂指针会怎样?→ 未定义行为(UB):可能读到旧值、读到被复用的新对象、直接崩溃。
6. const 与指针的组合(背这个口诀)
Section titled “6. const 与指针的组合(背这个口诀)”口诀:从右往左读,
*读作“指针”,const 修饰离它最近的左侧类型。
| 写法 | 读法 | 含义 |
|---|---|---|
const int* p |
p 是指向 const int 的指针 | 指向的值不能改;指针可改 |
int const* p |
同上(等价) | 同上 |
int* const p |
p 是 const 指针,指向 int | 指针本身不能改;指向的值可改 |
const int* const p |
指向 const int 的 const 指针 | 都不能改 |
int a = 1, b = 2;const int* p = &a; // *p = 10; 错误!值只读p = &b; // 可以:指针可改
int* const q = &a;*q = 10; // 可以q = &b; // 错误!指针不可改常函数 / 常量成员(呼应 02 自测第 10 题):const 成员函数里 this 是 const T*,所有成员只读;同时只有 const 成员函数能被 const 对象调用。
class Player { int hp;public: int getHp() const { return hp; } // 常量成员函数:承诺不改对象};const Player p; // const 对象只能调 const 成员int x = p.getHp(); // 正确7. 内存池 / 对象池(游戏高频概念)
Section titled “7. 内存池 / 对象池(游戏高频概念)”为什么游戏里要自己管内存?
- 每次 new/delete 都要经过通用堆分配器(加锁、查找空闲块,甚至系统调用),慢;
- 大量小对象频繁增删 → 内存碎片 → 内存够用却分配失败;
- 游戏一帧要创建销毁大量临时对象(子弹、特效、粒子、消息)。
内存池(Memory Pool):启动时一次性 malloc 一大块内存,切成固定大小的块,用自由链表管理。分配/释放都是 O(1) 指针操作,无系统调用,无碎片。
对象池(Object Pool):不是“复用内存”而是“复用对象”。预创建一批对象,用“空闲/活跃”标记,用完归还而不是销毁。
// 极简内存池示意(固定大小块)class Pool { struct Node { Node* next; }; Node* head = nullptr;public: void init(void* mem, int blockSize, int count) { // 把大块内存切成 count 个 blockSize 的块,串成链表 } void* alloc() { void* p = head; head = head->next; return p; } // O(1) void free(void* p) { ((Node*)p)->next = head; head = (Node*)p; } // O(1)};游戏里常见的应用:子弹/粒子的对象池、std::allocator 自定义分配器、Unreal 的 FMemory、渲染内存的帧内分配器(Frame Allocator)。
追问:帧分配器是什么?→ 每帧开一块内存,帧内分配只做指针递增,帧末整块复位(不需要逐个释放),速度快,适合“只活一帧”的临时数据(渲染命令、临时变换矩阵)。
💡 内存池 / 对象池 / 帧分配器 详解(游戏实战向): 为什么游戏要自己管内存:通用 malloc 慢——要加锁、在自由链表里找合适大小的空闲块、必要时系统调用。而游戏一帧要创建/销毁成千上万个小对象(子弹、粒子、消息、临时变换),全走通用堆 → 又慢又碎;碎片到最后“明明有空间却分配不出连续块”。 内存池:启动时一次性 malloc 一大块,按固定大小切成块串成链表。alloc 弹表头、free 插回表头,都 O(1),不碰系统、无碎片。适合“大量同尺寸对象”。 对象池:在内存池之上复用对象实例——预创建一批,用“空闲/活跃”标志管理,用完归还并重置状态而不是销毁,省掉反复构造/析构。子弹、粒子、特效、怪物、UI 弹窗都是典型。 帧分配器(Frame/Stack Allocator):每帧开头申请一大块,帧内分配只做指针递增,帧末指针整体复位(一次性释放全部),不逐个 free。适合“只活一帧”的临时数据:渲染命令、临时矩阵、每帧事件消息。 真实引擎对应:UE 的
FMemory/自定义分配器、物理引擎(PhysX/Bullet)的宽阶段分配器、ECS 用连续数组一次分配一堆。很多引擎是分层分配:能走帧分配器就先走,不够再退回通用堆。 三个实战注意:① 对象池复用前必须重置状态(否则上一帧残留);② 内存对齐(alignas,SIMD 数据尤其);③ 多线程分配器要加锁或用线程本地池(TLP)。
8. 高频面试题 Q&A(合上书能讲)
Section titled “8. 高频面试题 Q&A(合上书能讲)”Q1:指针和引用的区别? 至少说 5 点:初始化(引用必须)、可换绑(指针可)、可为空(指针可)、空间(引用概念上 0 空间)、多级(指针可)、重载/语法。
Q2:new 和 malloc 的区别? new = 分配 + 构造、类型安全、抛异常;malloc 只分配裸内存、返回 void*、失败返回 NULL。
Q3:内存泄漏是什么?怎么避免? 堆内存失去释放手段;RAII + 智能指针 + 释放后置空 + 工具检测(valgrind/ASAN)。
Q4:shared_ptr 的实现原理? 对象 + 堆上控制块(引用计数/弱计数/删除器),最后一个强引用销毁时释放对象;计数原子操作。
Q5:weak_ptr 为什么能打破循环引用? 不增加引用计数,只观察;对象销毁后 lock() 返回空,不会造成“互相等对方释放”。
Q6:make_shared 和 shared_ptr(new T) 区别? 一次 vs 两次分配;异常安全;前者对象+控制块连续更缓存友好;后者可自定义删除器、对象和控制块生命周期可分离。
Q7:为什么 delete[] 和 delete 不能混用? 两者簿记不同(常见实现会在数组头记录元素个数);混用是未定义行为,可能只析构一个元素、其余资源泄漏甚至堆损坏。
Q8:unique_ptr 为什么不能拷贝? 独占所有权语义:拷贝会产生两个都认为拥有资源的指针,double free;所以禁拷贝允许移动。
Q9:引用作为返回值有什么坑? 不能返回局部变量或按值传入的形参的引用(都是悬垂);只能返回引用参数、成员、静态对象等生命周期比引用长的对象。
Q10:什么是 RAII?和智能指针什么关系? 资源获取即初始化,构造获取资源、析构释放资源;智能指针就是 RAII 思想的实现。这是 C++ 防泄漏的基石。
Q11:游戏里为什么用内存池? 避免系统调用、避免碎片、分配 O(1)、缓存友好。
Q12:const 对象能调用非 const 成员函数吗? 不能,编译报错;const 对象只能调 const 成员函数(这是 const 正确性,团队规范里会强制)。
9. 本章自测(12 题)
Section titled “9. 本章自测(12 题)”1. `std::shared_ptr` 引用计数放哪?
我的原答:操作是否线程安全?堆上的控制块;计数是原子级操作,但是对象不是
2. 循环引用的两个结构,一方改 weak_ptr 后,weak 的方向是"谁观察谁"?
💡 题意:循环里 A→B、B→A 都持强引用,把其中一个方向改成 weak_ptr,这个 weak 方向代表"谁不保证对方活着"。判断:谁不依赖对方存活谁就 weak——典型:子→父 用 weak(子活着需要父,但父不关心子死活);资源→管理器用 weak(管理器必须活着来管理资源)。我的原答:这题目啥意思
3. 手写一个 `String` 类需要注意哪些点(深拷贝/赋值/移动/析构)?
💡 先记清单(第 4 章展开):深拷贝构造、深拷贝赋值(防自赋值、先释放再拷贝)、移动构造/移动赋值(转移指针+置空原对象)、析构释放、Rule of Five。我的原答:(第 4 章会展开,先列清单)不会,这不还没到第四章
4. `const int*` 和 `int* const` 区别?
💡 表述再精确:`const int*` = 指向 const int 的指针 → **指向的值**不能改,指针可换;`int* const` = 指向 int 的 const 指针 → **指针本身**不能换,指向的值可改。注意别把"值"和"指针"说反(你两句对调了对象)。我的原答:指针指向常量、值是常量的指针
5. `void f(int* p)` 和 `void f(int& r)` 调用上有什么区别?
我的原答:前者需要对象的地址、后者直接传递对象
6. 为什么 `std::move` 之后对象"不再安全"?
💡 不完全像悬垂。被 move 后的对象是"有效但未指定"(valid but unspecified):资源通常已转移、内部指针被置空,但对象本身仍**合法**——能再赋值、能安全析构,只是旧值不可依赖。悬垂指针是访问**已释放内存**(UB);访问被 move 的对象是合法的,别搞混。我的原答:(预告:见第 8 章,先写你自己的理解)类似于悬垂指针吧
7. 内存池为什么分配是 O(1)?
我的原答:只需要移动head就可以了
8. 悬垂指针怎么产生的?
💡 三件套 = **① 初始化即置空/绑定**(别留未初始化野指针)② delete 后置空 ③ 用智能指针。你缺第①个。我的原答:三个避免手段?释放对应内存空间后的指针;置空、智能指针、想不到了
9. `delete` 一个指向栈对象的指针会怎样?
我的原答:(UB:double free / 崩溃)栈返回时会再次删除,引发double free
10. unique_ptr 能放进 vector 吗?为什么?
💡 ❌ 记反了!unique_ptr **能**放进 vector(`vector我的原答:能放进需要拷贝的容器吗?不能,涉及拷贝操作的都不可以
11. 一个 shared_ptr 拷贝后引用计数从 1 变 2,这两个 shared_ptr 的 use_count 在哪里体现?不会
💡 在**控制块的强引用计数**里。两个 shared_ptr 共享同一个控制块,所以 a.use_count() 和 b.use_count() 返回同一个值(2)。12. 裸指针在什么情况下是"合理"的选择?
我的原答:(游戏性能热路径、不拥有资源)
答案提示(每题在正文里都有):
1. 堆上的控制块;
我的原答:计数增减是原子的(安全),对象本身不保证(不安全)。
2. 观察者方向 weak:谁不需要"确保对方活着"谁就 weak。
3. 构造/拷贝构造/赋值(深拷贝)、移动构造/移动赋值(搬指针并置空)、析构。
4. const 修饰值 vs 修饰指针本身。
5. 引用调用方无需关心空;
我的原答:指针需要判空;引用无法传空。
6. move 只是转型;
我的原答:被移动对象通常资源被转移、指针置空(标准只规定"有效但未指定"),内容不可依赖。
7. 分配只弹出链表头,释放只把块插回表头,都是指针操作。
8. delete 后未置空、返回局部地址;
我的原答:初始化置空、释放后置空、用智能指针。
9. 未定义行为。
10. 能(unique_ptr 可移动),vector 扩容时用移动不拷贝;
我的原答:需要拷贝的容器(如需要传值存储的)不行。
11. 在控制块的强引用计数里。
12. 性能热路径、明确所有权归属、不独占资源时(游戏引擎内部大量用裸指针+所有权规则)。
- 智能指针在 UE 里对应什么?(TSharedPtr/TWeakPtr/UObject 指针,第 13 章)
std::unique_ptr自定义删除器怎么实现一个“对象池回收”的删除器?- 64 位下指针 8 字节,为什么不能用整型代替?(类型安全、可空语义、语言级约束)
- 什么是 placement new?(在已有内存上构造对象,游戏引擎常用:
new(ptr) T(...))