Skip to content

内存布局

一个 C++ 对象内存里有什么?

一个 C++ 对象内存里,主要包含:非静态成员变量、对齐填充 padding、继承来的基类子对象、多态对象可能有的虚表指针 vptr

cpp-object-memory-layout

对象里通常有

非静态成员变量:

c
class Player // 定义一个 Player 类
{ // 类体开始
public: // public 访问区开始
    int hp; // 非静态成员变量,会存在每个 Player 对象里
    float speed; // 非静态成员变量,也会存在每个 Player 对象里
}; // 类定义结束

每个 Player 对象都有自己的 hpspeed

对象里可能有 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 那一部分,也就是 id
  • Player 自己的部分,也就是 hp
  • 可能的 padding

面试高分回答

NOTE

C++ 对象内存里主要存放非静态数据成员,以及为了对齐产生的 padding。如果类有继承,派生类对象里还包含基类子对象。如果类有虚函数,常见编译器实现会在对象里放一个虚表指针 vptr,指向类的虚表,但虚表本身不在每个对象内部。成员函数代码不在对象里,所有对象共享函数代码;static 成员也不属于单个对象,不会计入每个对象的大小。sizeof 看到的主要是成员变量、padding、可能的 vptr 和基类子对象。需要注意,具体布局由编译器和 ABI 决定。

空类大小为什么通常是 1?

空类大小为什么通常是 1?

因为 C++ 需要保证:每个完整对象都要有可区分的地址。空类虽然没有数据成员,但对象本身仍然要能被取地址、放进数组、做指针运算。

cpp-empty-class-size-one

简单例子

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 是:编译器为了满足对齐要求,自动插入的空字节。

cpp-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 改变对齐,但可能带来访问变慢和跨平台风险,所以不能为了省几个字节随便乱用。

虚函数会让对象多出什么?

cpp-virtual-function-object-extra

常见编译器实现里,类只要有虚函数,对象里通常会多出一个 虚表指针 vptrvptr 指向这个类对应的 虚函数表 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; // 非静态成员变量,存在每个对象里
}; // 类定义结束

这种对象里通常有:

  • vptr
  • hp
  • 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() 时,大概过程是:

  1. 通过 p 找到对象
  2. 从对象里读取 vptr
  3. 通过 vptr 找到 vtable
  4. vtable 里找到 Attack 对应的函数地址
  5. 调用 Monster::Attack

这就是运行时多态的底层基础。

多继承会更复杂

如果一个类有多个多态基类,可能会有多个 vptr。 如果涉及虚继承,还可能有额外的指针、偏移信息或更复杂的布局。

所以面试里要说“通常”或“常见实现”,不要说 C++ 标准规定对象里一定怎么放。C++ 标准不强制规定 vptr/vtable 的具体布局,这是编译器和 ABI 的实现细节。

面试高分回答

NOTE

虚函数通常会让对象多出一个虚表指针 vptrvptr 指向该类的虚函数表 vtable,虚表中保存虚函数的入口地址。虚函数调用时,会先通过对象里的 vptr 找到虚表,再根据虚函数在表中的位置找到最终要调用的函数,实现运行时多态。需要注意,vptr 通常在每个多态对象里,而 vtable 通常是类级别共享的,不是每个对象一份。单继承下一般多一个指针大小,64 位下常见是 8 字节;多继承和虚继承时布局会更复杂。

多继承对象布局有什么特点?

cpp-multiple-inheritance-layout

多继承的核心特点是:派生类对象里会包含多个基类子对象。如果这些基类有虚函数,对象里还可能有 多个 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::a
  • B 子对象,也就是 B::b
  • C 自己的成员,也就是 C::c
  • 可能的 padding

所以 C 不是只保存 c,它还要保存继承来的基类部分。

有虚函数时

如果 AB 都有虚函数,常见实现里 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_vptr
  • B 子对象:带 B_vptr
  • C 自己的成员

注意:这是常见 ABI 的实现方式,C++ 标准不强制规定具体 vptr 放在哪里。

基类指针地址可能不同

这是多继承非常重要的一点。

c
C obj; // 创建 C 对象
A* pa = &obj; // 转成 A 指针,通常指向 C 对象起始处
B* pb = &obj; // 转成 B 指针,可能指向 C 对象中间的 B 子对象

papb 的地址值可能不同。

原因是:

  • 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 和实际占用内存一定一样吗?

cpp-sizeof-vs-real-memory

不一定。sizeof 得到的是 对象本体的静态大小,但实际运行时占用可能还包括 堆内存、分配器额外开销、容器容量、系统内存页开销 等。

sizeof 算什么

sizeof 通常会算:

  • 非静态成员变量
  • 对齐填充 padding
  • 多态对象里常见的 vptr
  • 内嵌数组
  • 基类子对象

