尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

struct 的内存布局:对齐、填充与空对象

struct 的内存布局:对齐、填充与空对象 ① 钩子同样的成员换个顺序就省 1/3 内存一个空struct占 1 字节同样的成员换个声明顺序sizeof能差 50%。{ char; int; char; }是 12 字节{ int; char; char; }却只要 8 字节——成员没少一个为什么差这么多答案藏在 CPU 的对齐规则里。这一集我们用sizeof/alignof/offsetof实测 布局图把 C 对象的内存布局讲透。这是整个对象模型构造、虚表、继承的地基——看不懂布局后面 E08 的虚表指针、E09 的多重继承偏移就无从谈起。② 源码 vs 实测对照structA{charc;inti;chard;};// 声明顺序不友好structB{inti;charc;chard;};// 按大小降序排列structEmpty{};structDerived:Empty{intx;};// 空基类优化EBO本机实测输出g 15.2.0x86-64A: sizeof12 align4 offsetof(i)4 B: sizeof8 align4 Empty: sizeof1 Derived: sizeof4把A的 12 字节画出来偏移 0 1 2 3 4 7 8 9 10 11 ┌────┬────┬────┬────┬────────────┬────┬──────────────┐ │ c │ 填充 │ i │ d │ 填充 │ └────┴───────────────────┴──────────┴────┴──────────────┘ 1B 3B 4B 1B 3BB则是偏移 0 3 4 5 6 7 ┌────────────┬────┬────┬────────┐ │ i │ c │ d │ 填充 │ └────────────┴────┴────┴────────┘ 4B 1B 1B 2B③ 对齐规则CPU 为什么挑食为什么 4 字节的 int 必须放在 4 的倍数地址现代 CPU 访问内存是按字通常是 8 字节或 16 字节来读的。一个 4 字节的int如果放在 4 的倍数地址如 0x1004它恰好落在一个字内一次访问就能拿到如果放在非对齐地址如 0x1003它可能跨越两个字CPU 要读两次再拼接甚至直接触发异常。所以硬件层面的规则是N 字节的基本类型要放在 N 的倍数地址上。这就是对齐alignment。编译器如何保证对齐struct的每个成员都对齐编译器就按这个规则排每个成员放到它的对齐边界处不够就垫填充padding整个struct的对齐 所有成员里最大的对齐这里int的 4整个struct的sizeof必须是对齐的倍数这样数组A arr[2]的第二个元素也自动对齐。于是Ac放偏移 01B→ 下一个可用的对齐到 4 的地址是偏移 4中间 1~3 是填充 →i放偏移 44B→d放偏移 81B→ 结构体对齐 4、大小要对齐到 4 的倍数 → 9 补到 12。所以sizeof(A)12。而Bi放偏移 04B→c偏移 41B→d偏移 51B→ 大小补到 4 的倍数 → 8。所以sizeof(B)8。结论把大成员往前排填充最少。这不是玄学是尽量减少无法使用的空洞。更多对齐档位char对齐 1随便放short对齐 2int/float对齐 4long long/double/指针对齐 8long doublex87对齐 16平台相关复合类型取成员最大对齐alignas可手动抬高对齐如alignas(64)用于缓存行。alignof(T)给出类型的对齐要求offsetof(S, m)给出成员在结构体里的偏移——本集offsetof(A,i)4就是填充的直接证据。④ 空对象与空基类优化为什么空 struct 占 1 字节C 标准有一条硬性规则同一个类型的不同对象地址必须唯一。一个 0 字节的对象两个实例会挤在同一地址——违反规则。所以编译器给空struct/空类 1 字节占位。structEmpty{};// sizeof 1Empty e1,e2;// e1 和 e2 地址必然不同空基类优化EBOstruct Derived : Empty { int x; };的sizeof为什么是 4 而不是 8因为基类Empty不需要独立地址子对象可以和派生类对象共享地址所以它作为基类时可以零占位——x直接从偏移 0 开始sizeof(Derived)4。这就是EBOEmpty Base Optimization。但空对象作为成员时仍占 1 字节structHasMember{Empty e;intx;};// sizeof 8e 占 1 填充 3 x 4因为成员的地址唯一性规则更严格标准不保证两个成员地址可以相同。这也是为什么 C 里做标记/标签类型时用继承免费比用成员1 字节更省。⑤ 位域、pack 与缓存行对齐位域bit-field省空间但慢structFlags{unsigneda:3;// 只占 3 位unsignedb:5;// 5 位unsignedc:8;// 8 位};位域让你按位打包适合标志位密集的数据协议头等。代价是读写位域需要读-改-写多条指令移位 掩码 或比普通成员慢布局高度实现相关位域怎么跨字节、按什么顺序排标准没规定跨平台序列化位域是噩梦——同一份代码在两个编译器上布局可能不同。#pragma pack压缩布局自担风险#pragmapack(push,1)structPacked{charc;inti;};// 不再填充 → sizeof5#pragmapack(pop)#pragma pack(1)告诉编译器按 1 字节对齐去掉填充。典型用途磁盘/网络协议结构的直读。代价是成员可能未对齐——访问Packed::i偏移 1非 4 的倍数在 x86 上慢、在某些架构ARM 某些模式上直接异常。序列化场景常用但访问路径要小心。缓存行对齐性能敏感时的奢侈structalignas(64)CacheLine{intdata[16];};alignas(64)把对象对齐到 64 字节缓存行大小。用途让每个线程的计数器落在不同的缓存行上避免伪共享false sharing——两个线程各写各的变量但它们在同一缓存行每次写都互相作废缓存E17/E21 会展开。这是多线程性能优化的经典手段。⑥ 为什么这么设计空间 vs 速度的权衡对齐的本质是用空间换速度如果完全不对齐pack(1)结构体最小但访问慢/可能异常如果按自然对齐结构体有填充但访问快编译器选择自然对齐因为绝大多数代码更在乎速度而不是那几个字节。这也解释了AvsB的差异声明顺序决定布局而布局决定填充量。C 不会自动重排你的成员——它尊重你写的顺序这保证了你可以用offsetof精确控制序列化布局。所以省内存的责任在程序员把大成员排前面。⑦ 常见误区误区 1“sizeof(struct) 成员 sizeof 之和”不对要加填充。A的成员加起来 6 字节sizeof 是 12。误区 2“空 struct 占 0 字节”占 1 字节地址唯一性。但空基类可优化成 0。误区 3“结构体布局跨平台一致”long在 Windows 4 字节、Linux 8 字节填充随之不同E23 详述。跨平台传结构体是经典坑。误区 4“#pragma pack(1)只是省空间没坏处”可能让成员未对齐访问变慢或异常。误区 5“成员顺序无所谓”A12 字节 vsB8 字节差 33%。大量对象时这是实打实的内存。误区 6“结构体尾部没有填充”sizeof必须是对齐倍数所以尾部经常有填充A尾部 3 字节、B尾部 2 字节。误区 7“offsetof对任何类型都可用”只对标准布局类型合法带虚函数/虚基类/非统一访问控制的类型用offsetof是未定义行为结果可能带上隐藏 vptr 的偏移。⑧ 实战启示成员按大小降序排列A → B12 字节变 8 字节节省 33%。大量对象时这是实打实的内存也让缓存更友好。序列化/网络传输是经典坑结构体的 padding 依赖编译器和平台E23 见 ABI 差异跨平台传结构体很可能大小不一样。要么用#pragma pack显式压缩要么走字段级序列化——但要清楚#pragma pack(1)会让成员未对齐访问变慢。sizeof(struct) ≠ 成员 sizeof 之和估算结构体内存时把对齐算进去别只加成员。标记/策略类型用空基类EBO 免费但空成员占 1 字节trait、tag 这类空类作为基类不花钱作为成员则每个占 1 字节。多线程共享计数器加alignas(64)防伪共享性能敏感时这是免费的几倍提升。位域是最后的手段省了空间但慢、且不可移植。协议字段密集时用否则优先普通成员 显式掩码。给可能被向量化的热结构加alignas(16)对齐到 16 字节让编译器敢用movdqa等对齐 SIMD 指令代码可能因此更快E21 会看到movdquvsmovdqa的差异。跨平台协议结构用显式宽度 打包uint32_t/int16_t等固定宽度字段配合#pragma pack(1)或字段级序列化才能保证两平台读出一致布局。⑨ FAQQalignof和sizeof什么关系Aalignof(T)是类型的对齐要求sizeof(T)必须是对齐的整数倍。两者常常数值相同但语义不同。Q为什么long double对齐 16A平台相关。x86-64 上它内部用 80 位扩展精度MS ABI 给 16 对齐。跨平台别假设。Qstd::max_align_t是什么A对齐要求最高的基础类型常用来对齐到任何类型都够的边界内存池、aligned_storage。Q能不能让编译器自动重排成员省空间A不能标准尊重声明顺序。但你可以用工具如 clang 的-Wpadded、pahole找出填充然后手工重排。Qoffsetof有什么限制A只对标准布局类型POD 子集可用对带虚函数、继承、访问控制非统一的多态类型offsetof是未定义行为。Q为什么struct{char;int;char}尾部要补到 12A为了数组A arr[2]的第二个元素也能对齐arr[1].i必须在 4 的倍数所以单个元素大小必须是 4 的倍数 → 补到 12。⑩ 动手实验实测布局运行E06_layout.exe核对sizeof/alignof/offsetof输出与本集图表一致。重排省内存写一个包含char、int、double、short、long long的结构体先乱序排、再降序排对比sizeof差异。数填充用offsetof打印每个成员偏移画出布局图确认每处填充在哪。试位域对比struct{unsigned a,b,c;}12 字节和位域版本4 字节再看访问位域的汇编会看到移位掩码。验证 EBOstruct X : Empty{};与struct Y{Empty e;};对比sizeof前者 1、后者 1——实测一下X空派生类通常还是 1因为它本身是空类。⑫ 扩展专题一标准布局Standard Layout与 offsetofC 区分标准布局类型standard-layout与普通类关键是为了保证布局可预测、可序列化。粗略地说标准布局类型要求没有虚函数、没有虚基类所有非静态数据成员访问权限相同不能有的 private 有的 public最多一个类有非静态数据成员基类不能有数据而派生类也有数据。对标准布局类型offsetof(S, m)合法可用来精确序列化可以安全地memcpy到字节数组再拷回可以和 C 结构体互通布局ABI 层面。对非标准布局类型带虚函数等offsetof是未定义行为——因为虚表指针藏在对象头里E08 会看到布局不再是你写的成员顺序。判断标准std::is_standard_layout_vT、std::is_trivially_copyable_vT。⑬ 扩展专题二为什么对齐到 16很重要——SSE 的要求本集看到的基本类型对齐到 8但现代 CPU 的 SIMD 指令有更狠的对齐要求movdqu/movups非对齐 load/store任意对齐都能用但慢一点movdqa/movaps对齐 load/store要求 16 字节对齐否则异常但更快。编译器在向量化时E19/E21 会看到paddd等会优先用对齐指令。为此它要么知道对象对齐满足 16 字节如alignas(16)或编译器推断用对齐 prologue 非对齐尾处理的混合方案保守用非对齐指令。这就是为什么结构体对齐不只是省空间的问题还影响能否被高效向量化。给热数据结构加alignas(16)/alignas(64)有时能直接让编译器生成更快的 SIMD 代码。⑭ 扩展专题三布局重排实操——用工具找填充手动数填充容易错工程上可以用工具paholeLinuxpahole -C MyStruct a.out它会显示每个成员偏移、画出填充还能给重排建议。clang 警告-Wpadded会在每个有填充的结构体上警告提示你检查。offsetof自检写一个静态断言表把关键成员的偏移固化成static_assert(offsetof(S,x)0)换平台/编译器时立刻爆炸提醒。一个重排出奇迹的经典例子// 乱序char long long int char int → 32 字节// 降序long long int int char char → 24 字节同样的数据重排省 25%。在几十万对象的数组里这是几十 MB 的差异 缓存友好的提升。⑮ 扩展专题四位域的汇编长什么样位域看着省空间但访问它是有代价的。struct { unsigned a:3, b:5, c:8; };里写f.c 7;编译器生成的代码大致是movl (%rcx), %eax ; 读出整个 32 位 andl $-256, %eax ; 清掉 c 的 8 位掩码 orl $7, %eax ; 写入 7 movl %eax, (%rcx) ; 写回——一次读-改-写至少 4 条指令。相比普通int成员的一条movl位域访问慢且不可移植布局顺序未定义。结论位域是空间换速度的极端选项协议头、标志位密集时才值得普通业务代码优先用普通成员。⑯ 扩展 FAQQalignas(64)会浪费多少空间A每个对象 64 对齐 尾部补到 64 倍数小对象数组可能浪费接近 50%。只在跨缓存行是问题时用。Q结构体可以自己声明对齐小于自然对齐吗A可以#pragma pack或alignas(1)但成员访问可能变慢/未定义对某些类型。x86 通常能跑ARM 某些模式直接异常。Qstd::array/std::vector的元素对齐跟类型走吗A是。std::vectorint的元素按int对齐std::vector__m256按 32 对齐分配器会处理超对齐。Q为什么sizeof必须是对齐的倍数A数组T a[2]要求a[1]也满足T的对齐。若sizeof(T)不是alignof(T)倍数a[1]就错位了。Qoffsetof打印的偏移和实际地址差多少Aoffsetof是相对结构体起始的偏移加对象基址就是真实地址。多态类型的隐藏头vptr也会体现在偏移里但那是未定义用法。⑰ 扩展实验多态布局给struct A加一个虚函数打印sizeof和offsetof——会发现多出 8 字节vptrE08 会拆。alignas 前后对比struct alignas(64) T{ int x; };sizeof(T)变 64数组元素间隔也变 64。-Wpaddedg -Wpadded -c E06_layout.cpp看 clang/g 在哪几处报警填充。位域汇编g -O2 -S一个读位域的函数确认读-改-写指令序列。EBO 反例struct X { Empty e; int x; };sizeof(X)是不是 8验证成员空对象不享 EBO。⑱ 扩展专题五从布局到内存占用预估的工作流工程上当你需要预估一个结构体占多少内存时别只sizeof单个对象要算整块数据的真实成本总内存 ≈ N × sizeof(T) 每个成员里隐藏的堆分配如 std::string 的 SSO 外字符、std::vector 的堆数组 对齐/填充带来的浪费 容器自身的 overheadvector 三指针、map 每节点开销等例如一个std::vectorBig外层 vector 三指针24 字节但 1000 万个Big每个 64 字节占 640MB全部在堆上连续排布——此时alignof(Big)只影响数组内部对齐几乎不产生额外浪费因为连续。反过来一个std::mapint, Big每个节点除了 64 字节的Big还要加红黑树指针父/左/右 颜色约 32 字节总内存远大于N × 64。用汇编/布局视角估算内存能避免我以为只占 640MB其实快 1GB的意外。⑲ 扩展专题六缓存行、对齐与局部性的关系本集讲对齐最终要落到 E21 的性能上。对齐和性能的关系有三层未对齐 → 访问慢/异常成员跨字/跨缓存行CPU 要读两次本集已讲。对齐到缓存行边界 → 避免跨行把热数据按 64 字节对齐能保证一次缓存行命中就拿到完整对象不跨行。避免伪共享两个线程各写一个变量如果它们落在同一缓存行性能会急剧下降每次写都作废对方的缓存行。alignas(64)让每个线程的计数器独立一行。一个经典例子structalignas(64)PerThreadCounter{volatilelonglongcount;// 每个线程各一份按 64 对齐};如果把count换成普通成员让两个计数相邻多线程高频累加时会出现伪共享——-O2下明明每线程只写自己的变量却互相拖慢 5~10 倍。这是布局影响性能最直观的例子之一E17/E21 会展开。⑳ 扩展专题七POD、平凡类型与布局的可预测性C 标准里可预测布局有几个层级从最严到最松trivially copyable可以memcpy安全复制std::is_trivially_copyable_vT。无自定义拷贝/析构或全 trivial。standard layout布局可预测、可offsetof本集 ⑫ 已讲。PODC11 之前的概念trivially copyable standard layout。现代代码用is_standard_layout/is_trivially_copyable表达。为什么要区分因为它们决定能不能当 C 结构体用。与 C 交互、写序列化库、做内存映射把文件当结构体读时你只想要布局和成员顺序一致的类型——如果类型带虚函数或复杂的成员访问控制布局就不可靠了。实用建议需要offsetof/序列化 → 用 standard layout 类型static_assert(std::is_standard_layout_vT)守住。需要memcpy/memmove→ 用 trivially copyable 类型。别把带std::string的对象memcpy它有内部指针拷指针不拷内容。㉑ 扩展实验第二轮验证标准布局给struct A加虚函数static_assert(std::is_standard_layout_vA)编译报错——亲眼看多态 布局不可预测。内存预估练习估算std::mapint, Big存 100 万条的真实内存Big64B 红黑树节点 ~40B 对齐跑top/任务管理器对照。伪共享实验两个线程各对一个long long自增 1 亿次一个用普通结构体、一个用alignas(64)对比耗时差几倍。memcpy带 string 的对象memcpy一个含std::string的结构体然后修改原串——副本会变成悬空/共享验证trivially copyable的边界。㉒ 悬念布局定好了那构造一个对象时编译器到底替你执行了哪些隐藏调用成员构造顺序、析构顺序、隐式拷贝——下一集 E07我们看编译器生成的隐藏代码。布局定好了那构造一个对象时编译器到底替你执行了哪些隐藏调用成员构造顺序、析构顺序、隐式拷贝——下一集 E07我们看编译器生成的隐藏代码。布局定好了那构造一个对象时编译器到底替你执行了哪些隐藏调用成员构造顺序、析构顺序、隐式拷贝。
返回列表