Skip to content
团子云技术 Lite 1.048596
Go back

C++ 对象内存布局:从 sizeof 算账到虚函数深水区

本文是我在复习 C++ 内存布局时做的一轮系统推演,从一段带 alignas 的代码出发,一路算到虚函数下的对象模型。涉及的题目都是面试高频考点,计算过程全部展开,欢迎对照验证。

一段怪代码:alignas(64) 意味着什么

起点是这么一段代码:

class PlainClass {
public:
    PlainClass() = default;
    PlainClass(int _x, double _y, int _z):
        x_(_x), y_(_y), z_(_z) {}
    ~PlainClass() = default;
    void DoSomeThing() {
        std::cout << "DoSomeThing()" << std::endl;
    }
private:
    alignas(std::hardware_destructive_interference_size) int32_t x_;
    double y_;
    int z_;
};

核心是 alignas(std::hardware_destructive_interference_size)。这个常量是 C++17 引入的(位于 <new> 头文件),通常代表目标架构的 L1 缓存行大小(Cache Line Size),主流 x86/x64 上是 64 字节,部分架构是 128 字节。

alignas 作用在 x_ 上,但它同时决定了整个类实例的对齐方式。按 64 字节缓存行、64 位系统拆解布局:

偏移量占用成员 / 填充说明
04int32_t x_受 alignas(64) 影响,对象起始地址必须是 64 的倍数
44Paddingdouble y_ 需要 8 字节对齐,跳过地址 4-7
88double y_占据地址 8-15
164int z_占据地址 16-19
2044Padding总大小必须是对齐数 64 的倍数,20 向上取整到 64

结论:sizeof(PlainClass) == 64,alignof(PlainClass) == 64。

两个附带的常识:

sizeof 基础五题

算 sizeof 之前先锁定编译环境——这本身就是面试中该主动跟面试官确认的:64 位 x86_64,GCC/Clang 或主流 MSVC,char=1, short=2, int=4, double=8,无 #pragma pack 干预。

Quiz 1:声明顺序的影响

struct A { char a; int b; short c; };
struct B { int b; short c; char a; };

sizeof(A) = 12:char 占 1,填充 3(让 int 对齐到地址 4),int 占 4,short 占 2,尾部填充 2 凑齐 4 的倍数。

sizeof(B) = 8:int 占 4,short 占 2,char 占 1,尾部填充 1。

同样的成员,不同的声明顺序,一个是 12 一个是 8。最佳实践:成员按大小从大到小排列,能最大化省内存。

Quiz 2:空类与嵌套

class Empty {};
class Wrapper { Empty e; int x; };

sizeof(Empty) = 1。C++ 标准规定不同的对象不能拥有相同的地址。如果空类大小为 0,Empty arr[10] 里所有元素地址就重合了,所以编译器隐式给空类 1 字节占位。

sizeof(Wrapper) = 8:Empty 占 1,填充 3,int 占 4。

Quiz 3:静态成员与成员函数

class C {
public:
    C(); ~C(); void compute();
private:
    char c1;
    static int s_count;
    char c2;
    int x;
};

sizeof(C) = 8。三个要点:成员函数不占实例空间;static 变量存放在全局/静态数据区,不占实例内存;c1 和 c2 虽然在代码里被 static 隔开,但不影响它们在实例内存中的连续性——c1 在偏移 0,c2 紧挨着在偏移 1,填充 2 字节,x 在偏移 4。

Quiz 4:结构体嵌套的”对齐升级”

struct Inner { double d; char c; };
struct Outer { int i; Inner inner_obj; char c2; };

sizeof(Inner) = 16,sizeof(Outer) = 32。

这里的关键规则:结构体的整体对齐要求,等于它所有成员中对齐要求最大的那个。Inner 里最大的基础类型是 double(8 字节对齐),所以 Inner 这个类型的对齐基准被提升到 8:8 + 1 + 7(尾部填充)= 16。

Outer 包含 int(对齐 4)、char(对齐 1)和一个 Inner(对齐 8),整体对齐基准被 Inner 撑大到 8:

