引入问题
type Part1 struct {
a bool
b int32
c int8
d int64
e byte
}
首先先算一下part1占用的内存是多少?
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
可以看到最终输出Part1其实是占用32字节,与计算得到的15不一致,为什么呢?
那其实这里就要提到内存对齐这个概念。
内存对齐
首先要了解一点,内存读取的方式
上图表示按字节一个一个读取的方式,如果是这种读取方式,那我们计算的part1的长度应该是对的;但实际上CPU读取内存是一块一块读取的,块的大小可以是2,4,8,16字节等大小。这样的块大小称之为内存访问粒度。如下:
在这里,假设内存访问粒度为4。所以CPU应该是以4个字节大小的内存访问力度去读取和访问内存的。
什么时候需要用到内存对齐
- 你正在编写的代码在性能(CPU、Memory)方面有性能要求
- 你正在处理向量方面的指令
- 某硬件平台(如 ARM)体系不支持未对齐的内存访问
为什么要用内存对齐
- 平台(移植):不是所有的平台都支持访问任意地址上的任意数据,如上面提到的ARM;
- 性能:访问未对齐的内存,会导致CPU 进行二次访问内存,并需要花额外的时间来处理对齐及运算。而对齐的内存仅需要一次访问就可以完成读写操作;
上图中虚线为内存的访问边界,如果从Index1开始读取,会导致访问边界是不对齐的,因此CPU会做一下额外处理:
- CPU 首次读取未对齐地址的第一个内存块,读取 0-3 字节。并移除不需要的字节 0
- CPU 再次读取未对齐地址的第二个内存块,读取 4-7 字节。并移除不需要的字节 5、6、7 字节
- 合并 1-4 字节的数据
- 合并后放入寄存器
可见,不做内存对齐,是比较麻烦CPU的事,自然耗时就增加了;
而如果是做了内存对齐,上图从Index0开始读取,只需要读取一次也不需要额外的运算,提高了效率,达到空间换时间的目的。
默认对齐系数
不同的编译器默认系数不同,可使用预编译命令*#program 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.Aligof
来返回相应类型的对齐系数,基本上都是2^n
,最大不超过8,因为当前64位编译器默认对齐系数是8;
整体对齐
成员变量需要做字节对齐,那么最终得到的结构体肯定也需要做字节 对齐的;
对齐规则
- 结构体的成员变量,第一个偏移量为0,以后每一个成员变量的对齐值必须是编译器的 默认对齐系数(
#pragma pack(n)
)或者当前成员变量类型的长度(unsafe.Sizeof
),取最小值作为当前类型的对齐值,偏移量为对齐值的整数倍。 - 结构体本身,对齐值必须为编译器默认对齐长度(
#pragma pack(n)
)或结构体的所有成员变量类型中的最大长度,取最大数的最小整数倍作为对齐值 - 结合以上两点,可得知若编译器默认对齐长度(
#pragma pack(n)
)超过结构体内成员变量的类型最大长度时,默认对齐长度是没有任何意义的.
对齐分析
结构体*part1*
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
成员变量 | 类型 | 偏移量 | 自身占用 |
---|---|---|---|
a | bool | 0 | 1 |
字节对齐 | 无 | 1 | 3 |
b | int32 | 4 | 4 |
c | int8 | 8 | 1 |
字节对齐 | 无 | 9 | 7 |
d | int64 | 16 | 8 |
e | byte(uint8) | 24 | 1 |
字节对齐 | 无 | 25 | 7 |
P1成员对齐:
- a类型为bool,第一个变量偏移量0,占用1个字节。
- b类型为int32,本身占用4个字节,根据规则一,偏移量为4,所以2-4的位置被padding(填补),axxx|bbbb。
- c类型为int8,本身占1个字节,此时偏移量为8,c就在第9的位置,axxx|bbbb|cxxx|xxxx。
- d为int64,占用8个字节,根据规则一,此时应偏移16,axxx|bbbb|cxxx|xxxx|dddd|dddd。
- e为byte类型。占用1个字节,此时偏移量为24,e在25的位置,axxx|bbbb|cxxx|xxxx|dddd|dddd|e。
P1整体对齐
最后,变量对齐后,根据规则二,结构体本身也需要字节对齐,对齐值为8,此时为偏移为25,并不是8的整数倍,所以确定偏移量为32,对结构体对齐,结果为axxx|bbbb|cxxx|xxxx|dddd|dddd|exxx|xxxx。
结构体*part2*
type Part2 struct {
e byte
c int8
a bool
b int32
d int64
}
func main() {
part2 := Part2{}
fmt.Printf("part2 size: %d, align: %d\n", unsafe.Sizeof(part2), unsafe.Alignof(part2))
}
输出结果:
part2 size: 16, align: 8
成员变量 | 类型 | 偏移量 | 自身占用 |
---|---|---|---|
e | byte | 0 | 1 |
c | int8 | 1 | 1 |
a | bool | 2 | 1 |
字节对齐 | 无 | 3 | 1 |
b | int32 | 4 | 4 |
d | int64 | 8 | 8 |
P2成员对齐:
- e类型为byte,第一个变量偏移量0,占用1个字节。
- c类型为int8,本身占用1个字节,偏移量为1,满足规则一,如下:ec。
- a类型为bool,本身占1个字节,此时偏移量为2,c就在第3的位置,eca。
- b为int32,占用4个字节,根据规则一,此时应偏移4,4的位置被padding,如下:ecax|bbbb。
- d为int64类型。占用8个字节,此时偏移量为8,满足规则,不许额外补齐,即:ecax|bbbb|dddd|dddd
P2整体对齐
变量对齐后,符合规则二,即结构体part2
的 内存占用就是16。
总结
- 通过分析,可以发现之前推算的15确实是错误的,内存读取并非一个字节一个字节取得,而是一块一块的,通过空间换时间的思想来完成内存的读写。
- 调整结构体内变量的字段顺序可以减少结构体占用内存大小,是巧妙的规避了padding的存在,让变量存放更加"紧凑",这一点对于加深Go的内存布局和大对象的优化很有帮助!!