在 Go 中恰到好处的内存对齐

image

原文地址:在 Go 中恰到好处的内存对齐html

问题

type Part1 struct {
    a bool
    b int32
    c int8
    d int64
    e byte
}

在开始以前,但愿你计算一下 Part1 共占用的大小是多少呢?golang

func main() {
    fmt.Printf("bool size: %d\n", unsafe.Sizeof(bool(true)))
    fmt.Printf("int32 size: %d\n", unsafe.Sizeof(int32(0)))
    fmt.Printf("int8 size: %d\n", unsafe.Sizeof(int8(0)))
    fmt.Printf("int64 size: %d\n", unsafe.Sizeof(int64(0)))
    fmt.Printf("byte size: %d\n", unsafe.Sizeof(byte(0)))
    fmt.Printf("string size: %d\n", unsafe.Sizeof("EDDYCJY"))
}

输出结果:数组

bool size: 1
int32 size: 4
int8 size: 1
int64 size: 8
byte size: 1
string size: 16

这么一算,Part1 这一个结构体的占用内存大小为 1+4+1+8+1 = 15 个字节。相信有的小伙伴是这么算的,看上去也没什么毛病布局

真实状况是怎么样的呢?咱们实际调用看看,以下:性能

type Part1 struct {
    a bool
    b int32
    c int8
    d int64
    e byte
}

func main() {
    part1 := Part1{}
    
    fmt.Printf("part1 size: %d, align: %d\n", unsafe.Sizeof(part1), unsafe.Alignof(part1))
}

输出结果:学习

part1 size: 32, align: 8

最终输出为占用 32 个字节。这与前面所预期的结果彻底不同。这充分地说明了先前的计算方式是错误的。为何呢?优化

在这里要提到 “内存对齐” 这一律念,才可以用正确的姿式去计算,接下来咱们详细的讲讲它是什么spa

内存对齐

有的小伙伴可能会认为内存读取,就是一个简单的字节数组摆放调试

image

上图表示一个坑一个萝卜的内存读取方式。但实际上 CPU 并不会以一个一个字节去读取和写入内存。相反 CPU 读取内存是一块一块读取的,块的大小能够为 二、四、六、八、16 字节等大小。块大小咱们称其为内存访问粒度。以下图:code

image

在样例中,假设访问粒度为 4。 CPU 是以每 4 个字节大小的访问粒度去读取和写入内存的。这才是正确的姿式

为何要关心对齐

  • 你正在编写的代码在性能(CPU、Memory)方面有必定的要求
  • 你正在处理向量方面的指令
  • 某些硬件平台(ARM)体系不支持未对齐的内存访问

另外做为一个工程师,你也颇有必要学习这块知识点哦 :)

为何要作对齐

  • 平台(移植性)缘由:不是全部的硬件平台都可以访问任意地址上的任意数据。例如:特定的硬件平台只容许在特定地址获取特定类型的数据,不然会致使异常状况
  • 性能缘由:若访问未对齐的内存,将会致使 CPU 进行两次内存访问,而且要花费额外的时钟周期来处理对齐及运算。而自己就对齐的内存仅须要一次访问就能够完成读取动做

image

在上图中,假设从 Index 1 开始读取,将会出现很崩溃的问题。由于它的内存访问边界是不对齐的。所以 CPU 会作一些额外的处理工做。以下:

  1. CPU 首次读取未对齐地址的第一个内存块,读取 0-3 字节。并移除不须要的字节 0
  2. CPU 再次读取未对齐地址的第二个内存块,读取 4-7 字节。并移除不须要的字节 五、六、7 字节
  3. 合并 1-4 字节的数据
  4. 合并后放入寄存器

从上述流程可得出,不作 “内存对齐” 是一件有点 "麻烦" 的事。由于它会增长许多耗费时间的动做

而假设作了内存对齐,从 Index 0 开始读取 4 个字节,只须要读取一次,也不须要额外的运算。这显然高效不少,是标准的空间换时间作法

默认系数

在不一样平台上的编译器都有本身默认的 “对齐系数”,可经过预编译命令 #pragma pack(n) 进行变动,n 就是代指 “对齐系数”。通常来说,咱们经常使用的平台的系数以下:

  • 32 位:4
  • 64 位:8