偏移量内容
0-3int i
4-7填充 4 字节(inner_obj 要从 8 的倍数地址开始)
8-23Inner inner_obj(16 字节)
24char c2
25-31尾部填充 7 字节,总大小凑到 8 的倍数

Quiz 5:#pragma pack(网络编程常考)

#pragma pack(push, 2)
struct NetworkHeader {
    char flag;
    int payload_size;
    short checksum;
};
#pragma pack(pop)

#pragma pack(n) 强制改变默认对齐规则,核心逻辑是:成员的对齐要求不再是 sizeof(基础类型),而是取 min(sizeof(基础类型), n)。

sizeof(NetworkHeader) = 8:flag 占 1;payload_size 本来要求 4 字节对齐,被 pack(2) 压到 2 字节对齐,只填充 1 字节,落在偏移 2-5(跨越了 4 字节对齐边界,但在 pack(2) 下合法);checksum 落在 6-7。最大对齐数是 2,总大小 8 无需尾部填充。按默认对齐这个结构体是 12 字节。

关于 #pragma pack 的三个追问

为什么这道题不是 pack(1)? 生产环境里定义网络协议头,几乎 100% 都用 #pragma pack(push, 1)。这道题故意写 pack(2),是为了考察 min(基础类型大小, n) 这个推导规则——如果是 pack(1),所有成员都没有 padding,大小直接是 1+4+2=7,推导过程过于简单。

收发两端必须用一样的对齐吗? 绝对必须。网络传输的本质是字节流。发送端用 pack(1) 构造 7 字节的数据包,接收端若用默认对齐(12 字节)去强转解析,payload_size 和 checksum 读到的全是错位的垃圾数据,甚至可能越界访问。所以网络协议规定的是绝对的”字节偏移量”,收发两端不仅统一 pack(1),还要处理大小端字节序转换(htonl/ntohl 等)。

既然对齐浪费内存,编译器为什么默认要对齐? 取消对齐会带来两个致命问题:

一是性能暴跌。CPU 读内存以块为单位,走内存总线,块大小是 4、8 或 64 字节(缓存行)。一个 int 落在地址 0-3,一次访存搞定;如果被紧凑打包落在地址 1-4,CPU 只能先读 0-3、再读 4-7,然后在内部做位移和掩码拼凑——一次简单的整数读取变成 2 次访存加额外运算。

二是硬件崩溃。上面那种”默默替你处理”只是 x86 为了兼容性付的代价。很多 RISC 架构(ARM、MIPS、SPARC)硬件层面根本不支持不对齐访问,用 int* 读地址 1 的内存会直接抛硬件异常(Bus Error),操作系统向程序发 SIGBUS 信号,程序当场崩溃。

还有第三层:如果一个 int 跨越两个缓存行,多线程下对它的原子操作无法保证,另一个线程可能看到”读了一半/写了一半”的脏数据(Torn Read/Write)。

说白了这是一笔空间换时间的账:默认对齐(Time > Space)牺牲一点内存换 CPU 高效安全的读取,是绝大多数代码的默认选择;pack(1)(Space > Time)只用于网络传输或磁盘存储——相比网络带宽和磁盘 I/O 的缓慢,CPU 多花几个周期拼凑不对齐数据完全划算。常见做法是:网络收到 pack(1) 的数据后,立刻反序列化拷贝到内存对齐的正常 struct 里再处理业务。

编译期守住 layout:四步防御法

磁盘和网络 layout 一旦被”随手加个字段”破坏,就是线上事故。基础架构代码(数据库、网络代理、内核)里,防这类事故的武器是 static_assert 配合 <type_traits> 和 offsetof。

第一步,死守总大小:

#pragma pack(push, 1)
struct ProtocolHeader {
    uint8_t  magic;
    uint16_t payload_len;
    uint32_t msg_id;
};
#pragma pack(pop)

// 有人偷偷加一个 bool,编译直接 fail
static_assert(sizeof(ProtocolHeader) == 7,
              "ProtocolHeader size broken! Must be exactly 7 bytes.");

第二步,死守关键字段偏移。 总大小没变不代表安全——有人”好心”调整字段顺序,或者把两个同类型字段换了位置,都必须挡住:

