写在文章开头
我们都知道数据加载到CPU
缓存中可以提升执行性能,所以为了保证每一个结构体中的成员能够完整的被单个CPU
核心加载以避免缓存一致性问题而提出内存对齐
,这篇文章笔者会从go语言的角度来讨论这个优化机制。
Hi,我是 sharkChili ,是个不断在硬核技术上作死的 java coder ,是 CSDN的博客专家 ,也是开源项目 Java Guide 的维护者之一,熟悉 Java 也会一点 Go ,偶尔也会在 C源码 边缘徘徊。写过很多有意思的技术博客,也还在研究并输出技术的路上,希望我的文章对你有帮助,非常欢迎你关注我的公众号: 写代码的SharkChili 。
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。
详解go语言中的内存对齐
内存填充代码示例
我们不妨举个例子,我们现在声明一个Num
结构体并通过Sizeof
获取其大小:
type Num struct {
num1 int32
num2 int32
}
func main() {
// 打印字节大小
fmt.Println("Num bytes:", unsafe.Sizeof(Num{}))
}
输出结果为8字节,很明显两个32位的整型变量相加就是8字节:
Num bytes: 8
我们再来看点神奇的,我们将num1
改为int16
,再次进行打印,理论上2byte+4byte
最终输出应该是6byte
:
type Num struct {
num1 int16
num2 int32
}
func main() {
// 打印字节大小
fmt.Println("Num bytes:", unsafe.Sizeof(Num{}))
}
但输出结果确是8byte
,这是就是因为底层填充了2byte的内存空间:
Num bytes: 8
详解内存对齐
我们假设Num
未进行内存填充的Num
结构体在内存分配为struct-2
,在它的地址空间前方有一个struct-1
,在多核CPU
的64位操作系统下
,数据都以一个字长即64bit
加载,这就很可能导致一个完整的变量存在与不同的CPU核心
中。
我们假设一种情况CPU-1
操作struct-1
,因为字长的原因导致加载数据时把struct-2
的数据加载到CPU-1缓存中。同时CPU-2 Cache
处理struct-2
的业务逻辑,因为MESI
协议,导致CPU-1
中任何一个改动都会使得CPU-2缓存中的数据变成脏数据,出现缓存一致性问题。
考虑到这个问题,go语言
便在struct-1
空间的结尾填充了18bit
使得内存空间占满1个字长,保证每一个变量都能通过一个字长的单位读取到:
内存对齐的工作机制
在进行不同的内存填充的时候,不同类型变量都着不同的对齐系数,例如布尔和int32对应的内存系统为1和4,以下图为例,布尔的对齐系统为1就意味着它的内存空间首地址能被1整除,所以我们分配为0x00
,同理因为0x00
被布尔占用,所以int32
的内存空间地址分配到0x04
,基于对齐系数这一计算可以确保了两个变量完整的占用了一个字长,且加载时能够保证每个字长加载的变量都是完整的,从而保证内存原子性:
// 不同类型对应的对齐系数
fmt.Println("类型:", unsafe.Sizeof(false), " 对齐系数:", unsafe.Alignof(false))
fmt.Println("类型:", unsafe.Sizeof(int32(1)), " 对齐系数:", unsafe.Alignof(int32(1)))
fmt.Println("类型:", unsafe.Sizeof("hello"), " 对齐系数:", unsafe.Alignof("hello"))
对应输出结果:
类型: 1 对齐系数: 1
类型: 4 对齐系数: 4
类型: 16 对齐系数: 8
结构体中的内存对齐
我们再来看看结构体中对于内存对齐的使用,我们给出下面这段代码示例:
type Obj struct {
b bool
str string
num int16
}
func main() {
//输出其字节数
fmt.Println(unsafe.Sizeof(Obj{}))
}
这段代码输出结果为32
字节,原因很简单,bool为1字节,填充首位。然后string
为2字节即(16bit)对应对齐系数为8,所以占用0x08
到0x24
的内存空间,最后int16
对齐系数为2,于是从0x26开始填充2字节,即占用0x26
到0x28
,最后补齐剩余的4个字节空间,由此得出32字节
:
这明显因为变量排序不当导致bool
和string
之间空出了很多的内存空间,所以我们不妨将后面两个变量的顺序调换一下:
type Obj struct {
b bool
num int16
str string
}
func main() {
fmt.Println(unsafe.Sizeof(Obj{}))
}
最终输出结果变为24字节
,因为int16
对齐系统为2,所以bool之后空1格就可以完成对齐,随后移动4格保证字符串类型对齐,由此算出总空间为24字节
,确保bool
和int16
用1个字长(64bit)
,然后字符串用2个字长
完成加载:
空结构体内存对齐问题
基于上述的例子,我们在结构体末尾加上1个空结构体:
type Obj struct {
b bool
num int16
str string
i struct{}
}
func main() {
//输出其字节数
fmt.Println(unsafe.Sizeof(Obj{}))
}
最终输出结果为32
,我们都知道空值默认都用zero-base,为了保证空结构体的地址空间不被其他成员错误利用,go语言
会针对这种情况对结构体尾部进行一个内存填充,确保地址空间大小为32字节(字长的整数倍)
:
小结
本文通过几个简单的示例结合图解介绍了go语言如何通过内存填充的方式解决内存原子性以及缓存一致性问题,我们来简单小结一下内存填充的几个要点:
- 通过对齐系数决定变量首地址值。
- 空结构体结尾需要填充空间避免地址复用异常。
- 整个结构体填充完成后需要保证是字长(64bit)的整数倍。
我是 sharkchili ,CSDN Java 领域博客专家,开源项目—JavaGuide contributor,我想写一些有意思的东西,希望对你有帮助,如果你想实时收到我写的硬核的文章也欢迎你关注我的公众号: 写代码的SharkChili 。
因为近期收到很多读者的私信,所以也专门创建了一个交流群,感兴趣的读者可以通过上方的公众号获取笔者的联系方式完成好友添加,点击备注 “加群” 即可和笔者和笔者的朋友们进行深入交流。
参考
CPU 缓存一致性:https://xiaolincoding.com/os/1_hardware/cpu_mesi.html#cpu-cache-的数据写入