跳转到内容

03 · 指针引用与内存管理

定位:C++ 面试最高频话题,没有之一。智能指针、循环引用、内存泄漏是必考。 学完标准:能手写 unique_ptr 的核心逻辑;能把 shared_ptr 控制块画出来;能说清循环引用与 weak_ptr 解法。


对比项 指针 引用
本质 存放地址的变量 变量的别名(绑定到对象)
是否占空间 是(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[]

两个经典坑:

  1. new[] 必须配 delete[]:常见实现会在数组头记录元素个数,delete[] 才知道要析构几个(这是实现细节,标准未规定)。new[]delete未定义行为,常见表现是只析构一个元素、其余资源泄漏甚至堆损坏。
  2. 为什么 C++ 用 new 不用 malloc? 因为 malloc 不调构造、不建对象,类类型用 malloc 得到的是“一块没构造的内存”,行为未定义;且失败方式(返回 NULL vs 抛异常)在 RAII 体系里 new 更配合。

底层关系new 内部通常调用 operator new(全局或类的),它内部再调用 malloc。所以 new = malloc + 构造。


定义:堆上分配的内存不再使用,却没有释放,且失去了释放它的手段(指针丢了/覆盖了)。

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); } // 析构保证释放
};

C++11 起在 <memory>

  • 独占资源,不允许拷贝,允许移动(move 转移所有权)。
  • 默认删除器下通常与裸指针同大小、零额外内存开销,游戏/引擎里默认首选。
  • 自定义删除器:可释放非 new 资源(如文件句柄、贴图 ID)。
std::unique_ptr<Monster> m = std::make_unique<Monster>(100);
// auto m2 = m; // 编译错误:拷贝被 delete
auto m2 = std::move(m); // 所有权转移,m 变空
if (m) { /* 可以判空 */ }
  • 多个指针共享同一对象,引用计数:最后一个销毁时释放对象。
  • 引用计数存在控制块(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 │
│ 删除器 │
│ 分配器 │
└───────────────┘
  • 不增加引用计数(只增加弱引用计数),只“观察”对象还在不在。
  • 通过 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()) { /* 对象没了 */ }
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; }; // 弱 —— 破坏循环

追问链(面试官最爱一路问下去):

  1. 循环引用怎么产生的?→ 两个 shared_ptr 互相持强引用。
  2. 怎么破?→ 一方改 weak_ptr。
  3. weak_ptr 原理?→ 只加弱引用计数不加引用计数,lock() 提升时检查强计数是否为 0。
  4. 用 make_shared 还是 shared_ptr(new)?→ 见下节。
  5. shared_ptr 线程安全吗?→ 计数安全(原子操作),所指对象不安全。

💡 分两层看: ① 引用计数:多个线程各自拷贝/销毁同一批 shared_ptr(共享同一控制块)时,计数增减是原子操作,不会数错 → 不会 double free / 泄漏。这是“计数安全”。 ② 所指对象:shared_ptr 只保证“指针的共享”安全,不保证对象内容的读写安全。两个线程同时改 monster->hp,是数据竞争 → UB,需要自己加锁或用 atomic。 一句话:shared_ptr 管“指针”不管“对象”。它只替你把“谁最后离开谁释放”做对,对象内部的状态同步是你自己的事。

  1. 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*、贴图 ID GLuint)。游戏里贴图/音效/句柄类资源常用。 特殊需求 / 生命周期分离:make_shared 把“对象 + 控制块”放同一块内存,强计数归零 → 对象析构,但这整块内存(含对象那部分)要等弱计数也归零才还给系统。所以对象很大(如 100MB 贴图数据)且还有长期存活的 weak_ptr 在观察它 → 对象虽析构但内存一直被占。改用 shared_ptr(new T):对象内存与控制块分开,强计数归零 → 对象内存立即释放,weak_ptr 只留小块控制块。→ 结论:对象大 + 有 weak_ptr + 想尽早释放对象内存 → 用 new;否则用 make_shared。

结论金句:能用 make_shared 就用 make_shared(省一次分配 + 异常安全);需要自定义删除器,或对象很大且存在 weak_ptr、希望对象内存尽早释放时,用直接 new。


  • 野指针(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 成员函数里 thisconst 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 正确性,团队规范里会强制)。


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>` 是常见写法)。vector 只要元素"可移动"(move-insertable),不要求可拷贝;unique_ptr 可移动,所以 `v.push_back(make_unique())`、扩容走移动构造都合法。真正不行的是需要**拷贝语义**的场合(复制整个 vector、要求 CopyInsertable 的算法)。记住:**不能拷贝 ≠ 不能进 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. 性能热路径、明确所有权归属、不独占资源时(游戏引擎内部大量用裸指针+所有权规则)。


## 10. 进阶追问
  • 智能指针在 UE 里对应什么?(TSharedPtr/TWeakPtr/UObject 指针,第 13 章)
  • std::unique_ptr 自定义删除器怎么实现一个“对象池回收”的删除器?
  • 64 位下指针 8 字节,为什么不能用整型代替?(类型安全、可空语义、语言级约束)
  • 什么是 placement new?(在已有内存上构造对象,游戏引擎常用:new(ptr) T(...)