static_assert(offsetof(ProtocolHeader, payload_len) == 1,
              "payload_len must be at offset 1");
static_assert(offsetof(ProtocolHeader, msg_id) == 3,
              "msg_id must be at offset 3");

第三步,守住可安全强转/memcpy 的底层属性:

static_assert(std::is_standard_layout_v<ProtocolHeader>,
              "ProtocolHeader must be standard layout to ensure memory order!");
static_assert(std::is_trivially_copyable_v<ProtocolHeader>,
              "ProtocolHeader must be trivially copyable for network serialization!");

这两条为什么必须,见下一节。

第四步,显式占位,永远不要依赖隐式 Padding。 当协议本身要求 4 字节对齐(不用 pack(1))时,不要让编译器帮你填充:

// 错误:隐式填充的 3 字节是未初始化内存,
// 写到磁盘上可能泄露敏感旧数据
struct DiskRecord {
    uint8_t  flag;
    uint32_t offset;
};

// 正确:显式保留字段,初始化为 0
struct DiskRecord {
    uint8_t  flag;
    uint8_t  reserved[3];
    uint32_t offset;
};
static_assert(offsetof(DiskRecord, offset) == 4, "offset must be 4");

四步合起来,就是一份工业级网络/磁盘结构体模板:

#pragma pack(push, 1)
struct NetworkPacket {
    uint8_t  version;
    uint8_t  type;
    uint16_t length;
    uint32_t crc32;
    uint64_t timestamp;
};
#pragma pack(pop)

static_assert(std::is_standard_layout_v<NetworkPacket>, "Must be standard layout");
static_assert(std::is_trivially_copyable_v<NetworkPacket>, "Must be trivially copyable");
static_assert(sizeof(NetworkPacket) == 16, "Packet header must be exactly 16 bytes");
static_assert(offsetof(NetworkPacket, length) == 2, "length offset error");
static_assert(offsetof(NetworkPacket, crc32) == 4, "crc32 offset error");
static_assert(offsetof(NetworkPacket, timestamp) == 8, "timestamp offset error");

一个加分项:固定头部(Fixed Header)用上面的硬编码防御;但结构频繁变更的业务载荷(Payload),最佳实践是放弃手撸 C++ 结构体,改用 IDL 工具——Protocol Buffers,或对零拷贝更友好的 FlatBuffers——让工具链生成跨语言、前向兼容的布局代码。

四大 cast 与两份”生死状”

reinterpret_cast 强转指针或 memcpy 直接拷贝,本质上是在和编译器签生死状。签这份状子要求类满足两个属性:**Trivially Copyable(可平凡复制)**和 Standard Layout(标准布局)——这俩加起来,在老标准里叫 POD(Plain Old Data)。

先铺垫四大 cast:

Cast用途特点
static_cast良性转换:基础类型互转、无虚函数继承体系的上行/下行转换编译期检查,类型不搭边直接报错
dynamic_cast多态类型(有虚函数)父子指针/引用的下行转换运行时查 RTTI,失败返回 nullptr(指针)或抛 std::bad_cast(引用),安全但有开销
const_cast移除/添加 const 或 volatile只改指针/引用的权限;若原对象在只读内存区(如字符串常量),强行修改直接段错误
reinterpret_cast对二进制位重新解释:整型 ↔ 指针、无关指针类型互转编译器不加任何阻拦,最危险

网络编程里从 Socket 收到 char buf[1024],想把前 16 字节当网络头用,只能靠它:

NetworkHeader* header = reinterpret_cast<NetworkHeader*>(buf);

条件一:Trivially Copyable。 什么时候能直接 memcpy 一个对象而不出事?当这个类是”平凡的”:没有自定义拷贝/移动构造函数、没有自定义赋值运算符、没有自定义析构函数、不包含非平凡的成员(std::string、std::vector 这类)。

反面教材:

struct BadMessage {
    int id;
    std::string text;
};
BadMessage msg1;
msg1.text = "Hello";
BadMessage msg2;
memcpy(&msg2, &msg1, sizeof(BadMessage));  // 灾难

