当你在编辑器里谢谢type xxx struct
时,是否想过这个结构体在内存中究竟占用多少字节呢?
那还不简单,把所有字段的字节数加起来不就好了。
朋友,如果你这样想,那就太草率了。
基本概念
由于内存对齐的原因,结构体实际占用字节数永远大于等于结构体所有字段字节数之和。没错,确实有正好相等的情况,后面我们会看到在一种什么样的机缘巧合之下他们会恰好相等。
对于结构体的每个字段,我们先来认识一下如下4个概念:
- 对齐宽度
- 本身所占字节数
- 实际占用字节数
- 偏移量
那么他们之间有什么关系呢?
对齐宽度≤本身所占字节数≤实际占用字节数。
首先来说,本身所占字节数就是类型大小。当把类型放到结构体时,它实际占用字节数是大于等于类型本身大小的,多出来的部分叫填充字节。也就是说实际占用字节数=本身所占字节数+填充字节数。
对齐宽度是类型的一种属性,他和类型本身以及操作系统有关。一般情况下,对齐宽度和类型大小是一致的。比如byte
和bool
类型的对齐宽度是1字节,int32
类型对齐宽度是4字节。那为什么对齐宽度又会小于类型大小呢?那是因为对齐宽度有一个上限,在32位系统上,对齐宽度最大为4字节,因此,即便是int64
类型,对齐宽度也是4字节,而不是8字节;相应的,在64位系统上,对齐宽度为8字节,即使是string
(本身占16字节),对齐宽度也只有8字节。因此,以下的例子都是在64位系统上的结果。
唯一的规则
废了这么多话,那么字段实际占用字节数究竟怎么计算呢?这件事说起来挺有意思,其实他和字段的偏移量有关系,并且是和他的下一个字段的偏移量有关系,而偏移量又和对齐宽度有关系。怎么说呢,规则其实是针对偏移量定义的,而实际占用字节数是通过偏移量反推出来的。
唯一的规则是:每个字段的偏移量必须是其对齐宽度的整数倍。如此一来,多出来的字节就填充到他的前一个字段里去了。举个例子:
type st struct {
a byte
b int64
}
按照规则来看,字段a
的对齐宽度为1字节,偏移量为0。字段b
的对齐宽度为8字节,在不把前面的字段覆盖掉的前提下,他的最小偏移量是8字节,于是多出来7字节的空间会补到a
字段。最终的结果就是a
、b
字段实际都占用了8字节,整个结构体共占用16字节。
幽灵出现
那么如果上面结构体中的a
和b
字段顺序反过来会怎么样呢?
type st struct {
b int64
a byte
}
根据规则,b
还是占用8字节,偏移为0。byte
类型本身占1字节,最小偏移量为8。哟嚯,偏移量刚好是本身字节的整数倍。于是a
实际占用1字节吗?非也,实际上字段a
也占用了8字节。
为什么会这样呢?
假设在a
字段后面还有一个字段c
,什么类型无所谓,但是他的对齐宽度是结构体中所有字段对齐宽度的最大值,上例中是8字节。想想会发生什么?
由于c
的存在,它的最小偏移量应该是2*8=16
字节,而b
加a
才占了9字节,于是多出来7字节被填充到a
字段里,a
最终也占了8字节。
这个不存在的c
就向一个幽灵一样跟在结构体的最后面,不如就叫他“幽灵字段”吧。你可以认为他是一个本身占0字节,但是对齐宽度等于结构体中所有字段对齐宽度最大值的一种类型,也不如就叫他“幽灵类型”吧。
在其他文章中,还会提到另一条规则:整个结构体的大小必须是所有字段最大对齐宽度的整数倍。其实仔细想想,如果对齐宽度最大的字段在结构体的最后面的话,那么有了前面的规则限制,这条规则是一定会成立的。它的存在是为了解释对齐宽度最大的字段不在最后面的情况。而一旦引入”幽灵字段“,只需要一条规则就可也解释一切了,那就是所有字段的偏移量必须是其对齐宽度的整数倍。
go在unsafe
包中提供了相应的函数用来获取对齐宽度,本身大小和偏移量。举例如下:
package main
import (
"unsafe"
"fmt"
)
type st struct {
f1 int64
f2 byte
}
func main() {
var s st
fmt.Println("st")
fmt.Println("对齐宽度:", unsafe.Alignof(s))
fmt.Println("本身大小:", unsafe.Sizeof(s))
fmt.Println("f1")
fmt.Println("对齐宽度:", unsafe.Alignof(s.f1))
fmt.Println("本身大小:", unsafe.Sizeof(s.f1))
fmt.Println("偏 移 量:", unsafe.Offsetof(s.f1))
fmt.Println("f2")
fmt.Println("对齐宽度:", unsafe.Alignof(s.f2))
fmt.Println("本身大小:", unsafe.Sizeof(s.f2))
fmt.Println("偏 移 量:", unsafe.Offsetof(s.f2))
}
/* -- 输出 --
st
对齐宽度: 8
本身大小: 16
f1
对齐宽度: 8
本身大小: 8
偏 移 量: 0
f2
对齐宽度: 1
本身大小: 1
偏 移 量: 8
*/
意义
通过上面的分析我们发现字段的排列是会影响结构体大小的,下面我们看两个例子:
type s1 struct {
f1 byte
f2 bool
f3 int16
}
type s2 struct {
f1 byte
f3 int16
f2 bool
}
我们来分析下s1
,byte
大小为1字节,偏移量为0字节;bool
大小也为1字节,最小偏移量也是1字节,于是,f1
实际占用1字节;int16
大小为2字节,最小偏移量为2字节,f1
已经占去1字节,剩下1字节刚好够f2
,于是,f2
实际也占一字节;最后,幽灵字段的对齐宽度等于f3
的对齐宽度,也就是2字节,幽灵字段的偏移量应该是2的整数倍,而f1、f2、f3
至少要用去4字节,因此幽灵字段的偏移量至少是4字节,刚好是2的两倍,因此f3
实际占用2字节。由于幽灵字段本身大小为0字节,因此结构体最终占用4字节,和各个字段本身大小之和刚好相等。
同样的方式来分析s2
,f1
不用说了,看f3
,int16
对齐宽度为2字节,因此f3
至少偏移2字节,而byte
本身之有1字节,因此被填充1字节,实际占用2字节;f2
偏移至少是4字节,到目前为止,f1、f3、f2
至少要用去5字节,而幽灵字段对齐宽度是2字节,偏移至少是6字节,因此f2
也被填充了1字节,实际占用2字节;整个结构体占用6字节。
由此可见,合理的排布结构体字段能减少填充字节和结构体占用空间大小,提高结构体的空间利用率。那么该如何排布字段才是最合理的呢?无他,唯手熟尔…呃,不对,是按类型大小从小到大排就能得到最佳的空间利用。暂时没想到什么办法证明。
那么知道了这些又有什么用呢?其实没什么用。试想一下,假如一个100字节的结构体,这已经算是大型结构体了,空间利用率为50%。如果通过字段重排可以将利用率提升到100%,那么每个结构体可以节省50字节的空间。为了方便计算,我们按1K=1000字节来计算。20个结构体能省下1K的空间,两万个结构体能省下1M的空间,两千万个结构体才能省下1G空间。在绝大多数情况下可能都达不到千万的量级,因此并没有必要太过于纠结结构体字段的排列。因为几十兆的空间对于你的电脑来说真的不算什么,除非你是在板子上编程。
虽然说纠结结构体字段排列没什么必要,是精益求精应该是一个程序员的基本素养。所以我的建议是书写结构体时,把将字段按从小到大排列做为一种习惯。强迫症表示,终于找到理由解释了。
工具
虽然我们总结出了一条规则来分析结构体各个字段实际占用空间大小,但程序员表示,能写代码分析的事情从来不应该人工分析。于是我写了一个程序来分析go结构体各个字段的空间占用和利用率,虽然还相当不成熟,也算是学习过程中的一点收获吧,说实话怎么展示结果让我很苦恼。下面是gitee地址。