Go语言结构体内存对齐完全指南
# 1. 简介
在Go语言开发中,结构体内存对齐是影响程序性能和内存效率的关键因素。本文将从底层原理出发,详细解析内存对齐的工作机制及其对程序性能的实际影响。
# 2. 使用结构体内存对齐效果是怎么样的?
创建两个结构体,其中的成员变量数量和类型完成一致,只有顺序摆放的不一样。
import (
"fmt"
"unsafe"
)
type PoorlyAligned struct {
flag bool
count int64
id byte
}
type WellAligned struct {
count int64
flag bool
id byte
}
func main() {
fmt.Println(unsafe.Sizeof(PoorlyAligned{}))
fmt.Println(unsafe.Sizeof(WellAligned{}))
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
运行结果如下,可以看到 PoorlyAligned 的内存大小是24,而 WellAligned 的内存大小是 16
24
16
2
为什么改了变量的顺序,却在内存中的大小则不一样了呢?
# 3. 什么是结构体内存对齐?
CPU 访问内存时是根据字长来访问而不是字节。比如 32 位的 CPU 字长是 4 字节,64 位的 CPU 时 8 字节。这么设计的目的是减少 CPU 访问内存的次数,提高吞吐量。
这个时候我们看到结构体 PoorlyAligned,bool是 4 字节,int64 是 8 字节,byte是 4 字节,CPU 读取 bool 变量 flag 时,因为字长是 8 字节,必须得读 8 个字节,所以必须得补上空的 4 个字节。而 int64 的 count 变量本身是 8 字节的不需要补位,读byte 的 id 时和 flag 一样也需要补 4 个字节,最终是 24 个字节。
再看到结构体 WellAligned,顺序是 count、flag、id,count 可以一次取走,而 bool 可以和 id 在同一次一起取走,所以最终只需要在内存存储 16 个字节。
# 4. 针对结构体是否对齐的基准测试
func BenchmarkPoorlyAligned(b *testing.B) {
for range b.N {
var items = make([]PoorlyAligned, 10_000_000)
for j := range items {
items[j].count = int64(j)
}
}
}
func BenchmarkWellAligned(b *testing.B) {
for range b.N {
var items = make([]WellAligned, 10_000_000)
for j := range items {
items[j].count = int64(j)
}
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
运行结果如下,内存对齐在速度上要快上约 35%,内存分配要少上约 50%
$ go test -bench=. -benchmem .
goos: darwin
goarch: arm64
pkg: main/demo
cpu: Apple M4 Pro
BenchmarkPoorlyAligned-12 248 4575658 ns/op 240001038 B/op 1 allocs/op
BenchmarkWellAligned-12 356 3392792 ns/op 160006157 B/op 1 allocs/op
PASS
ok main/demo 5.126s
2
3
4
5
6
7
8
9
# 5. 兼容硬件平台与架构
接下来看这么一种情况,f1 int8占用 1 个字节,f2 int16占用 2 个字节,f3 int8 也占用 1 个字节,但是输出的字节大小却是 6 而不是 4,因为这里也做了内存对齐。
type S1 struct {
f1 int8
f2 int16
f3 int8
}
func main() {
s1 := S1{}
fmt.Println(unsafe.Sizeof(s1))
}
2
3
4
5
6
7
8
9
10
运行输出:
6
问题来了,操作系统一个字长 8 个字节,那么无论是 4 还是 6 CPU 都能够一次拿到整体数据,效率是一样的呀。
这是因为,内存对齐不仅仅是为了保证 CPU 的读取效率,还有就是为了兼容硬件平台和架构做的限制,比如在 CPU 是 ARM架构 时强制要做内存对齐的,不做的话则会直接报错。
那么这里的对齐规则是怎么样的呢?
# 6. 内存对齐规则
# 6.1 对齐系数
字段类型不仅有类型大小,还有类型对齐系数, 其有以下两个规则
- Go原始类型的对齐系数与类型长度相等。
可以使用unsafe.Alignof
来查看类型的对齐系数,可以发现两者是一致的。
func main() {
fmt.Println(unsafe.Sizeof(int64(1))) // 8
fmt.Println(unsafe.Sizeof(float32(32))) // 4
fmt.Println(unsafe.Alignof(int64(1))) // 8
fmt.Println(unsafe.Alignof(float32(32))) //4
}
2
3
4
5
6
7
- Go结构体类型的对齐系数是最长字段的对齐系数和系统对齐系数两者中的最小的那个。
可以看到结构体的对齐系数是 2,这是因为最长字段是 f2 其对齐系数是 2,操作系统是 64 位,其对齐系数是 8,取最小的也就是 2
type S1 struct {
f1 int8
f2 int16
f3 int8
}
func main() {
s1 := S1{}
fmt.Println(unsafe.Sizeof(s1)) // 6
fmt.Println(unsafe.Alignof(s1)) // 2
}
2
3
4
5
6
7
8
9
10
11
# 6.2 字段偏移量
字段偏移量是指在结构体内存对齐后字段从起始位置开始的偏移量,也就是从起始位置开始计算的字节数,第一个字段的偏移量是 0。
# 6.3 struct 内存对齐规则
对齐规则如下:
- 结构体中的每个字段所在的偏移量必须是其对齐系数的整数倍,如果不能保证则需要添加空的字节来保证内存对齐。
- 结构体的长度一定要是其本身对齐系数的整数倍。
知道规则后,再回到之前的 S1 的例子:
- f1 的偏移量为0,占用 1 个字节,默认是对齐的。
- f2 的对齐系数是2,如果从偏移量 1 开始存储,则不是2的倍数,必须前面空出一个,从偏移量2开始,才是对齐系数的倍数,所以从偏移量从2开始存储,并存储2个字节
- f3 的对齐系数是1,从偏移量 5 开始存储,存储 1 个字节,是对齐的。
- 本结构体的对齐系数是 2,而大小需要时对齐系数的整数倍,而根据前面几步的长度是 5,不能对齐,所以还需要空出一个字节才能使结构体对齐,也就是长度为 6 字节
type S1 struct { // 长度:6, 对齐系数: 2
f1 int8 // 长度:1, 对齐系数: 1
f2 int16 // 长度:2, 对齐系数: 2
f3 int8 // 长度:1, 对齐系数: 1
}
2
3
4
5
# 6.4 struct{} 作为结构体最后一个字段
struct{}这样的空结构体的大小是为 0 的,如果其作为其他结构体字段的时候,是不占用内存的,也不需要考虑内存对齐的问题,但只有一种情况例外,就是struct{}作为结构体的最后一个字段时,是会进行内存对齐的,而其对齐规则是:会被填充对齐到前一个字段的大小。
这是因为如果有指针指向该字段, 返回的地址将在结构体之外,如果此指针一直存活不释放对应的内存,就会有内存泄露的问题(该内存不因结构体释放而释放)
具体案例如下,demo1、demo2、demo3中的struct{}的都会按照前一个字段的大小进行填充对齐。
type demo1 struct {
c int8
a struct{}
}
type demo2 struct {
c int16
a struct{}
}
type demo3 struct {
c int32
a struct{}
}
type demo4 struct {
a struct{}
c int32
}
func main() {
fmt.Println(unsafe.Sizeof(demo1{})) // 2
fmt.Println(unsafe.Sizeof(demo2{})) // 4
fmt.Println(unsafe.Sizeof(demo3{})) // 8
fmt.Println(unsafe.Sizeof(demo4{})) // 4
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# 7. fieldalignment linter
通过使用fieldalignment linter
工具可以提前发现内存对齐问题,可以帮助我们来对结构体中的字段顺序进行正确的排列,减少内存的占用,提高运行的效率。
该结构体通过上面的分析,长度为 6,为了内存对齐是补了 2 个字节的。
type S1 struct {
f1 int8
f2 int16
f3 int8
}
2
3
4
5
在项目中新增文件.golangci.yaml
,内容如下,主要是将 fieldalignmeng 功能开启
version: 2
linters:
default: all
enable:
- govet
settings:
govet:
enable:
- fieldalignment
2
3
4
5
6
7
8
9
然后运行golangci-lint
会有提示,结构体的大小可以被优化,从 6 到 4
$ golangci-lint run
...
main.go:9:9: fieldalignment: struct of size 6 could be 4 (govet)
type S1 struct {
...
2
3
4
5
修改结构体中字段顺序为不需要补齐内存的方式,再次运行上面的命令后,上面的提示错误信息就没有了。
type S1 struct {
f1 int8
f3 int8
f2 int16
}
2
3
4
5
# 8. 最佳实践
- 通过对字段的排序来减少内部无意义的填充。
- 尽可能将类型大小相同的字段放在一起,避免大小交替。
- 使用
fieldalignment linter
对代码进行校验,通过工具自动捕获