std::string 内部是一个指向堆内存的指针,memcpy 只是把指针地址按位复制过去。结果是 msg1 和 msg2 的 text 指向同一块堆内存,两个对象析构时这块内存被释放两次(Double Free),程序崩溃。

条件二:Standard Layout。 把 char* 强转成 MyStruct* 时,怎么保证字段真的按你想的顺序排在内存里?标准布局保证这一点,它要求:

只有标准布局的类,内存表现才和 C struct 完全一致——可见即所得,没有隐藏的 vptr,没有顺序重排。

所以面试里被问”网络协议解析能用 memcpy 和 reinterpret_cast 吗”,完整的答案是:可以,但前提是结构体满足 POD 语义(C++11 后细分为可平凡复制 + 标准布局);标准布局保证没有 vptr 和字段重排,可平凡复制保证内部只有标量和嵌套平凡结构体、没有 std::string 这类管理动态资源的对象;实践中用 static_assert 配合 std::is_standard_layout_v 和 std::is_trivially_copyable_v 在编译期卡死协议体。

编译器凭什么能分辨?

一个自然的追问:编译器真的有能力判断一个类是否可平凡复制、是否标准布局?答案是不止有能力——只有编译器才能权威地做这个判断。

翻 GCC/Clang 的 STL 源码,<type_traits> 里的 is_trivially_copyable 并没有用复杂的 C++ 代码推导,而是直接调用双下划线开头的编译器内置函数(Intrinsic):

// GCC/Clang 内部 STL 大致的实现
template <typename _Tp>
struct is_trivially_copyable
    : public integral_constant<bool, __is_trivially_copyable(_Tp)> { };
//                               ^^^^^^^^^^^^^^^^^^^^^^^^^^^ 编译器开的"后门"

为什么需要后门?“类里有没有虚函数”、“有没有自定义析构”、“成员是否全 public”这些信息,纯语法手段很难穷尽,但编译器在生成 AST 阶段对类的所有细节了如指掌。所以标准要求编译器厂商在底层提供这些判断接口。编译器做判定时,本质上是在内部符号表里查:有没有生成 vptr、继承树里父类有没有非静态成员、非静态成员有没有跨越 public/private 边界、程序员有没有手写析构或拷贝构造。

这套判定能力还有个更重要的副产物:基于类型特征的自动优化。以 std::copy 为例:如果元素是 Trivially Copyable(比如 int 或上面的 NetworkPacket),编译器知道遍历调用赋值运算符纯属浪费,直接把代码优化成 memmove/memcpy,甚至用 SIMD 指令(AVX)做大块搬运;如果不是(比如 std::string),就老老实实生成 for 循环逐个调用拷贝赋值。这也是 C++ 性能能吊打很多语言的核心原因之一。

顺着聊还能接到 C++20 的 Concepts:C++11/14/17 时代只能用 static_assert 或 SFINAE(std::enable_if)做约束,报错信息是一大串难懂的模板错误;C++20 之后可以直接写 template <typename T> requires std::is_standard_layout_v<T>,约束不满足时错误提示清晰直接。

进阶:虚函数加入后的 sizeof

最后是对象模型深水区。环境约定升级为:64 位 x86_64,指针 8 字节,GCC/Clang 遵循 Itanium C++ ABI,vptr 放在对象起始位置(偏移 0)。

// Quiz 1
class A { int a; virtual void func1(); };
// Quiz 2
class B { int b; virtual void func1(); virtual void func2(); virtual void func3(); };
// Quiz 3
class C : public A { int c; virtual void func1(); virtual void func4(); };
// Quiz 4
class Base1 { virtual void f1(); };
class Base2 { virtual void f2(); };
class D : public Base1, public Base2 { int d; virtual void f3(); };
// Quiz 5
class E { char c1; virtual ~E(); int i; char c2; };

对话到这里结束,题目留给我自己算。我的答案和思路:

几点心得


Share this post on:

Previous Post
malloc(15) 实际给了你 32 字节:拆一段侥幸存活的 C++ 代码
Next Post
左手印股票,右手大回购:拆解科技巨头的股权博弈