字节对齐与字节填充:从 CPU 到 Go

前言

这篇文章主要讲字节对齐(Alignment)字节填充(Padding),这是 C/C++、Go、Rust、Java 虚拟机底层实现以及操作系统领域经常出现的知识点。我们从「CPU 为什么需要对齐」开始讲,更容易建立完整认知。

一、什么是字节对齐

假设有一个 int32

int a = 10;

int32 占 4 个字节。如果把它放到 0x1000 开始的位置,那么它是 4 字节对齐 的,因为 0x1000 % 4 = 0

0x1000  10
0x1001
0x1002
0x1003

如果放到 0x1001 开始,那么 0x1001 % 4 != 0,这叫未对齐(Unaligned)

0x1000
0x1001 10
0x1002
0x1003
0x1004

二、CPU 为什么喜欢对齐

现代 CPU 读取数据不是一次读 1 字节,而是 4 / 8 / 16 / 32 / 64 字节成块读取。例如 CPU 一次读取 8 字节,内存 0x1000 ~ 0x1007 一个周期即可完成。

对齐情况int32 放在 0x1000,CPU 读一次即可得到数据:

┌─────────────┐
│0x1000~1007 │
└─────────────┘
     int32

未对齐情况:如果放在 0x1007,数据 0x1007 ~ 0x100A 横跨两个缓存块。CPU 需要先读 0x1000~1007,再读 0x1008~100F,然后拼接,变成两次访存,性能下降。

三、什么是字节填充(Padding)

看一个结构体:

struct Test {
    char a;
    int  b;
};

大小是多少?很多人第一反应是 1 + 4 = 5,实际上 sizeof = 8。内存布局:

offset
0   a (1 byte)
1
2
3   <-- padding
4   b (4 byte)
5
6
7

图示:

┌────┬────┬────┬────┬────┬────┬────┬────┐
│ a  │pad │pad │pad │       b      │
└────┴────┴────┴────┴────┴────┴────┴────┘

为什么?因为 int 需要 4 字节对齐,编译器必须让 b 的地址 % 4 == 0,因此补了 3 字节,这就是 Padding

四、结构体对齐规则

大多数平台遵循两条规则:

规则 1:成员地址必须是 min(成员大小, 对齐系数) 的整数倍。

char   1
short  2
int    4
double 8

规则 2:结构体总大小必须是最大对齐数的整数倍。

例子:

struct S {
    char a;
    int  b;
    char c;
};

推算过程:

成员 offset 说明
a 0 size = 1
b 4 需要 4 字节对齐,补 3 字节
c 8 size = 1,到 9
末尾 当前大小 9,最大对齐数 4,补 3 字节到 12

最终布局:

0   a
1-3 padding
4-7 b
8   c
9-11 padding

sizeof(S) = 12

五、为什么结构体末尾也要填充

struct S {
    int  a;
    char b;
};

明明 4 + 1 = 5,为什么 sizeof(S) = 8?原因是数组:

S arr[100];

如果结构体大小是 5,那么 arr[0]0~4,下一项 arr[1] 从地址 5 开始,但 int a 需要 4 字节对齐,地址 5 显然不满足。所以编译器直接让 sizeof(S) = 8,这样每个结构体首地址(0、8、16、24…)都天然对齐。

六、优化结构体大小

经典问题:

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

大小为 12。调整顺序:

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

布局变成 4 + 1 + 1 + 2(padding),得到 8,从 12 → 8,减少 33% 内存

大规模对象时收益非常明显,例如 1000 万对象,原来 120MB,优化后 80MB,直接少 40MB。

七、Go 中的字节对齐

type User struct {
    Age  int64
    Flag bool
}

布局:Age 8字节 + Flag 1字节 + Pad 7字节,总大小 16 字节:

fmt.Println(unsafe.Sizeof(User{}))  // 16

字段顺序颠倒也一样:

type User struct {
    Flag bool
    Age  int64
}

仍然是 16,因为 bool 后面补 7 字节,让 Age 对齐到 8 字节边界。

再复杂一点:

type User struct {
    A bool
    B bool
    C bool
    D int64
}

布局:A 1 + B 1 + C 1 + Pad 5 + D 8,总计 16。

八、为什么 Go 的 Map 和 Slice 也依赖对齐

研究 Go 底层时这个知识非常重要。例如:

type bmap struct {
    tophash [8]uint8
    keys    ...
    values  ...
}

Go Runtime 在设计 Bucket 时会保证 key 区域对齐、value 区域对齐、overflow 指针对齐,原因:CPU 读取更快,GC 扫描更方便

Slice 底层:

type slice struct {
    array unsafe.Pointer
    len   int
    cap   int
}

64 位机器上是 8 + 8 + 8,总共 24 字节,天然按 8 字节对齐。

总结(一句话版)

字节对齐是让数据地址满足 CPU 要求的边界规则,从而提高访存效率;字节填充是编译器为了满足对齐规则自动插入的空白字节。结构体大小不仅取决于成员大小之和,还取决于成员对齐和结构体整体对齐,因此合理调整字段顺序能够显著减少内存占用并提高缓存利用率。

可以记成一条链路:

CPU要对齐
成员要对齐
产生Padding
结构体总大小也要对齐
字段顺序影响内存占用

这条链路基本覆盖了关于「字节对齐」80% 以上的追问。