详解 Go 内存对齐

300次阅读  |  发布于3年以前

前言

今天我们来聊一聊一道常见的面试八股文——内存对齐,我们平常在业务开发中根本不care内存对齐,但是在面试中,这就是一个高频考点,今天我们就一起来看一看到底什么是内存对齐。

前情概要

在了解内存对齐之前,先来明确几个关于操作系统的概念,更加方面我们对内存对齐的理解。

写在最后的一个知识点:

计算机中,最小的存储单元为字节,理论上任意地址都可以通过总线进行访问,每次寻址能传输的数据大小就跟CPU位数有关。常见的CPU位数有8位,16位,32位,64位。位数越高,单次操作执行的数据量越大,性能也就越强。os的位数一般与CPU的位数相匹配,32CPU可以寻址4GB内存空间,也可以运行32位的os,同样道理,64位的CPU可以运行32位的os,也可以运行64位的os

何为内存对齐

以下内容来源于网络总结:

现代计算机中内存空间都是按照字节(byte)进行划分的,所以从理论上讲对于任何类型的变量访问都可以从任意地址开始,但是在实际情况中,在访问特定类型变量的时候经常在特定的内存地址访问,所以这就需要把各种类型数据按照一定的规则在空间上排列,而不是按照顺序一个接一个的排放,这种就称为内存对齐,内存对齐是指首地址对齐,而不是说每个变量大小对齐。

为何要有内存对齐

主要原因可以归结为两点:

假设当前CPU32位的,并且没有内存对齐机制,数据可以任意存放,现在有一个int32变量占4byte,存放地址在0x00000002 - 0x00000005(纯假设地址,莫当真),这种情况下,每次取4字节的CPU第一次取到[0x00000000 - 0x00000003],只得到变量1/2的数据,所以还需要取第二次,为了得到一个int32类型的变量,需要访问两次内存并做拼接处理,影响性能。如果有内存对齐了,int32类型数据就会按照对齐规则在内存中,上面这个例子就会存在地址0x00000000处开始,那么处理器在取数据时一次性就能将数据读出来了,而且不需要做额外的操作,使用空间换时间,提高了效率。

没有内存对齐机制:

内存对齐后:

对齐系数

每个特定平台上的编译器都有自己的默认"对齐系数",常用平台默认对齐系数如下:

这只是默认对齐系数,实际上对齐系数我们是可以修改的,之前写C语言的朋友知道,可以通过预编译指令#pragma pack(n)来修改对齐系数,因为C语言是预处理器的,但是在Go语言中没有预处理器,只能通过tags命名约定来让Go的包可以管理不同平台的代码,但是怎么修改对齐系数,感觉Go并没有开放这个参数,找了好久没有找到,等后面再仔细看看,找到了再来更新!

既然对齐系数无法更改,但是我们可以查看对齐系数,使用Go语言中的unsafe.Alignof可以返回相应类型的对齐系数,使用我的mac(64位)测试后发现,对齐系数都符合2^n这个规律,最大也不会超过8

func main()  {
 fmt.Printf("string alignof is %d\n", unsafe.Alignof(string("a")))
 fmt.Printf("complex128 alignof is %d\n", unsafe.Alignof(complex128(0)))
 fmt.Printf("int alignof is %d\n", unsafe.Alignof(int(0)))
}
运行结果
string alignof is 8
complex128 alignof is 8
int alignof is 8

注意:不同硬件平台占用的大小和对齐值都可能是不一样的。

结构体的内存对齐规则

一提到内存对齐,大家都喜欢拿结构体的内存对齐来举例子,这里要提醒大家一下,不要混淆了一个概念,其他类型也都是要内存对齐的,只不过拿结构体来举例子能更好的理解内存对齐,并且结构体中的成员变量对齐有自己的规则,我们需要搞清这个对齐规则。

C语言的对齐规则与Go语言一样,所以C语言的对齐规则对Go同样适用:

举个例子

根据上面的对齐规则,我们来分析一个例子,加深理解:

// 64位平台,对齐参数是8
type User struct {
 A int32 // 4
 B []int32 // 24
 C string // 16
 D bool // 1
}

func main()  {
 var u User
 fmt.Println("u1 size is ",unsafe.Sizeof(u))
}
// 运行结果
u size is  56

