对象大小与内存布局
面试回答
常见问法
sizeof一个对象,到底由哪些因素决定?- 为什么空类不是 0,而是 1?
- 成员变量顺序会影响对象大小吗?
- 虚函数、继承、虚继承会怎样影响对象内存布局?
- 为什么要内存对齐?不对齐会怎样?
sizeof(class)和对象实际占用内存是一样的吗?
回答
对象大小本质上由 成员大小 + 对齐要求 + 填充字节 + 继承布局 + 多态开销 共同决定。
一个比较完整、面试里好用的回答是:
sizeof(对象)不是简单把成员大小相加。编译器在布局对象时,会同时考虑成员本身的大小、每个成员的对齐要求、为了满足对齐插入的填充字节,以及继承带来的基类子对象布局。如果类里有虚函数,通常还会额外有一个虚表指针vptr;如果有虚继承,布局会更复杂,往往还会引入额外的指针或偏移信息。至于空类为什么大小不是 0,是因为 C++ 要保证两个不同对象通常拥有不同地址,所以即使没有成员,空类对象也至少要占 1 个字节。
工程上判断对象大小,通常优先看三件事:成员顺序是否造成浪费、是否引入多态、是否用了复杂继承。这三类问题最容易让对象“变胖”。
追问
- 为什么编译器要做内存对齐?
- 成员顺序为什么会影响大小?
- 虚函数一定会增加一个指针大小吗?
- 空类作基类时,为什么有时看起来“不占空间”?
#pragma pack(1)能不能直接用来省内存?sizeof和堆上new出来的总开销有什么区别?- 多重继承、虚继承时,指针转换为什么会变复杂?
原理展开
1. 对象大小由什么决定
影响对象大小的核心因素可以按下面顺序理解:
① 非静态成员变量
只有 非静态成员变量 直接参与对象大小计算。
静态成员变量属于类本身,不属于某个对象实例,所以 不计入 sizeof。
成员函数也不计入对象大小,普通成员函数本质上和对象实例分离存放。
struct S {
int x; // 计入对象大小
static int cnt; // 不计入 sizeof(S)
void func() {} // 不计入 sizeof(S)
};
② 对齐要求
每个类型通常都有自己的对齐要求,例如:
char通常按 1 字节对齐int通常按 4 字节对齐double通常按 8 字节对齐
编译器会把成员放到满足其对齐要求的位置上。 这样做的主要原因是 提高 CPU 访问效率,某些平台上未对齐访问甚至可能更慢,或者直接触发硬件异常。
③ 填充字节(padding)
为了满足对齐,编译器会在成员之间,甚至对象尾部插入填充字节。 所以对象大小常常 大于成员大小之和。
struct A {
char c; // 1
int i; // 4,通常要求4字节对齐
char d; // 1
};
// 典型布局:c + 3字节填充 + i + d + 3字节尾部填充
// sizeof(A) 常见为 12
④ 类整体对齐
对象总大小通常还要满足“整个对象的对齐要求”, 这个要求通常等于其成员中最严格的那个。
所以尾部也可能补 padding,使得数组中每个元素都能正确对齐。
struct B {
int i; // 4
char c; // 1
};
// 成员和为 5,但常见 sizeof(B) == 8
// 因为对象整体通常按 4 字节对齐,尾部补 3 字节
2. 为什么空类也有大小
空类没有数据成员,但对象通常不能是 0 字节。原因是:
语言需要保证不同对象通常有不同地址,至少要能区分“这是两个不同实例”。
因此空类通常大小为 1。
struct Empty {};
static_assert(sizeof(Empty) == 1);
这是面试里最常见的回答,但更高分的补充是:
空类作基类时,可能出现“空基类优化”(EBO)
空类作为 基类 时,编译器通常可以不给它单独分配存储空间,这叫 Empty Base Optimization。
struct Empty {};
struct X : Empty {
int value;
};
static_assert(sizeof(X) == sizeof(int)); // 常见成立
也就是说:
- 空类作为独立对象:通常至少 1 字节
- 空类作为基类:可能被优化为 0 额外开销
C++20 的 [[no_unique_address]]
对于空成员对象,C++20 提供了 [[no_unique_address]],允许编译器做类似优化。
struct Empty {};
struct Y {
[[no_unique_address]] Empty e;
int value;
};
这个点不是必须答,但如果面试官继续追问“空对象一定占 1 字节吗”,这个补充很加分。
3. 为什么成员顺序会影响对象大小
成员顺序直接影响 padding 的位置和数量。
struct Bad {
char c1; // 1
int i; // 4
char c2; // 1
};
// 常见 sizeof(Bad) == 12
struct Good {
int i; // 4
char c1; // 1
char c2; // 1
};
// 常见 sizeof(Good) == 8
面试表述建议
相同成员、不同顺序,
sizeof可能不同。工程上如果对象数量非常大,比如百万级节点、缓存条目、消息结构体,合理调整成员顺序能显著降低内存占用,并提高缓存友好性。
选择原则
一般把 对齐要求高的成员放前面,小成员尽量聚合在一起。 但不要为了省几个字节牺牲可读性,只有在热点对象、批量对象、内存敏感系统中才值得刻意优化。
4. 虚函数为什么会影响对象大小
如果类有虚函数,编译器通常会给对象里放一个 虚表指针 vptr,指向该类的虚函数表 vtable,用于运行时多态分发。
struct Base {
virtual void f() {}
int x;
};
典型情况下对象布局可能类似:
// [vptr][x][padding]
因此:
- 类一旦引入虚函数,对象通常会至少增加一个指针大小
- 在 64 位系统上,常见是增加 8 字节左右,再加上可能的对齐填充
但要注意一个高频误区
C++ 标准并没有规定必须用 vptr/vtable 实现虚函数。
只是主流编译器几乎都这样做,所以面试时要说“通常”“大多数 ABI 实现中”。
虚析构函数也一样
虚析构函数本质上也是虚函数,所以也会让对象具备多态布局开销。
5. 继承如何影响对象布局
单继承
派生类对象通常由:
- 基类子对象
- 派生类新增成员
两部分组成。
struct Base {
int x;
};
struct Derived : Base {
int y;
};
// 常见布局:[Base::x][Derived::y]
这意味着派生类大小通常至少是:
基类子对象大小 + 派生类新增成员大小 + 可能的填充
多重继承
如果一个类继承多个基类,对象里通常会包含多个基类子对象。
struct A { int a; };
struct B { int b; };
struct C : A, B { int c; };
// 常见布局:[A子对象][B子对象][C::c]
这会带来两个后果:
- 对象更大
- 基类指针转换可能涉及地址调整,不一定是同一个起始地址
虚继承
虚继承是为了解决菱形继承中虚基类重复的问题,但代价是布局复杂度显著增加。
struct VB { int v; };
struct D1 : virtual VB { int d1; };
struct D2 : virtual VB { int d2; };
struct Final : D1, D2 { int f; };
虚继承通常意味着:
- 虚基类只保留一份
- 但对象里往往需要额外信息定位虚基类
- 指针调整和布局都更复杂
- 对象大小、访问成本、可读性通常都更差
工程实践建议
如果不是必须解决菱形继承共享同一份基类状态的问题,实际工程里一般尽量避免虚继承。多数业务代码更推荐接口继承 + 组合。
6. sizeof 到底算的是什么
sizeof(T) 或 sizeof(obj) 算的是:
编译器为该类型对象本体分配的静态存储大小
它不包括:
- 堆分配器额外元数据
- 成员指针指向的堆内存
- STL 容器内部动态扩容出去的空间
- 虚函数表本身那张表的大小(对象里通常只放指针)
struct Node {
int val;
int* p;
};
sizeof(Node); // 只算 int + 指针 + padding
// 不算 p 指向的那块堆内存
这句话在面试中非常重要,因为很多候选人会把“对象大小”和“对象相关的总内存消耗”混为一谈。
7. 对齐为什么重要
性能角度
CPU 通常更擅长按自然边界访问数据。对齐后:
- 读取次数可能更少
- 总线访问更高效
- 缓存行行为更稳定
兼容性角度
某些硬件架构对未对齐访问更敏感,可能:
- 性能下降
- 需要额外指令修正
- 严重时触发异常
面试里别答太绝对
“为了性能”是对的,但不完整。更稳妥的说法是:
对齐主要是为了满足硬件访问约束并提升访问效率,不同平台影响程度不同,但现代 C++ 编译器默认都会按 ABI 规则做合理对齐。
8. #pragma pack / __attribute__((packed)) 能不能用
可以,但要谨慎。
作用
它们可以降低或取消对齐,减少 padding,从而压缩结构体大小。
#pragma pack(push, 1)
struct Packet {
char c;
int i;
};
#pragma pack(pop)
风险
- 可能导致未对齐访问,带来性能下降
- 某些平台可能有兼容性问题
- 直接把内存布局当协议格式,容易埋下可移植性问题
工程建议
通常只在这些场景使用:
- 网络协议头
- 磁盘文件格式
- 硬件寄存器映射
- 与外部二进制协议严格对齐的场景
普通业务对象、核心数据结构,不要为了省几个字节随便 pack(1)。
9. 典型代码示例
示例 1:空类
struct Empty {};
static_assert(sizeof(Empty) == 1);
示例 2:成员顺序影响大小
struct S1 {
char c;
int i;
char d;
};
// 常见 sizeof(S1) == 12
struct S2 {
int i;
char c;
char d;
};
// 常见 sizeof(S2) == 8
示例 3:虚函数增加对象开销
struct V {
int x;
virtual void f() {}
};
// 64位下常见大于等于 16
示例 4:继承影响布局
struct Base {
int x;
};
struct Derived : Base {
char y;
};
// 大小 = Base子对象 + y + padding
示例 5:空基类优化
struct Empty {};
struct Holder : Empty {
int x;
};
static_assert(sizeof(Holder) == sizeof(int)); // 常见成立
对比总结
| 概念 | 是否计入 sizeof(对象) | 典型影响 | 适用场景 | 优点 | 风险 / 缺点 |
|---|---|---|---|---|---|
| 非静态成员变量 | 是 | 直接增加对象本体大小 | 所有对象 | 语义明确 | 成员多时对象膨胀 |
| 静态成员变量 | 否 | 不影响单个对象大小 | 共享状态 | 节省实例空间 | 需要考虑全局状态管理 |
| 普通成员函数 | 否 | 不进入对象本体 | 行为定义 | 不增加对象体积 | 无法单独表达对象状态 |
| 指针成员 | 是 | 只增加指针本身大小 | 资源句柄、Pimpl、树/图结构 | 解耦、可延迟分配 | 不包含指向内存,易误判总占用 |
| 动态分配内存 | 否(对象本体外) | 与 sizeof 分离 | 大对象、可变长数据 | 灵活 | 堆开销、碎片、管理复杂 |
| 情况 | 布局特点 | 大小是否容易变大 | 典型说明 |
|---|---|---|---|
| 普通类 | 成员 + padding | 中等 | 主要看成员顺序和对齐 |
| 含虚函数类 | 通常多一个 vptr | 是 | 64位常多一个指针并伴随对齐 |
| 单继承 | 基类子对象 + 派生类成员 | 中等 | 通常比较直观 |
| 多重继承 | 多个基类子对象并列 | 是 | 指针转换可能要调地址 |
| 虚继承 | 共享虚基类 + 额外定位信息 | 很容易变大 | 解决菱形问题,但布局最复杂 |
| 方案 | 适用场景 | 优点 | 缺点 | 实践建议 |
|---|---|---|---|---|
| 调整成员顺序 | 热点对象、海量对象 | 减少 padding,成本低 | 可读性可能变差 | 首选优化手段 |
alignas 指定更高对齐 | SIMD、缓存优化、硬件接口 | 可控对齐 | 可能增大对象体积 | 只在明确需要时使用 |
pack(1) 压缩布局 | 协议、文件、寄存器映射 | 节省空间,布局可控 | 性能和可移植性风险 | 仅用于二进制边界 |
| 组合替代复杂继承 | 工程代码 | 结构清晰,易维护 | 设计上略繁琐 | 大多数业务代码推荐 |
易错点
- 以为
sizeof就是“成员大小简单相加”。 - 忽略了 尾部 padding,只看成员之间的空隙。
- 认为成员声明顺序不影响对象大小,实际上影响很大。
- 把 对象本体大小 和 对象关联的总内存占用 混为一谈。
- 以为有虚函数时,标准规定必须有
vptr,其实这只是主流实现方式。 - 认为空类永远只占 1 字节,没有意识到 空基类优化 EBO。
- 误以为静态成员也算在对象里。
- 认为
pack(1)一定是优化,忽略了未对齐访问风险。 - 拿某个平台的
sizeof结果当成语言层面的绝对结论,忽略 ABI、编译器、平台差异。 - 把“内存布局”和“序列化格式”画等号,直接
memcpy跨平台传输对象。
记忆技巧
-
四个关键词记忆对象大小:成员、对齐、填充、继承。
-
一旦看到虚函数,再补一个关键词:多态开销。
-
一句话口诀: “先看成员,再看对齐;有虚看指针,有继承看基类。”
-
判断大小时按这个顺序想:
- 非静态成员有哪些?
- 最大对齐要求是多少?
- 成员之间会不会插 padding?
- 对象尾部需不需要补齐?
- 有没有虚函数?
- 有没有基类、多个基类、虚基类?
面试速答版
sizeof(对象) 不是成员大小简单相加,它主要由 非静态成员大小、对齐要求、编译器插入的填充字节、继承带来的基类子对象,以及虚函数带来的多态开销 决定。
空类通常大小是 1,不是因为它有数据,而是为了保证不同对象通常有不同地址。
成员顺序会影响大小,因为不同顺序会导致不同的 padding。
如果类有虚函数,主流实现里对象通常会多一个 vptr;如果有继承,尤其虚继承,布局会更复杂,也可能引入额外开销。
工程上如果要优化对象大小,优先做成员重排,其次再评估是否真的需要多态和复杂继承。
面试加分版
对象大小我一般从五个维度看:成员、对齐、填充、继承、多态。
先说最基础的,sizeof 只统计对象本体,也就是非静态成员加上编译器为了布局插入的额外空间。静态成员和成员函数都不算在对象里。
然后是对齐。编译器会按 ABI 规则把成员放到合适边界上,所以对象大小经常大于成员大小之和。典型例子是 char + int + char,逻辑上是 6 字节,但实际常见是 12 字节,因为中间和尾部都会补 padding。
空类通常大小为 1,这是为了保证不同对象通常有不同地址;但如果空类作为基类,编译器常常会做空基类优化,不一定真的额外占空间。
再往上就是多态。如果类有虚函数,主流实现里对象里通常会有一个 vptr 指向虚表,所以对象一般会变大,64 位下常见增加一个指针大小并伴随对齐填充。
继承也会影响布局。单继承相对直观,就是基类子对象加派生类成员;多重继承会有多个基类子对象;虚继承为了共享虚基类,布局最复杂,往往还有额外定位开销。
所以工程上如果想优化对象大小,我通常优先做三件事:第一,调整成员顺序减少 padding;第二,确认是否真的需要虚函数;第三,避免不必要的复杂继承,尤其虚继承。
另外我会特别区分 sizeof 和总内存占用,比如对象里有指针,sizeof 只算指针本身,不算指向的堆内存,这在面试里是一个很容易被追问的点。