一文掌握 Go 内存对齐

往期精选文章推荐:

  1. 深入理解 go map

  2. go 常用关键字

  3. 深入理解 Go 数组、切片、字符串

  4. 深入理解channel

  5. 深入理解 go context

  6. 深入 go interface 底层原理

  7. 深入理解 go reflect

  8. 深入理解 go unsafe

前言

在前面的文章 《深入理解 go reflect》和 《深入理解 go unsafe》 中都提到内存对齐,由于篇幅原因没有展开详细介绍,后面有同学私聊我想了解一下内存对齐的细节,今天和大家简单聊聊内存对齐。

为什么要内存对齐

下面是维基百科对内存对齐的描述:

现代计算机CPU一般是以32位元64位元大小作地址对齐,以32位元架构的计算机举例,每次以连续的4字节为一个区间,第一个字节的位址位在每次CPU抓取资料大小的边界上,除此之外,如果要访问的变量没有对齐,可能会触发总线错误

总的来说,内存对齐有两点好处:

  1. 方便CPU读写内存(性能更好)

    现代计算机体系结构通常以特定的字节边界来访问内存。如果数据的存储地址没有按照合适的边界对齐,可能会导致多次内存访问,降低程序的执行效率。例如,对于一个 32 位的整数,如果存储在非 4 字节对齐的地址上,可能需要两次内存读取操作才能获取完整的数据,而在对齐的地址上,一次读取操作即可。

  1. 平台兼容性

    不同的硬件平台和操作系统对内存对齐的要求可能不同。通过遵循合适的内存对齐规则,可以确保程序在不同的环境下都能正确运行,提高程序的可移植性。

内存对齐规则

内存对齐是一种将数据在内存中进行排列的方式,目的是提高内存访问的效率和保证数据的完整性。数据的内存排列方式最直观的体现就是数据的内存地址,说白了就是数据的内存地址要符合一定规则,以便于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、uint 4 or 8 4 8
int32、uint32 4 4 4
int64、uint64 8 4 8
int8、uint8 1 1 1
int16、uint16 2 2 2
float32 4 4 4
float64 8 4 8
bool 1 1 1

基本类型的对齐示例:

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

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

数组的对齐

  1. 如果数组中的元素是基本类型,那么数组的对齐值通常与元素类型的对齐值相同。例如,如果数组中的元素是int16类型,int16通常是 2 字节对齐,那么这个数组的对齐值也是 2 字节。
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
相关推荐
爱笑的眼睛119 小时前
浅谈 前端验证码那些事
前端·程序人生
GPT01231 天前
大模型日报 2024-12-20
程序人生
十二测试录1 天前
Python基础——字符串
开发语言·经验分享·python·程序人生·职场发展
测试老哥1 天前
Python自动化测试图片比对算法
自动化测试·软件测试·python·测试工具·程序人生·职场和发展·测试用例
杰哥就是杰哥3 天前
坚持奋斗到底
程序人生
大鱼前端4 天前
React 19新特性探索:提升性能与开发者体验
程序人生
测试19984 天前
什么是自动化测试?
自动化测试·软件测试·python·测试工具·程序人生·职场和发展·测试用例
python_知世4 天前
基于LLaMA-Factory微调Llama3
人工智能·深度学习·程序人生·自然语言处理·大语言模型·llama·大模型微调
赵大仁5 天前
从零开始掌握Vue.js组件开发:详解原理与实践
前端·javascript·vue.js·经验分享·vscode·程序人生·前端框架