Skip to content

概述

内存对齐,或者说字节对齐,指代码编译后在内存的布局与使用方式。现代计算机一般是 32位64位 地址对齐,如果要访问的变量内存没有对齐,可能会触发总线错误。

为什么需要内存对齐

CPU 访问内存时,并不是逐个字节访问,而是以字长(word size)为单位访问。比如 32 位的 CPU ,字长为 4 字节,那么 CPU 访问内存的单位也是 4 字节。 这么设计的目的,是减少 CPU 访问内存的次数,提升 CPU 访问内存的吞吐量。比如同样读取 8 个字节的数据,一次读取 4 个字节那么只需要读取 2 次。

CPU 始终以字长访问内存,如果不进行内存对齐,很可能增加 CPU 访问内存的次数,例如:

这里假设字长为 4 字节

变量 a、b 各占据 3 字节的空间,内存对齐后,a、b 占据 4 字节空间,CPU 读取 b 变量的值只需要进行一次内存访问。 如果不进行内存对齐,CPU 读取 b 变量的值需要进行 2 次内存访问。第一次访问得到 b 变量的第 1 个字节,第二次访问得到 b 变量的后两个字节。

从这个例子中也可以看到,内存对齐对实现变量的原子性操作也是有好处的,每次内存访问是原子的,如果变量的大小不超过字长,那么内存对齐后, 对该变量的访问就是原子的,这个特性在并发场景下至关重要。

内存对齐可以提高内存读写性能,并且便于实现原子性操作。

内存对齐带来的影响

内存对齐提升性能的同时,也需要付出相应的代价。由于变量与变量之间增加了填充,并没有存储真实有效的数据,所以 占用的内存会更大,这也是典型的 空间换时间 策略。

内存对齐规则

内存对齐是一种将数据在内存中进行排列的方式,目的是提高内存访问的效率和保证数据的完整性。数据的内存排列方式最直观的体现就是数据的内存地址,说白了就是数据的内存地址要符合一定规则,以便于CPU读取,这个规则就是下面要讲的内存对齐规则。

默认对齐值

在不同的平台上的编译器有自己默认的"对齐值", 可以通过预编指令 #pragmapack(n) 进行变更,n 就是代指"对齐系数"。一般来讲,常用的平台对齐系数是: 32位是 4,64位是 8。

基本类型的对齐

在 Go 语言中,基本数据类型通常按默认对齐值和数据自身大小的最小值进行对齐,也就是说数据的内存地址必须是默认对齐值和数据大小的最小值的整数倍。

使用伪代码表示一下:

Go
// 64 位平台,默认对齐值 8
const defaultAlign int = 8

func GetAlign(a any) int {
    return min(defaultAlign, sizeOf(a))
}

常见的基本类型对齐值:

数据类型自身大小32位平台对齐值64位平台对齐值
int、uint4 or 848
int32、uint32444
int64、uint64848
int8、uint8111
int16、uint16222
float32444
float64848
bool111

基本类型的对齐示例:

Go
Go 代码解读复制代码package main

import (
    "unsafe"
    "fmt"
)

func main() {
    var a int16 = 1
    var b bool = true
    var c int = 1
    var d bool = true
    var e float32 = 3.14
    
    
    fmt.Printf("a 的对齐值:%d, 地址: %d\n", unsafe.Alignof(a), uintptr(unsafe.Pointer(&a)))
    fmt.Printf("b 的对齐值:%d, 地址: %d\n", unsafe.Alignof(b), uintptr(unsafe.Pointer(&b)))
    fmt.Printf("c 的对齐值:%d, 地址: %d\n", unsafe.Alignof(c), uintptr(unsafe.Pointer(&c)))
    fmt.Printf("d 的对齐值:%d, 地址: %d\n", unsafe.Alignof(d), uintptr(unsafe.Pointer(&d)))
    fmt.Printf("e 的对齐值:%d, 地址: %d\n", unsafe.Alignof(e), uintptr(unsafe.Pointer(&e)))
}

$ go run main.go
a 的对齐值:2, 地址: 824634150562
b 的对齐值:1, 地址: 824634150561
c 的对齐值:8, 地址: 824634150568
d 的对齐值:1, 地址: 824634150560
e 的对齐值:4, 地址: 824634150564

上面的输出和我们的结论是契合的,内存地址都是对齐值的倍数,另外还有一点,为了减少内存开销,编译器还会优化内存布局,减少内存碎片。

img

数组的对齐

  1. 如果数组中的元素是基本类型,那么数组的对齐值通常与元素类型的对齐值相同。例如,如果数组中的元素是int16类型,int16通常是 2 字节对齐,那么这个数组的对齐值也是 2 字节。
