内存对齐
概念
内存对齐是指数据在内存中的存储起始地址必须是某个值的整数倍(通常是2、4、8等)。
操作系统访问内存时是按照字长(word)为单位的,字长是 CPU 一次能读取的内存数据的大小。比如在 64 位机器上,字长为 8 字节。如果内存数据的地址是字长的整数倍,那么 CPU 就可以一次读取到完整的数据,否则就需要多次访问内存,造成效率降低。内存对齐还可以保证内存数据的原子性,比如在 32 位平台上进行 64 位的原子操作,就必须要求数据是 8 字节对齐的,否则可能会出现 panic。
CPU访问对齐的数据比访问未对齐的数据更高效。
- 硬件要求:某些CPU架构要求特定类型的数据必须对齐访问,否则会导致硬件异常
- 性能优化:对齐的数据访问速度更快,因为CPU可以一次读取完整数据
- 缓存友好:对齐数据能更好地利用CPU缓存行
规则
基本类型
类型 | 大小(字节) | 对齐要求 |
---|---|---|
bool | 1 | 1 |
byte | 1 | 1 |
int/uint | 8 | 8 |
int8/uint8 | 1 | 1 |
int16/uint16 | 2 | 2 |
int32/uint32 | 4 | 4 |
int64/uint64 | 8 | 8 |
float32 | 4 | 4 |
float64 | 8 | 8 |
complex64 | 8 | 4 |
complex128 | 16 | 8 |
string | 16 | 8 |
slice | 24 | 8 |
map | 8 | 8 |
chan | 8 | 8 |
pointer | 8 | 8 |
func | 8 | 8 |
interface | 16 | 8 |
结构体
示例
- 结构体的对齐要求是其所有字段中对齐要求最大的那个
- 结构体的大小必须是其对齐要求的整数倍
- 字段在结构体中的排列顺序会影响最终的内存布局和大小
- 内部字段对齐和外部长度填充:内部字段对齐是指结构体中每个字段的偏移量(offset)必须是该字段自身大小和对齐系数中较小值的整数倍。外部长度填充是指结构体所占用的内存大小必须是结构体最大成员长度和操作系统字长较小值的整数倍。
1type Example1 struct {
2 a bool // 1字节
3 b int32 // 4字节
4 c int8 // 1字节
5 d int64 // 8字节
6 e byte // 1字节
7}
这个结构体的实际内存布局和大小可以通过以下步骤计算:
- 最大对齐要求是8(来自int64)
- 内存布局:
- a: 偏移0,大小1
- 填充3字节(因为b需要4字节对齐)
- b: 偏移4,大小4
- c: 偏移8,大小1
- 填充7字节(因为d需要8字节对齐)
- d: 偏移16,大小8
- e: 偏移24,大小1
- 填充7字节(使总大小为8的倍数)
- 总大小:32字节
1fmt.Println(unsafe.Sizeof(Example1{})) // 32
2fmt.Println(unsafe.Alignof(Example1{})) // 8
通过调整字段顺序可以优化:
1type Example2 struct {
2 d int64 // 8字节
3 b int32 // 4字节
4 a bool // 1字节
5 c int8 // 1字节
6 e byte // 1字节
7}
优化后的内存布局:
- d: 偏移0,大小8
- b: 偏移8,大小4
- a: 偏移12,大小1
- c: 偏移13,大小1
- e: 偏移14,大小1
- 填充2字节(使总大小为8的倍数) 总大小:16字节
1fmt.Println(unsafe.Sizeof(Example2{})) // 16
2fmt.Println(unsafe.Alignof(Example2{})) // 8
空结构体
空结构体struct{}
大小为0,但作为字段时,一般不需要内存对齐。特别的,如果它是末尾字段,那么为了防止指针指向结构体之外的地址,导致内存泄露(该内存不因结构体释放而释放),可能也会对结构体后面进行长度填充。
1type Problem struct {
2 data int64
3 empty struct{} // 零大小字段在末尾
4}
5
6var p1 Problem
7var p2 Problem
8
9// 如果没有填充,&p1.empty和&p2.empty理论上会指向相同地址:
10// &p1.empty == &p2.empty
p1.empty
会紧跟在p1.data
之后(地址 = &p1 + 8)p2.empty
会紧跟在p2.data
之后(地址 = &p2 + 8)- 如果
p1
和p2
在内存中连续分配,&p1.empty
实际上会指向p2.data
的地址,违反指针唯一性及其他内存安全问题
1type S1 struct {
2 a struct{} // 不占用空间
3 b int64
4}
5// sizeof(S1) == 8 (仅b的大小)
6
7type S2 struct {
8 b int64
9 a struct{} // 导致填充
10}
11// sizeof(S2) == 16 (64位系统上)
指针字段
原始问题:https://github.com/golangci/golangci-lint/discussions/2298
1type MyStruct struct {
2 FirstID int64
3 SecondID int64
4 ThirdID int64
5 FourthID int64
6 Name string
7}
When I run the golang-ci on this piece of code on travis, I get the following answer :
fieldalignment: struct with 40 pointer bytes could be 8
.
- 指针字段应该前置:Go 的垃圾回收器需要扫描所有包含指针的内存块,把指针集中放在结构体开头可以显著减少 GC 扫描范围
- 当前问题:
string
类型底层包含指针(指向实际字符数据),现在被放在最后,导致 GC 需要扫描整个结构体 - 40→8 的含义:当前布局 GC 需要扫描 40 字节(整个结构体),优化后只需扫描 8 字节(仅指针部分)
字符串底层是引用类型,标头含有值地址指针(8字节)和字符个数(int型8字节)两个字段;所以总共16字节。
- Go 的垃圾回收器需要扫描所有包含 指针 的内存区域
- 当扫描一个结构体时,GC 会:
- 从结构体开头扫描
- 持续扫描直到遇到 连续的非指针字段(根据类型信息判断)
- 然后停止扫描该结构体的剩余部分
查看对齐信息
unsafe
1import "unsafe"
2
3type MyStruct struct {
4 a int8
5 b int32
6 c int64
7}
8
9func main() {
10 var s MyStruct
11 fmt.Println("Size:", unsafe.Sizeof(s)) // 结构体大小
12 fmt.Println("Align:", unsafe.Alignof(s)) // 结构体对齐要求
13 fmt.Println("Field a offset:", unsafe.Offsetof(s.a)) // 偏移量
14}
reflect
1import "reflect"
2
3func printStructAlignment(s interface{}) {
4 typ := reflect.TypeOf(s)
5 fmt.Printf("Struct is %d bytes, alignment %d\n", typ.Size(), typ.Align())
6
7 for i := 0; i < typ.NumField(); i++ {
8 field := typ.Field(i)
9 fmt.Printf("%s: offset %d, size %d, align %d\n",
10 field.Name,
11 field.Offset,
12 field.Type.Size(),
13 field.Type.Align())
14 }
15}
注意
- 空结构体:空结构体
struct{}
大小为0,但作为字段时,一般不需要内存对齐。特别的,如果它是最后一个字段,那么为了防止指针指向结构体之外的地址,导致内存泄露,可能也会对结构体后面进行长度填充。 - 数组:数组的对齐要求与其元素类型相同
- 接口:接口变量占用16字节(64位系统),包含一个类型指针和一个值指针
- 字符串:字符串变量占用16字节,包含一个数据指针和一个长度字段
- 切片:切片变量占用24字节,包含一个数据指针、一个长度和一个容量字段
建议
- 字段排序:将大小相近的字段放在一起,特别是将较大的字段放在前面
- 避免过度填充:合理排列字段顺序可以减少填充字节
- 热点数据结构:对于频繁访问的结构体,优化其内存布局可以显著提高性能
- 测试验证:使用基准测试验证不同布局的性能差异
通过unsafe
和reflect
包,检查和验证结构体的内存布局,做出更好的设计决策。
Go 官方也有一个工具 fieldalignment 进行检测和调整。