这里我的mac64位的,对齐参数是8int32[]int32stringbool对齐值分别是4881,占用内存大小分别是424161,我们先根据第一条对齐规则分析User

根据第一条规则分析后,现在结构所占大小为49字节,我们再来根据第二条规则分析:

成员变量顺序对内存对齐带来的影响

根据上面的规则我们可以看出,成员变量的顺序也会影响内存对齐的结果,我们先来看一个例子:

type test1 struct {
 a bool // 1
 b int32 // 4
 c string // 16
}

type test2 struct {
 a int32 // 4
 b string // 16
 c bool // 1
}


func main()  {
 var t1 test1
 var t2 test2

 fmt.Println("t1 size is ",unsafe.Sizeof(t1))
 fmt.Println("t2 size is ",unsafe.Sizeof(t2))
}

运行结果:

t1 size is  24
t2 size is  32

test1的内存布局:

test2的内存布局:

)

通过以上分析,我们可以看出,结构体中成员变量的顺序会影响结构体的内存布局,所以在日常开发中大家要注意这个问题,可以节省内存空间。

空结构体字段对齐

Go语言中空结构体的大小为0,如果一个结构体中包含空结构体类型的字段时,通常是不需要进行内存对齐的,举个例子:

type demo1 struct {
 a struct{}
 b int32
}

func main()  {
 fmt.Println(unsafe.Sizeof(demo1{}))
}
运行结果:
4

从运行结果可知结构体demo1占用的内存与字段b占用内存大小相同,所以字段a是没有占用内存的,但是空结构体有一个特例,那就是当 struct{} 作为结构体最后一个字段时,需要内存对齐。因为如果有指针指向该字段, 返回的地址将在结构体之外,如果此指针一直存活不释放对应的内存,就会有内存泄露的问题(该内存不因结构体释放而释放),所以当struct{}作为结构体成员中最后一个字段时,要填充额外的内存保证安全。

type demo2 struct {
 a int32
 b struct{}
}

func main()  {
 fmt.Println(unsafe.Sizeof(demo2{}))
}
运行结果:
8

考虑内存对齐的设计

在之前的文章[源码剖析sync.WaitGroup] 分析sync.waitgroup的源码时,使用state1来存储状态:

// A WaitGroup must not be copied after first use.
type WaitGroup struct {
 noCopy noCopy

 // 64-bit value: high 32 bits are counter, low 32 bits are waiter count.
 // 64-bit atomic operations require 64-bit alignment, but 32-bit
 // compilers do not ensure it. So we allocate 12 bytes and then use
 // the aligned 8 bytes in them as state, and the other 4 as storage
 // for the sema.
 state1 [3]uint32
}

state1这里总共被分配了12个字节,这里被设计了三种状态:

提供了(wg *WaitGroup) state() (statep *uint64, semap *uint32)帮助我们从state1字段中取出他的状态和信号量,为什么要这样设计呢?

因为64位原子操作需要64位对齐,但是32位编译器不能保证这一点,所以为了保证waitGroup32位平台上使用的话,就必须保证在任何时候,64位操作不会报错。所以也就不能分成两个字段来写,考虑到字段顺序不同、平台不同,内存对齐也就不同。因此这里采用动态识别当前我们操作的64位数到底是不是在8字节对齐的位置上面,我们来分析一下state方法:

// state returns pointers to the state and sema fields stored within wg.state1.
func (wg *WaitGroup) state() (statep *uint64, semap *uint32) {
 if uintptr(unsafe.Pointer(&wg.state1))%8 == 0 {
  return (*uint64)(unsafe.Pointer(&wg.state1)), &wg.state1[2]
 } else {
  return (*uint64)(unsafe.Pointer(&wg.state1[1])), &wg.state1[0]
 }
}

当数组的首地址是处于一个8字节对齐的位置上时,那么就将这个数组的前8个字节作为64位值使用表示状态,后4个字节作为32位值表示信号量(semaphore)。同理如果首地址没有处于8字节对齐的位置上时,那么就将前4个字节作为semaphore,后8个字节作为64位数值。画个图表示一下:

)

总结

终于接近尾声了,内存对齐一直面试中的高频考点,通过内存对齐可以了解面试者对操作系统知识的了解程度,所以这块知识还是比较重要的,希望这篇文章能帮助大家答疑解惑,更好的忽悠面试官~。

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8