比如:

c
struct Player // 定义 Player 结构体
{ // 结构体开始
    int hp; // 非静态成员,会被 sizeof 统计
    float speed; // 非静态成员,会被 sizeof 统计
}; // 结构体结束

这里 sizeof(Player) 会统计 hpspeed,以及可能的 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*) 也只表示指针大小,不代表它指向的数据大小。所以判断真实内存占用时,要看对象本体加上它拥有或引用的外部资源。

栈内存为什么快?

cpp-stack-memory-fast

栈内存快,主要不是因为“栈这个地方有魔法”,而是因为它的 分配和释放模型非常简单:通常只是移动一下栈指针。

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 友好;同时栈变量生命周期明确,函数返回时栈帧整体释放。不过栈空间有限,不能放大对象,也不能保存需要跨作用域长期存在的数据。所以栈适合小对象、局部对象、生命周期明确的对象;堆适合大对象、动态大小和跨函数生命周期的对象。

堆分配为什么慢?

cpp-heap-allocation-slow

堆分配慢,主要是因为它不是简单拿一块内存就完事,而是要经过 分配器管理:找空闲块、维护元数据、处理碎片、多线程同步,必要时还要向操作系统申请新内存页。

和栈分配对比

栈分配通常很简单:移动栈指针。

c
void Func() // 定义一个函数
{ // 函数体开始
    int a = 10; // 局部变量通常在栈上
    int b = 20; // 局部变量通常也在栈上
} // 函数结束时,栈帧整体释放

函数结束,栈帧整体弹出。 不需要找空闲块,也不需要手动释放每一个小对象。

堆分配则不同:

c
int* p = new int(10); // 在堆上申请 int,并调用构造或初始化
delete p; // 释放堆内存

new 背后不只是“拿 4 字节”,还要找合适的内存块并记录管理信息。

堆分配慢在哪里

第一,查找空闲块。 堆里有很多已用块和空闲块,分配器要找到一块大小合适的空闲区域。

第二,维护元数据。 分配器通常要记录这块内存的大小、状态、对齐信息、前后关系等。

第三,处理碎片。 频繁申请和释放不同大小的对象,会让堆里出现很多小空洞。分配器可能要拆分、合并、整理空闲块。

第四,多线程同步。 多个线程同时申请堆内存时,分配器可能需要加锁或使用线程缓存。即使现代分配器已经优化过,同步成本仍然可能存在。

第五,可能触发系统调用。 如果当前堆空间不够,分配器可能要向操作系统申请更多内存页。这个成本比普通内存操作大得多。

第六,缓存不友好。 堆对象的位置可能很分散,遍历时容易 cache miss。栈上的局部变量通常更连续,缓存命中率更好。

newmalloc 的区别补充

malloc 只负责分配原始内存。 new 会先分配内存,然后调用构造函数。

所以:

c
Player* p = new Player(); // 分配内存,并调用 Player 构造函数

它的成本包括:

  • 堆内存分配成本
  • 构造函数执行成本

delete 也类似,它会:

  • 调用析构函数
  • 释放内存

是不是所有堆分配都很慢

也不是。现代分配器已经做了很多优化,比如线程缓存、小对象池、大小分级桶等。

但面试里要抓重点:堆分配比栈分配复杂,尤其是频繁、小对象、跨线程、大量生命周期不一致的场景,成本会明显。

游戏里怎么优化

游戏开发里常见做法:

  • 对象池
  • 内存池
  • 预分配
  • Arena Allocator
  • Frame Allocator
  • 减少每帧 new/delete
  • 复用子弹、特效、伤害数字、临时容器
  • 热路径避免动态分配

面试高分回答

NOTE

堆分配慢主要是因为它需要分配器参与管理。一次堆分配可能要对齐请求大小、查找合适的空闲块、维护分配器元数据、处理内存碎片,多线程情况下还可能有同步成本;如果堆空间不足,还可能向操作系统申请新页。相比之下,栈分配通常只是移动栈指针,释放时也是函数返回后整体弹出,所以更快。堆的优势是生命周期灵活、可以分配大对象和动态大小对象,但在游戏这种对帧时间敏感的场景,频繁堆分配容易造成卡顿,所以常用对象池、内存池和预分配来优化。

内存池如何减少碎片?

cpp-memory-pool-reduce-fragmentation

内存池减少碎片的核心思路是:提前申请一大块连续内存,然后切成固定大小的小块,后续对象反复从这些小块里取,用完再还回来。

普通堆为什么容易碎片化

普通堆里经常会发生这种情况:

  • 分配 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 是什么?

cpp-cache-line-explained

Cache LineCPU 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 管理的,如果 ab 在同一行,两个线程会让这行缓存反复失效。

这就叫 伪共享 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 利用率。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0