前言

在编程的世界里,理解并有效利用内存是每位开发者不可或缺的技能之一。特别是在追求高性能和高效率的现代应用程序开发中,合理的内存管理显得尤为重要。

内存对齐(Memory Alignment)是一个既基础又关键的概念,它直接关系到程序的运行效率和性能。内存对齐是指数据在内存中的存储位置按照某个特定的规则进行排列,通常是为了满足硬件访问内存时的最优性能。

在Go语言中,编译器会自动处理大部分内存对齐的工作,但作为一名有志于写出高效Go代码的开发者,深入了解其背后的原理与规则,无疑会让我们在编程时更加游刃有余。

本文将深入探讨Go语言的内存对齐机制,首先解析Go中基本数据类型的对齐规则,随后聚焦于结构体的内存布局与对齐策略,帮助读者理解并优化自己的Go程序,以达到更高的运行效率。

内存对齐的必要性——从一份代码说起

在讨论内存对齐之前,我们从一份代码看起,引出内存对齐的概念。

下面这份代码写了两个结构体,其中一个是2个int32成员,另一个包括1个int32和1个int16成员。根据我们对基本数据类型的认识,这里Sizeof打印出来的结果,S1应该为8,S2应该为6。我们运行一下:

type S1 struct {
    a1 int32
    a2 int32
}

type S2 struct {
    a1 int16
    a2 int32
}

func main() {
    fmt.Println("S1=", unsafe.Sizeof(S1{}))
    fmt.Println("S2=", unsafe.Sizeof(S2{}))
}

然而实际程序输出结果:两者都是8。

这不禁引发我们的思考:为什么两个结构体的内存占用相同?

实际上,如果没有使用内存对齐,当我们要依次存放1个int16和2个int32时,会出现下面这种情况:

image.png

如果学过《计算机组成原理》这门课,我们知道,计算机的内部每次是读取内存是按照一个字长进行读取。这意味着如果我们这样分配内存,就把一个int32拆分成了两次读取,需要读取两次才能读到完整数据。这无疑破坏了内存读取的原子性,降低了内存读取的效率

内存对齐正是为了解决上述问题,以内存对齐的方式分配内存,有利于提高内存操作效率,同时有利于内存的原子性。使用了内存对齐后,内存分配示意图如下:

image.png

Go语言如何实现内存对齐

对齐系数

为了方便内存对齐,Go语言给每种类型都提供了一个对齐系数

所谓对齐系数,其含义是:变量的内存地址必须被对齐系数整除。例如对齐系数是8,那么变量的内存地址必须是8的倍数。

想要查看对齐系数,可以使用unsafe.AlignOf这个Api,我们可以用代码测试一下。

type S1 struct {
    a1 int32
    a2 int32
}

type S2 struct {
    a1 int16
    a2 int32
}

func main() {
    fmt.Printf("bool sizeof=%d,align=%d \n", unsafe.Sizeof(true), unsafe.Alignof(true))
    fmt.Printf("byte sizeof=%d,align=%d \n", unsafe.Sizeof(byte(1)), unsafe.Alignof(byte(1)))
    fmt.Printf("int16 sizeof=%d,align=%d \n", unsafe.Sizeof(int16(1)), unsafe.Alignof(int16(1)))
    fmt.Printf("int32 sizeof=%d,align=%d \n", unsafe.Sizeof(int32(1)), unsafe.Alignof(int32(1)))
    fmt.Printf("int64 sizeof=%d,align=%d \n", unsafe.Sizeof(int64(1)), unsafe.Alignof(int64(1)))
    fmt.Printf("float32 sizeof=%d,align=%d \n", unsafe.Sizeof(float32(1.0)), unsafe.Alignof(float32(1.0)))
    fmt.Printf("float64 sizeof=%d,align=%d \n", unsafe.Sizeof(float64(1)), unsafe.Alignof(float64(1)))
    fmt.Printf("string sizeof=%d,align=%d \n", unsafe.Sizeof("1"), unsafe.Alignof("1"))
    fmt.Printf("S1 sizeof=%d,align=%d \n", unsafe.Sizeof(S1{}), unsafe.Alignof(S1{}))
    fmt.Printf("S2 sizeof=%d,align=%d \n", unsafe.Sizeof(S2{}), unsafe.Alignof(S2{}))
}

输出结果如下:

byte sizeof=1,align=1 
int16 sizeof=2,align=2 
int32 sizeof=4,align=4 
int64 sizeof=8,align=8 
float32 sizeof=4,align=4 
float64 sizeof=8,align=8 
string sizeof=16,align=8 
S1 sizeof=8,align=4 
S2 sizeof=8,align=4

对于结构体,我们暂时还不清楚其的对齐系数计算方式,但是对于基本数据类型,我们可以看出:基础数据类型的对齐系数与其本身的占用长度是一样的

接下来,我们进一步探究结构体的对齐系数。

结构体的对齐系数

结构体可以看作是由众多类型组成的小系统。对于一个系统,我们既要考虑它的内部,也要考虑它的外部。结构体也是一样。结构体的内存对齐可以分为内部对齐和外部对齐

内部对齐:考虑成员大小和成员的对齐系数。

结构体的内部对齐指的是结构体内部成员的相对内存位置。每个成员的偏移量是自身大小与其对齐系数的较小倍数。例如我们看下面的结构体:

type Example struct{
    a bool
    b string
    c int16
}

我们从本文刚开始的代码知道,bool的对齐系数和长度都是1,int16的对齐系数和长度都是2,string的对齐系数是8而字长是16。按照三个字段的顺序以及对齐系数的定义,我们可以知道这个结构体在内存应该是长这样的:

image.png

长度填充

长度填充指的是结构体通过增加长度,对齐系统字长。结构体长度是最大成员长度与系统字长较小值的整数倍。

以上面的结构体为例,最大成员长度为string=16,系统字长=8,因此结构体的长度应该为8的整数倍。因此在下面这张图中,我们需要把int16剩下的6个字节填充为0。

image.png

如果我们想节约结构体的内存,我们可以把int16和string换个位置,这样就不用0x18~0x20那个部分填充的内存了。即我们可以尝试通过调整成员顺序来节约内存空间

外部对齐:考虑自身的对齐系数和系统字长。

结构体的对齐系数是其成员的最大对齐系数。我们都知道string内部由int和data数组构成,因为我的系统为64位,因此int的对齐系数是8,整个string结构体的对齐系数就是8。同理,example结构体的对齐系数是8。

空结构体的对齐

空结构体单独出现的时候,其地址为zerobase,不占长度。

但是当空结构体被其它结构体包裹时,其地址跟随前一个变量。例如我们在上述的结构体后增加一个空结构体成员,则其内存分布如图所示:

image.png

因为空结构体没有长度,如果将空结构体放到末尾,如果后面内存分配了其它的变量,是会造成内存地址重合的。所以当空结构体出现在结构体末尾时,我们需要补齐字长,如下图所示:

image.png

小结

  • 内存对齐可以提高内存操作的效率,变量之间需要内存对齐。
  • 基本数据类型需要考虑内存对齐。
  • 结构体的内存对齐分为内部对齐和外部对齐。
  • 结构体的对齐系数为其成员的最大对齐系数。
  • 空结构体作为结构体最后一个成员,需要补齐字长。