Go
Go 代码解读复制代码package main

import (
    "unsafe"
    "fmt"
)

func main() {
    var arr [3]int16 = [3]int16{1,2,3}

    fmt.Printf("arr 的对齐值:%d\n", unsafe.Alignof(arr))
    fmt.Printf("arr 的大小: %d\n", unsafe.Sizeof(arr))
    fmt.Printf("arr 的地址: %d\n", uintptr(unsafe.Pointer(&arr)))
}

$ go run main.go
arr 的对齐值:2
arr 的大小: 6
arr 的地址: 824634150682
  1. 如果数组中的元素是结构体类型,那么数组的对齐值通常是结构体中最大字段对齐值的倍数。例如,如果结构体中有一个int64字段和一个bool字段,在 64 位系统上,int64通常是 8 字节对齐,bool通常是 1 字节对齐,那么这个结构体的对齐值可能是 8 字节。如果这个结构体组成的数组,其对齐值也会是 8 字节。
Go
package main

import (
    "unsafe"
    "fmt"
)

type s struct {
        a bool
        b int64
}

func main() {
        var arr [3]s = [3]s{s{},s{},s{}}
    
        fmt.Printf("arr 的对齐值:%d\n", unsafe.Alignof(arr))
        fmt.Printf("arr 的大小: %d\n", unsafe.Sizeof(arr))
        fmt.Printf("arr 的地址: %d\n", uintptr(unsafe.Pointer(&arr)))
}

$ go run main.go
arr 的对齐值:8
arr 的大小: 48
arr 的地址: 824634216176

结构体的对齐

  1. 结构体中的字段会按照各自的类型进行对齐。每个字段的起始地址必须是其类型对齐值的整数倍。
Go
package main

import (
    "unsafe"
    "fmt"
)

type S struct {
        a bool
        b int32
}

func main() {
        bar := S{}
    
        fmt.Printf("bar.b 的对齐值:%d\n", unsafe.Alignof(bar.b))
        fmt.Printf("bar.b 的偏移量: %d\n", unsafe.Offsetof(bar.b))
}

$ go run main.go
bar.b 的对齐值:4
bar.b 的偏移量: 4
  1. 结构体本身也有一个对齐值,这个对齐值是结构体中最大字段对齐值的倍数。整个结构体的大小必须是其对齐值的整数倍。
Go
package main

import (
    "unsafe"
    "fmt"
)

type S struct {
    a int64
    b bool
}

func main() {
    bar := S{}

    fmt.Printf("bar 的对齐值: %d\n", unsafe.Alignof(bar))
    fmt.Printf("bar 的大小: %d\n", unsafe.Sizeof(bar))
}

$ go run main.go
bar 的对齐值: 8
bar 的大小: 16

数据结构优化

为了实现内存对齐,编译器有时候会在结构体的字段之间或者结构体末尾进行填充,这样会带来两个问题:

  1. 影响性能:由于字段之间填充了空置的内存,会导致CPU读取结构体数据时访问内存的次数会增加。
  2. 浪费内存:浪费内存就更好理解,填充的空置内存是无法被程序利用的,就白白浪费掉了。

所以我们设计结构体时应该注意内存对齐对性能的影响,尽量减少填充空置内存,下面两个结构体拥有相同类型的字段,只因字段顺序的不同就造成很大性能差异:

Go
// 需要填充 9 个字节
type S1 struct {
    a bool
    b int64
    c int16
    d int32
}

// 只需要填充 1 个字节
type S2 struct {
    a bool
    b int16
    c int32
    d int64
}

func Benchmark_S1(b *testing.B) {
        b.ResetTimer()

        for i := 0; i < b.N; i++ {
            _ = make([]S1, b.N)
        }
}

func Benchmark_S2(b *testing.B) {
        b.ResetTimer()

        for i := 0; i < b.N; i++ {
            _ = make([]S2, b.N)
        }
}


>>> go test -run='^$' -bench=. -count=1 -benchtime=100000x -benchmem
goos: darwin
goarch: amd64
pkg: go_test/unsafe/align
Benchmark_S1-12           100000             66230 ns/op         2400267 B/op          1 allocs/op
Benchmark_S2-12           100000             45750 ns/op         1605655 B/op          1 allocs/op
PASS
ok      go_test/unsafe/align    11.736s

总结

结构体类型时刻牢记内存对齐。

现在随着硬件成本的降低,以及处理速度的提升。没什么特殊问题,可以不关注这一块。但要知道这块知识点