另外要注意,不一样硬件平台占用的大小和对齐值均可能是不同的。所以本文的值不是惟一的,调试的时候需按本机的实际状况考虑

成员对齐

func main() {
    fmt.Printf("bool align: %d\n", unsafe.Alignof(bool(true)))
    fmt.Printf("int32 align: %d\n", unsafe.Alignof(int32(0)))
    fmt.Printf("int8 align: %d\n", unsafe.Alignof(int8(0)))
    fmt.Printf("int64 align: %d\n", unsafe.Alignof(int64(0)))
    fmt.Printf("byte align: %d\n", unsafe.Alignof(byte(0)))
    fmt.Printf("string align: %d\n", unsafe.Alignof("EDDYCJY"))
    fmt.Printf("map align: %d\n", unsafe.Alignof(map[string]string{}))
}

输出结果:

bool align: 1
int32 align: 4
int8 align: 1
int64 align: 8
byte align: 1
string align: 8
map align: 8

在 Go 中能够调用 unsafe.Alignof 来返回相应类型的对齐系数。经过观察输出结果,可得知基本都是 2^n,最大也不会超过 8。这是由于我手提(64 位)编译器默认对齐系数是 8,所以最大值不会超过这个数

总体对齐

在上小节中,提到告终构体中的成员变量要作字节对齐。那么想固然身为最终结果的结构体,也是须要作字节对齐的

对齐规则

  • 结构体的成员变量,第一个成员变量的偏移量为 0。日后的每一个成员变量的对齐值必须为编译器默认对齐长度#pragma pack(n))或当前成员变量类型的长度unsafe.Sizeof),取最小值做为当前类型的对齐值。其偏移量必须为对齐值的整数倍
  • 结构体自己,对齐值必须为编译器默认对齐长度#pragma pack(n))或结构体的全部成员变量类型中的最大长度,取最大数的最小整数倍做为对齐值
  • 结合以上两点,可得知若编译器默认对齐长度#pragma pack(n))超过结构体内成员变量的类型最大长度时,默认对齐长度是没有任何意义的

分析流程

接下来咱们一块儿分析一下,“它” 到底经历了些什么,影响了 “预期” 结果

成员变量 类型 偏移量 自身占用
a bool 0 1
字节对齐 1 3
b int32 4 4
c int8 8 1
字节对齐 9 7
d int64 16 8
e byte 24 1
字节对齐 25 7
总占用大小 - - 32

成员对齐

  • 第一个成员 a

    • 类型为 bool
    • 大小/对齐值为 1 字节
    • 初始地址,偏移量为 0。占用了第 1 位
  • 第二个成员 b

    • 类型为 int32
    • 大小/对齐值为 4 字节
    • 根据规则 1,其偏移量必须为 4 的整数倍。肯定偏移量为 4,所以 2-4 位为 Padding。而当前数值从第 5 位开始填充,到第 8 位。以下:axxx|bbbb
  • 第三个成员 c

    • 类型为 int8
    • 大小/对齐值为 1 字节
    • 根据规则1,其偏移量必须为 1 的整数倍。当前偏移量为 8。不须要额外对齐,填充 1 个字节到第 9 位。以下:axxx|bbbb|c...
  • 第四个成员 d

    • 类型为 int64
    • 大小/对齐值为 8 字节
    • 根据规则 1,其偏移量必须为 8 的整数倍。肯定偏移量为 16,所以 9-16 位为 Padding。而当前数值从第 17 位开始写入,到第 24 位。以下:axxx|bbbb|cxxx|xxxx|dddd|dddd
  • 第五个成员 e

    • 类型为 byte
    • 大小/对齐值为 1 字节
    • 根据规则 1,其偏移量必须为 1 的整数倍。当前偏移量为 24。不须要额外对齐,填充 1 个字节到第 25 位。以下:axxx|bbbb|cxxx|xxxx|dddd|dddd|e...

总体对齐

在每一个成员变量进行对齐后,根据规则 2,整个结构体自己也要进行字节对齐,由于可发现它可能并非 2^n,不是偶数倍。显然不符合对齐的规则

