Appearance
内存布局
一个 C++ 对象内存里有什么?
一个 C++ 对象内存里,主要包含:非静态成员变量、对齐填充 padding、继承来的基类子对象、多态对象可能有的虚表指针 vptr。
对象里通常有
非静态成员变量:
c
class Player // 定义一个 Player 类
{ // 类体开始
public: // public 访问区开始
int hp; // 非静态成员变量,会存在每个 Player 对象里
float speed; // 非静态成员变量,也会存在每个 Player 对象里
}; // 类定义结束每个 Player 对象都有自己的 hp 和 speed。
对象里可能有 padding
为了内存对齐,编译器可能在成员之间或对象末尾插入 padding。
c
struct Data // 定义一个结构体 Data
{ // 结构体开始
char a; // 1 字节成员
int b; // 4 字节成员,通常需要按 4 字节对齐
}; // 结构体结束它不一定是 1 + 4 = 5 字节,很多平台上可能是 8 字节,因为中间有对齐填充。
多态对象里可能有 vptr
如果类有虚函数,常见实现会在对象里放一个虚表指针 vptr。
c
class Enemy // 定义一个 Enemy 类
{ // 类体开始
public: // public 访问区开始
virtual void Attack() {} // 虚函数,会让对象通常带有 vptr
int hp; // 普通非静态成员变量
}; // 类定义结束vptr 指向虚表 vtable。 对象里通常存的是 vptr,虚表本身不在每个对象里,而是在程序的某个共享区域。
对象里没有什么
成员函数代码不在对象里。 所有对象共享同一份函数代码。
static 成员变量不在每个对象里。 它属于类,不属于某一个对象实例。
c
class Monster // 定义一个 Monster 类
{ // 类体开始
public: // public 访问区开始
static int count; // 静态成员变量,不在每个 Monster 对象里
int hp; // 非静态成员变量,在每个 Monster 对象里
}; // 类定义结束所以 sizeof(Monster) 通常只会算 hp、padding、可能的 vptr,不会把 count 算进每个对象。
继承时对象里有什么
如果有继承,派生类对象里会包含基类子对象。
c
class Actor // 定义基类 Actor
{ // Actor 类体开始
public: // public 访问区开始
int id; // Actor 的非静态成员
}; // Actor 类定义结束
class Player : public Actor // Player 继承 Actor
{ // Player 类体开始
public: // public 访问区开始
int hp; // Player 自己的非静态成员
}; // Player 类定义结束Player 对象里大概包含:
Actor那一部分,也就是idPlayer自己的部分,也就是hp- 可能的 padding
面试高分回答
NOTE
C++ 对象内存里主要存放非静态数据成员,以及为了对齐产生的 padding。如果类有继承,派生类对象里还包含基类子对象。如果类有虚函数,常见编译器实现会在对象里放一个虚表指针 vptr,指向类的虚表,但虚表本身不在每个对象内部。成员函数代码不在对象里,所有对象共享函数代码;static 成员也不属于单个对象,不会计入每个对象的大小。sizeof 看到的主要是成员变量、padding、可能的 vptr 和基类子对象。需要注意,具体布局由编译器和 ABI 决定。
空类大小为什么通常是 1?
空类大小为什么通常是 1?
因为 C++ 需要保证:每个完整对象都要有可区分的地址。空类虽然没有数据成员,但对象本身仍然要能被取地址、放进数组、做指针运算。
简单例子
c
#include <iostream> // 引入输入输出库,用来使用 std::cout
using namespace std; // 使用标准命名空间,方便直接写 cout
class Empty // 定义一个空类 Empty
{ // 空类类体开始
}; // 空类类体结束
int main() // 程序入口函数
{ // main 函数体开始
Empty a; // 创建第一个 Empty 对象
Empty b; // 创建第二个 Empty 对象
cout << sizeof(Empty) << endl; // 通常输出 1
cout << (&a == &b) << endl; // 通常输出 0,因为两个对象地址不同
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束为什么不能是 0
如果 sizeof(Empty) 是 0,就会有问题:
c
Empty arr[3]; // 创建 3 个 Empty 对象组成的数组数组里每个元素都应该有不同地址:
&arr[0]&arr[1]&arr[2]
如果每个元素大小是 0,那么指针往后移动时就不会前进,多个对象可能落在同一个地址。这样对象就无法区分了。
所以编译器通常给空类对象塞一个“占位字节”。
这个 1 字节不是为了存业务数据,而是为了让对象能被正常寻址。
空类里真的什么都没有吗
空类没有非静态数据成员:
c
class Empty // 定义空类
{ // 类体开始
}; // 类体结束但是成员函数不算进每个对象大小,static 成员也不算进每个对象大小。
c
class Test // 定义 Test 类
{ // 类体开始
public: // public 区域开始
static int count; // static 成员不属于某个对象,不进每个对象内存
void Func() {} // 成员函数代码不放在每个对象里
}; // 类体结束所以如果没有非静态成员、没有虚函数,它仍然通常是 1。
什么时候不是 1
如果空类有虚函数,就不再是普通空类了,常见实现会有虚表指针 vptr。
c
class EmptyVirtual // 定义带虚函数的类
{ // 类体开始
public: // public 区域开始
virtual void Func() {} // 虚函数通常会让对象里多一个 vptr
}; // 类体结束这时 sizeof(EmptyVirtual) 通常是指针大小,比如 64 位下可能是 8。
特殊情况:空基类优化
空类作为基类时,编译器可能做 EBO,Empty Base Optimization。
c
class Empty // 定义空基类
{ // 类体开始
}; // 类体结束
class Player : public Empty // Player 继承 Empty
{ // 类体开始
public: // public 区域开始
int hp; // Player 自己的成员变量
}; // 类体结束这里 Empty 作为基类子对象,编译器可能不额外占空间。 所以 sizeof(Player) 可能就是 int 的大小加对齐,而不会额外多 1。
面试高分回答
IMPORTANT
空类没有非静态数据成员,但 C++ 要求完整对象可以被取地址,并且不同对象需要能通过地址区分。如果空类大小是 0,那么数组里的多个空对象可能地址相同,指针运算也无法正常前进。所以编译器通常让空类对象占 1 字节,这个字节不是存数据,而是占位,保证对象有唯一地址。需要注意,成员函数和 static 成员不在每个对象里;如果类有虚函数,通常会因为 vptr 变大;如果空类作为基类,还可能触发空基类优化,减少额外空间。
对齐和填充是什么?
对齐 alignment 是:数据最好放在某个地址倍数上。 填充 padding 是:编译器为了满足对齐要求,自动插入的空字节。
为什么需要对齐
CPU 访问某些类型时,希望它们放在合适地址上。
常见情况:
char通常 1 字节对齐int通常 4 字节对齐double通常 8 字节对齐- 指针在 64 位下一般 8 字节对齐
比如 int 最好放在 4 的倍数地址,double 最好放在 8 的倍数地址。这样 CPU 访问更高效,某些平台还可能要求必须对齐访问。
什么是 padding
看这个结构体:
c
#include <iostream> // 引入输入输出库,用来使用 std::cout
using namespace std; // 使用标准命名空间,方便直接写 cout
struct A // 定义结构体 A
{ // 结构体 A 开始
char c; // char 占 1 字节,对齐要求通常是 1
int i; // int 占 4 字节,对齐要求通常是 4
}; // 结构体 A 结束
int main() // 程序入口函数
{ // main 函数开始
cout << sizeof(A) << endl; // 输出结构体 A 的大小,很多平台上是 8
return 0; // 返回 0,表示程序正常结束
} // main 函数结束很多平台上 sizeof(A) 不是 1 + 4 = 5,而是 8。
原因是:
char c占 1 字节- 后面的
int i要放到 4 的倍数地址 - 所以编译器在
c后面补 3 个空字节 int i占 4 字节- 总大小变成 8
这 3 个空字节就是 padding。
成员顺序会影响大小
比如:
c
struct Bad // 定义一个成员顺序不太好的结构体
{ // Bad 结构体开始
char c; // char 占 1 字节
double d; // double 通常占 8 字节,要求 8 字节对齐
int i; // int 通常占 4 字节,要求 4 字节对齐
}; // Bad 结构体结束它可能占 24 字节。
换个顺序:
c
struct Good // 定义一个成员顺序更好的结构体
{ // Good 结构体开始
double d; // 先放对齐要求最大的 double
int i; // 再放 int
char c; // 最后放 char
}; // Good 结构体结束它可能只占 16 字节。
原因是把对齐要求大的成员放前面,能减少中间 padding。
结构体整体也要对齐
结构体的总大小通常要是其最大成员对齐值的倍数。
比如结构体里最大对齐要求是 8,那么整个结构体大小通常也会补齐到 8 的倍数。
这样数组才能正确对齐:
c
Good arr[10]; // 数组里每个 Good 对象都要保证起始地址满足对齐要求如果结构体总大小不补齐,arr[1] 的起始地址可能就不满足对齐要求。
能不能强行取消 padding
可以用类似 #pragma pack 的方式改变对齐规则,但不能乱用。
它可能让结构体变小:
c
#pragma pack(push, 1) // 将结构体对齐设置为 1 字节
struct Packed // 定义紧凑结构体
{ // Packed 结构体开始
char c; // char 占 1 字节
int i; // int 占 4 字节,但这里可能不按 4 字节对齐
}; // Packed 结构体结束
#pragma pack(pop) // 恢复之前的对齐设置风险是:
- CPU 访问可能变慢
- 某些平台非对齐访问可能出问题
- 跨平台二进制布局更容易踩坑
- 和网络包、文件格式、硬件结构交互时要特别小心
面试高分回答
CAUTION
对齐是指不同类型的数据通常需要放在满足特定倍数的地址上,比如 int 常按 4 字节对齐,double 常按 8 字节对齐。填充是编译器为了满足成员对齐和结构体整体对齐,在成员之间或末尾插入的空字节。它不存业务数据,但会影响 sizeof。成员顺序会影响 padding,通常把对齐要求大的成员放前面可以减少浪费。虽然可以用 #pragma pack 改变对齐,但可能带来访问变慢和跨平台风险,所以不能为了省几个字节随便乱用。
虚函数会让对象多出什么?
常见编译器实现里,类只要有虚函数,对象里通常会多出一个 虚表指针 vptr。 vptr 指向这个类对应的 虚函数表 vtable。
普通对象
没有虚函数时,对象里通常主要是非静态成员变量和 padding。
c
class Player // 定义一个普通 Player 类
{ // 类体开始
public: // public 区域开始
int hp; // 非静态成员变量,会存在每个对象里
void Attack() {} // 普通成员函数,函数代码不在每个对象里
}; // 类定义结束Attack() 这份函数代码不会复制到每个对象里。 所有 Player 对象共享同一份函数代码。
有虚函数的对象
c
class Enemy // 定义一个有虚函数的 Enemy 类
{ // 类体开始
public: // public 区域开始
virtual void Attack() {} // 虚函数,常见实现会让对象里多一个 vptr
int hp; // 非静态成员变量,存在每个对象里
}; // 类定义结束这种对象里通常有:
vptrhp- padding
如果是 64 位程序,一个指针通常是 8 字节,所以对象大小可能会多 8 字节。
vptr 和 vtable 分别是什么
vptr:虚表指针,通常存在每个多态对象里。 vtable:虚函数表,通常是类级别共享的表,不是每个对象都有一份。
可以理解为:
对象里有一个 vptr,它指向一张表。 这张表里记录了虚函数最终该调用哪个函数地址。
虚函数调用怎么走
比如:
c
class Base // 定义基类 Base
{ // Base 类体开始
public: // public 区域开始
virtual void Attack() {} // 定义虚函数 Attack
}; // Base 类定义结束
class Monster : public Base // Monster 继承 Base
{ // Monster 类体开始
public: // public 区域开始
void Attack() override {} // 重写基类虚函数 Attack
}; // Monster 类定义结束
int main() // 程序入口函数
{ // main 函数体开始
Base* p = new Monster(); // 基类指针指向派生类对象
p->Attack(); // 通过 vptr 找到 Monster 的 Attack 实现
delete p; // 释放对象,这里实际项目中基类析构函数应声明为 virtual
return 0; // 返回 0,表示程序正常结束
} // main 函数体结束执行 p->Attack() 时,大概过程是:
- 通过
p找到对象 - 从对象里读取
vptr - 通过
vptr找到vtable - 在
vtable里找到Attack对应的函数地址 - 调用
Monster::Attack
这就是运行时多态的底层基础。
多继承会更复杂
如果一个类有多个多态基类,可能会有多个 vptr。 如果涉及虚继承,还可能有额外的指针、偏移信息或更复杂的布局。
所以面试里要说“通常”或“常见实现”,不要说 C++ 标准规定对象里一定怎么放。C++ 标准不强制规定 vptr/vtable 的具体布局,这是编译器和 ABI 的实现细节。
面试高分回答
NOTE
虚函数通常会让对象多出一个虚表指针 vptr。vptr 指向该类的虚函数表 vtable,虚表中保存虚函数的入口地址。虚函数调用时,会先通过对象里的 vptr 找到虚表,再根据虚函数在表中的位置找到最终要调用的函数,实现运行时多态。需要注意,vptr 通常在每个多态对象里,而 vtable 通常是类级别共享的,不是每个对象一份。单继承下一般多一个指针大小,64 位下常见是 8 字节;多继承和虚继承时布局会更复杂。
多继承对象布局有什么特点?
多继承的核心特点是:派生类对象里会包含多个基类子对象。如果这些基类有虚函数,对象里还可能有 多个 vptr,并且基类指针转换时可能发生 指针偏移调整。
普通多继承
c
class A // 定义基类 A
{ // A 类体开始
public: // public 区域开始
int a; // A 的非静态成员变量
}; // A 类定义结束
class B // 定义基类 B
{ // B 类体开始
public: // public 区域开始
int b; // B 的非静态成员变量
}; // B 类定义结束
class C : public A, public B // C 同时继承 A 和 B
{ // C 类体开始
public: // public 区域开始
int c; // C 自己的非静态成员变量
}; // C 类定义结束C 对象里大概包含:
A子对象,也就是A::aB子对象,也就是B::bC自己的成员,也就是C::c- 可能的 padding
所以 C 不是只保存 c,它还要保存继承来的基类部分。
有虚函数时
如果 A 和 B 都有虚函数,常见实现里 C 对象可能有多个 vptr。
c
class A // 定义基类 A
{ // A 类体开始
public: // public 区域开始
virtual void FA() {} // A 的虚函数,A 子对象通常需要 vptr
}; // A 类定义结束
class B // 定义基类 B
{ // B 类体开始
public: // public 区域开始
virtual void FB() {} // B 的虚函数,B 子对象通常需要 vptr
}; // B 类定义结束
class C : public A, public B // C 多继承 A 和 B
{ // C 类体开始
public: // public 区域开始
int c; // C 自己的成员变量
}; // C 类定义结束此时 C 对象里可能类似:
A子对象:带A_vptrB子对象:带B_vptrC自己的成员
注意:这是常见 ABI 的实现方式,C++ 标准不强制规定具体 vptr 放在哪里。
基类指针地址可能不同
这是多继承非常重要的一点。
c
C obj; // 创建 C 对象
A* pa = &obj; // 转成 A 指针,通常指向 C 对象起始处
B* pb = &obj; // 转成 B 指针,可能指向 C 对象中间的 B 子对象pa 和 pb 的地址值可能不同。
原因是:
A子对象可能在对象开头B子对象可能在A子对象后面- 所以
B*需要指向B子对象的起始位置
这就是指针偏移调整。
虚调用时 this 也可能要调整
如果通过 B* 调用一个最终由 C 重写的虚函数,传给函数的 this 可能需要从 B 子对象地址调整回 C 对象地址。
编译器可能通过:
- thunk 小跳板函数
- vtable 里的 offset 信息
- ABI 规定的 this adjustment
来完成这个过程。
这个点说出来,面试官会觉得你是真的理解底层。
菱形继承更复杂
普通菱形继承:
c
class A // 定义共同基类 A
{ // A 类体开始
public: // public 区域开始
int a; // A 的成员变量
}; // A 类定义结束
class B : public A // B 继承 A
{ // B 类体开始
}; // B 类体结束
class C : public A // C 继承 A
{ // C 类体开始
}; // C 类体结束
class D : public B, public C // D 同时继承 B 和 C
{ // D 类体开始
}; // D 类体结束D 里会有两份 A 子对象:
- 一份来自
B - 一份来自
C
这会导致二义性:
c
D d; // 创建 D 对象
// d.a; // 错误,编译器不知道你要 B 那边的 A,还是 C 那边的 A可以用虚继承解决:
c
class B : virtual public A // B 虚继承 A
{ // B 类体开始
}; // B 类体结束
class C : virtual public A // C 虚继承 A
{ // C 类体开始
}; // C 类体结束
class D : public B, public C // D 同时继承 B 和 C
{ // D 类体开始
}; // D 类体结束虚继承会让 D 里共享一份 A 虚基类子对象,但对象布局会更复杂,可能有虚基类指针、虚基类表或偏移信息。
面试高分回答
TIP
多继承对象布局的特点是派生类对象中会包含多个基类子对象,通常按照继承声明顺序排列,但具体布局由编译器和 ABI 决定。如果多个基类都有虚函数,那么派生类对象里可能有多个 vptr,分别服务于不同的基类子对象。多继承下,派生类指针转成不同基类指针时,地址可能不同,比如 C* 转 B* 可能要加偏移,指向对象内部的 B 子对象。虚函数调用时也可能需要 this 指针调整。菱形继承还可能产生两份共同基类子对象,导致二义性;虚继承可以共享一份虚基类,但会让对象布局和访问成本更复杂。
sizeof 和实际占用内存一定一样吗?
不一定。sizeof 得到的是 对象本体的静态大小,但实际运行时占用可能还包括 堆内存、分配器额外开销、容器容量、系统内存页开销 等。
sizeof 算什么
sizeof 通常会算:
- 非静态成员变量
- 对齐填充
padding - 多态对象里常见的
vptr - 内嵌数组
- 基类子对象
比如:
c
struct Player // 定义 Player 结构体
{ // 结构体开始
int hp; // 非静态成员,会被 sizeof 统计
float speed; // 非静态成员,会被 sizeof 统计
}; // 结构体结束这里 sizeof(Player) 会统计 hp、speed,以及可能的 padding。
sizeof 不算什么
sizeof 不会顺着指针继续算。
c
struct Buffer // 定义 Buffer 结构体
{ // 结构体开始
char* data; // 指针成员,sizeof 只统计这个指针本身
int size; // 普通成员,sizeof 会统计
}; // 结构体结束如果 data 指向一块 1MB 的堆内存:
c
Buffer b; // 创建 Buffer 对象
b.data = new char[1024 * 1024]; // 在堆上申请 1MB 内存
b.size = 1024 * 1024; // 记录数据大小sizeof(Buffer) 只会算:
char* data这个指针int size- padding
它不会把 new char[1024 * 1024] 那 1MB 算进去。
容器也是常见陷阱
比如 vector<int>:
c
#include <vector> // 引入 vector 容器
using namespace std; // 使用标准命名空间
int main() // 程序入口函数
{ // main 函数开始
vector<int> nums; // 创建一个 vector 对象
nums.reserve(1000); // 让 vector 在堆上预留 1000 个 int 的空间
return 0; // 返回 0,表示程序正常结束
} // main 函数结束sizeof(nums) 通常只会得到 vector 对象本体大小。 它可能包含几个指针,比如:
- begin 指针
- end 指针
- capacity end 指针
但 reserve(1000) 申请的那块堆内存,不会被 sizeof(nums) 算进去。
所以:
sizeof(vector<int>)看的是 vector 控制块vector.capacity()才更接近它当前外部分配了多少元素空间
string 也类似
std::string 的情况更复杂,因为很多实现有 SSO,小字符串优化。
短字符串可能直接存在 string 对象内部。 长字符串可能在堆上另外分配内存。
所以 sizeof(string) 是固定的,但不同内容的字符串实际占用可能不同。
new / malloc 也有额外开销
比如你申请:
c
int* p = new int[10]; // 在堆上申请 10 个 int你以为只占 10 * sizeof(int),但实际分配器可能还要保存:
- 分配块大小
- 对齐信息
- 调试信息
- 内存块管理信息
所以系统实际占用可能比你请求的更大。
面试高分回答
IMPORTANT
sizeof 和实际占用内存不一定一样。sizeof 计算的是对象本体的静态大小,包括非静态成员、padding、基类子对象以及多态对象中常见的 vptr,但它不会计算指针指向的堆内存,也不会计算 allocator 的元数据、vector/string 内部动态分配的缓冲区、系统内存页开销等。比如 sizeof(vector<int>) 只表示 vector 控制块大小,不代表它存了多少元素;sizeof(char*) 也只表示指针大小,不代表它指向的数据大小。所以判断真实内存占用时,要看对象本体加上它拥有或引用的外部资源。
栈内存为什么快?
栈内存快,主要不是因为“栈这个地方有魔法”,而是因为它的 分配和释放模型非常简单:通常只是移动一下栈指针。
1. 分配快
函数进入时,会创建栈帧,给局部变量留空间。 这通常就是让栈指针移动一下。
比如概念上类似:
c
void Func() // 定义一个函数 Func
{ // 函数体开始
int a = 10; // 局部变量 a 通常在栈帧里
int b = 20; // 局部变量 b 通常也在栈帧里
} // 函数结束时,栈帧整体释放它不需要像堆分配那样去找一块合适的空闲内存。
2. 释放快
栈上的局部变量离开作用域后,栈帧整体弹出。 释放时通常也是移动栈指针。
堆内存释放则更复杂:
- 要通知分配器
- 要维护空闲块
- 要处理碎片
- 可能涉及锁
- 可能涉及合并内存块
所以频繁 new/delete 通常比栈分配更贵。
3. 缓存友好
栈内存通常是连续的。 函数里的局部变量一般挨得比较近,CPU 读取时更容易命中 Cache。
Cache 命中率高,访问就快。 堆上的对象可能分散在不同位置,访问时更容易 cache miss。
4. 生命周期简单
栈变量的生命周期很清晰:
- 进入作用域创建
- 离开作用域销毁
不需要你手动决定什么时候释放。 这减少了管理成本,也降低了内存泄漏风险。
但栈不是万能
栈也有明显限制:
- 栈空间通常比较小
- 不能放特别大的对象
- 栈对象生命周期不能超过作用域
- 递归太深可能栈溢出
- 需要跨函数长期存在的对象不能简单放栈上
比如大数组不要随便放栈上:
c
void Bad() // 定义函数 Bad
{ // 函数体开始
int arr[10000000]; // 超大局部数组可能导致栈溢出
} // 函数体结束面试高分回答
CAUTION
栈内存快主要是因为它的分配和释放非常简单,通常只是对栈指针做加减操作;而堆分配需要分配器查找合适的空闲块、维护元数据、处理碎片,甚至可能涉及锁。栈内存通常连续,局部变量相邻,CPU Cache 友好;同时栈变量生命周期明确,函数返回时栈帧整体释放。不过栈空间有限,不能放大对象,也不能保存需要跨作用域长期存在的数据。所以栈适合小对象、局部对象、生命周期明确的对象;堆适合大对象、动态大小和跨函数生命周期的对象。
堆分配为什么慢?
堆分配慢,主要是因为它不是简单拿一块内存就完事,而是要经过 分配器管理:找空闲块、维护元数据、处理碎片、多线程同步,必要时还要向操作系统申请新内存页。
和栈分配对比
栈分配通常很简单:移动栈指针。
c
void Func() // 定义一个函数
{ // 函数体开始
int a = 10; // 局部变量通常在栈上
int b = 20; // 局部变量通常也在栈上
} // 函数结束时,栈帧整体释放函数结束,栈帧整体弹出。 不需要找空闲块,也不需要手动释放每一个小对象。
堆分配则不同:
c
int* p = new int(10); // 在堆上申请 int,并调用构造或初始化
delete p; // 释放堆内存new 背后不只是“拿 4 字节”,还要找合适的内存块并记录管理信息。
堆分配慢在哪里
第一,查找空闲块。 堆里有很多已用块和空闲块,分配器要找到一块大小合适的空闲区域。
第二,维护元数据。 分配器通常要记录这块内存的大小、状态、对齐信息、前后关系等。
第三,处理碎片。 频繁申请和释放不同大小的对象,会让堆里出现很多小空洞。分配器可能要拆分、合并、整理空闲块。
第四,多线程同步。 多个线程同时申请堆内存时,分配器可能需要加锁或使用线程缓存。即使现代分配器已经优化过,同步成本仍然可能存在。
第五,可能触发系统调用。 如果当前堆空间不够,分配器可能要向操作系统申请更多内存页。这个成本比普通内存操作大得多。
第六,缓存不友好。 堆对象的位置可能很分散,遍历时容易 cache miss。栈上的局部变量通常更连续,缓存命中率更好。
new 和 malloc 的区别补充
malloc 只负责分配原始内存。 new 会先分配内存,然后调用构造函数。
所以:
c
Player* p = new Player(); // 分配内存,并调用 Player 构造函数它的成本包括:
- 堆内存分配成本
- 构造函数执行成本
delete 也类似,它会:
- 调用析构函数
- 释放内存
是不是所有堆分配都很慢
也不是。现代分配器已经做了很多优化,比如线程缓存、小对象池、大小分级桶等。
但面试里要抓重点:堆分配比栈分配复杂,尤其是频繁、小对象、跨线程、大量生命周期不一致的场景,成本会明显。
游戏里怎么优化
游戏开发里常见做法:
- 对象池
- 内存池
- 预分配
- Arena Allocator
- Frame Allocator
- 减少每帧
new/delete - 复用子弹、特效、伤害数字、临时容器
- 热路径避免动态分配
面试高分回答
NOTE
堆分配慢主要是因为它需要分配器参与管理。一次堆分配可能要对齐请求大小、查找合适的空闲块、维护分配器元数据、处理内存碎片,多线程情况下还可能有同步成本;如果堆空间不足,还可能向操作系统申请新页。相比之下,栈分配通常只是移动栈指针,释放时也是函数返回后整体弹出,所以更快。堆的优势是生命周期灵活、可以分配大对象和动态大小对象,但在游戏这种对帧时间敏感的场景,频繁堆分配容易造成卡顿,所以常用对象池、内存池和预分配来优化。
内存池如何减少碎片?
内存池减少碎片的核心思路是:提前申请一大块连续内存,然后切成固定大小的小块,后续对象反复从这些小块里取,用完再还回来。
普通堆为什么容易碎片化
普通堆里经常会发生这种情况:
- 分配 64B 对象
- 分配 128B 对象
- 分配 32B 对象
- 释放中间某些对象
- 再申请一个 100B 对象
这时候堆里可能有很多小空洞。 总空闲内存看起来不少,但连续的大块空间不够,这就是 外部碎片。
内存池怎么解决
内存池会先申请一整块内存:
c
[ 大块连续内存 ]然后切成固定大小:
c
[块1][块2][块3][块4][块5]比如子弹对象都从 BulletPool 里拿:
c
Pool<Bullet>子弹销毁时,不是真的还给系统堆,而是把这块内存挂回池子的空闲链表。 下一颗子弹生成时,直接复用这块。
这样就避免了大量 new/delete 在通用堆里制造不规则空洞。
freelist 是什么
内存池通常会维护一个空闲链表 freelist。
空闲块会串起来:
c
free block -> free block -> free block分配时,从链表头拿一个块。 释放时,把块挂回链表头。
所以分配和释放通常接近 O(1),比通用堆分配器简单很多。
固定大小为什么能减少碎片
因为同一个池里的块大小一样。
比如 Bullet 都是 64B:
c
[64B][64B][64B][64B]释放一个 Bullet 后,这个 64B 块下次仍然可以给另一个 Bullet 用。 不会出现“这里空了 20B,那里空了 50B,但都塞不下 64B 对象”的问题。
内部碎片也要注意
内存池主要减少外部碎片,但可能产生内部碎片。
比如对象实际只需要 60B,但池子块大小是 64B:
c
64B 块里浪费 4B所以实际项目里常用 多规格内存池:
- 32B 池
- 64B 池
- 128B 池
- 256B 池
不同大小对象放到不同池里,减少浪费。
游戏里为什么常用
游戏里很多对象有这些特点:
- 数量多
- 频繁创建销毁
- 类型固定
- 生命周期短
- 大小相近
比如:
- 子弹
- 粒子
- Buff
- 伤害数字
- AI 节点
- 网络消息对象
- 临时事件对象
这些特别适合对象池或内存池。
面试高分回答
TIP
内存池通过预先向系统申请大块连续内存,再按固定大小或大小分类切成小块来管理。对象分配时从空闲链表取一块,释放时不归还给通用堆,而是挂回池子等待复用。这样相同大小的对象反复使用同一批内存块,避免通用堆中频繁申请释放不同大小对象造成外部碎片,也减少了分配器查找、拆分、合并空闲块的成本。需要注意,固定块可能带来内部碎片,所以实际项目通常会按对象类型或大小档位设计多个池。
Cache Line 是什么?
Cache Line 是 CPU Cache 和内存之间搬运数据的最小单位。 常见大小是 64 字节,但不是 C++ 规定的,是具体 CPU 硬件决定的。
简单理解
CPU 不是你要一个 int,它就只从内存拿 4 字节。 它通常会把附近一整块数据一起搬进 Cache,这一整块就是 Cache Line。
比如一个 cache line 是 64B,一个 int 是 4B:
64 / 4 = 16也就是说,一次可能把连续的 16 个 int 都搬进 Cache。
为什么数组遍历快
c
int arr[1000]; // 定义一个连续的 int 数组
for (int i = 0; i < 1000; i++) // 从前往后遍历数组
{ // 循环体开始
arr[i] += 1; // 访问连续内存,容易命中 Cache
} // 循环体结束访问 arr[0] 时,arr[1]、arr[2]、arr[3] 可能已经跟着同一个 cache line 进来了。 所以继续访问后面的元素,会非常快。
这叫 空间局部性。
为什么链表可能慢
链表节点可能分散在堆上:
c
struct Node // 定义链表节点
{ // 结构体开始
int value; // 节点保存的数据
Node* next; // 指向下一个节点的指针
}; // 结构体结束遍历链表时:
c
节点 A -> 节点 B -> 节点 C这些节点可能不连续。 CPU 读了节点 A,但节点 B 不一定在同一个 cache line 里,就容易 cache miss。
所以链表理论上插入删除快,但实际性能不一定比数组好,尤其是遍历场景。
Cache Miss 是什么
Cache Miss 就是:CPU 要的数据不在 Cache 里。
这时 CPU 要去更慢的内存层级找:
c
L1 Cache -> L2 Cache -> L3 Cache -> RAM越往后越慢。 如果程序频繁 cache miss,即使算法复杂度看起来不错,也可能很慢。
False Sharing 是什么
这个面试很加分。
两个线程修改不同变量,但这两个变量刚好在同一个 cache line 里:
c
struct Counter // 定义计数器结构体
{ // 结构体开始
int a; // 线程 1 修改 a
int b; // 线程 2 修改 b,但 a 和 b 可能在同一个 cache line
}; // 结构体结束虽然线程 1 改 a,线程 2 改 b,看起来没有共享同一个变量。 但 CPU Cache 是按 cache line 管理的,如果 a 和 b 在同一行,两个线程会让这行缓存反复失效。
这就叫 伪共享 False Sharing。
可以用对齐隔开:
c
struct alignas(64) CounterA // 让 CounterA 按 64 字节对齐,尽量独占一个 cache line
{ // 结构体开始
int value; // 线程 1 修改的计数值
}; // 结构体结束
struct alignas(64) CounterB // 让 CounterB 按 64 字节对齐,尽量独占另一个 cache line
{ // 结构体开始
int value; // 线程 2 修改的计数值
}; // 结构体结束游戏开发里为什么重要
游戏里很多性能优化不只是算法,还有数据布局。
常见优化思路:
- 连续数组比链表更 cache friendly
- 热数据放在一起
- 冷数据拆出去
- 减少指针跳转
- ECS / SoA 结构提高缓存命中
- 多线程写数据时避免 false sharing
面试高分回答
IMPORTANT
Cache Line 是 CPU Cache 和内存之间传输数据的基本单位,常见是 64 字节。CPU 访问某个地址时,通常会把它附近的一整条 cache line 加载进缓存,所以连续内存访问,比如数组遍历,能很好利用空间局部性;而链表、指针跳转这种数据分散的结构容易产生 cache miss。Cache Line 还会带来 false sharing 问题,也就是多个线程修改不同变量,但变量落在同一个 cache line 上,导致缓存行反复失效。在游戏引擎里,ECS、SoA、对象数组、热冷数据分离,本质上很多都是为了提高 cache line 利用率。