根据规则 2,可得出对齐值为 8。如今的偏移量为 25,不是 8 的整倍数。所以肯定偏移量为 32。对结构体进行对齐

结果

Part1 内存布局:axxx|bbbb|cxxx|xxxx|dddd|dddd|exxx|xxxx

小结

经过本节的分析,可得知先前的 “推算” 为何错误?

是由于实际内存管理并不是 “一个萝卜一个坑” 的思想。而是一块一块。经过空间换时间(效率)的思想来完成这块读取、写入。另外也须要兼顾不一样平台的内存操做状况

巧妙的结构体

在上一小节,可得知根据成员变量的类型不一样,其结构体的内存会产生对齐等动做。那假设字段顺序不一样,会不会有什么变化呢?咱们一块儿来试试吧 :-)

type Part1 struct {
    a bool
    b int32
    c int8
    d int64
    e byte
}

type Part2 struct {
    e byte
    c int8
    a bool
    b int32
    d int64
}

func main() {
    part1 := Part1{}
    part2 := Part2{}

    fmt.Printf("part1 size: %d, align: %d\n", unsafe.Sizeof(part1), unsafe.Alignof(part1))
    fmt.Printf("part2 size: %d, align: %d\n", unsafe.Sizeof(part2), unsafe.Alignof(part2))
}

输出结果:

part1 size: 32, align: 8
part2 size: 16, align: 8

经过结果能够惊喜的发现,只是 “简单” 对成员变量的字段顺序进行改变,就改变告终构体占用大小

接下来咱们一块儿剖析一下 Part2,看看它的内部到底和上一位之间有什么区别,才致使了这样的结果?

分析流程

成员变量 类型 偏移量 自身占用
e byte 0 1
c int8 1 1
a bool 2 1
字节对齐 3 1
b int32 4 4
d int64 8 8
总占用大小 - - 16

成员对齐

  • 第一个成员 e

    • 类型为 byte
    • 大小/对齐值为 1 字节
    • 初始地址,偏移量为 0。占用了第 1 位
  • 第二个成员 c

    • 类型为 int8
    • 大小/对齐值为 1 字节
    • 根据规则1,其偏移量必须为 1 的整数倍。当前偏移量为 2。不须要额外对齐
  • 第三个成员 a

    • 类型为 bool
    • 大小/对齐值为 1 字节
    • 根据规则1,其偏移量必须为 1 的整数倍。当前偏移量为 3。不须要额外对齐
  • 第四个成员 b

    • 类型为 int32
    • 大小/对齐值为 4 字节
    • 根据规则1,其偏移量必须为 4 的整数倍。肯定偏移量为 4,所以第 3 位为 Padding。而当前数值从第 4 位开始填充,到第 8 位。以下:ecax|bbbb
  • 第五个成员 d

    • 类型为 int64
    • 大小/对齐值为 8 字节
    • 根据规则1,其偏移量必须为 8 的整数倍。当前偏移量为 8。不须要额外对齐,从 9-16 位填充 8 个字节。以下:ecax|bbbb|dddd|dddd

总体对齐

符合规则 2,不须要额外对齐

结果

Part2 内存布局:ecax|bbbb|dddd|dddd

总结

经过对比 Part1Part2 的内存布局,你会发现二者有很大的不一样。以下:

  • Part1:axxx|bbbb|cxxx|xxxx|dddd|dddd|exxx|xxxx
  • Part2:ecax|bbbb|dddd|dddd

仔细一看,Part1 存在许多 Padding。显然它占据了很多空间,那么 Padding 是怎么出现的呢?

经过本文的介绍,可得知是因为不一样类型致使须要进行字节对齐,以此保证内存的访问边界

那么也不难理解,为何调整结构体内成员变量的字段顺序就能达到缩小结构体占用大小的疑问了,是由于巧妙地减小了 Padding 的存在。让它们更 “紧凑” 了。这一点对于加深 Go 的内存布局印象和大对象的优化很是有帮

固然了,没什么特殊问题,你能够不关注这一块。但你要知道这块知识点 😄

参考

相关文章
相关标签/搜索