以golang角度,不同语言编译器有不同表现

一个例子

go 官方issue - what the size of the struct si 40?

以下输出什么?

type S struct {
  A uint32
  B uint64
  C uint64
  D uint64
  E struct{}
}

func main() {
  fmt.Println(unsafe.Offsetof(S{}.E))
  fmt.Println(unsafe.Sizeof(S{}.E))
  fmt.Println(unsafe.Sizeof(S{}))x
}

字的概念

这里有几种不同的释义,对应不同的场景

  1. cpu架构上的处理器『字』, 即寄存器的大小,指针的大小,32位处理器对应的字为32位,64位对应的为64位。 这里有对应半字的概念。
  2. 软硬件层面上与cpu架构一致
  3. win32 API 大写的 WORD,一定为 16bit,即2字节
  4. Intel/AMD 指令集的字表示 16bit。但在32bit下 dword(双字)=32bit qword(四字)=32bit,64bit下 dword(双字)=32bit qword(四字)=64bit

这里可以引申下为什么32位程序能在64位上机器跑? 然后64位程序在32位上机器跑会出现什么现象?

NOTE: 为什么指令集的字为16bit, 是由于从16位体系结构扩展成32位,字沿用下来。 在汇编指令中带有对应后缀 b, w, l, q,表示不同的数据类型做区分。

  • b: byte, 字节
  • w: word, 字
  • l: double world, 双字。为什么是简称l?
  • q: quard word, 四字

什么是内存对齐

内存对齐,简单解释就是 address/size=0, 即数据大小能被地址整除。 不能整除的则按0补充到满足位数。

为什么要内存对齐

比较典型的空间换取时间

cpu处理数据时,一次性读取一个字长。

内存对齐至少有以下几点优势

1. 为了速度

cpu读取数据问题以一个 字长 为单位,所有内置变量(非复合类型如 struct )定义的类型都不会超过一个字长。如果使用非对齐方式,那么处理器需要读取1-2次,看数据类型如( byteint 的结构体),当要读取 int 数据就需要2次操作(还要合并两次操作的位数)。

2. 原子性

很简单,对齐的话一个指令就能读取数据,非对应需要1-2次,即1-2个指令。所以一个指令肯定能保证原子性。

3. 缓存一致性

现代处理器都有多级缓存,这里涉及缓存一致性(计算机非常大的痛点,速度与一致性不可完全兼顾)。

与原子性类型,非对齐需要1-2个指令,当cpu时间片切换时间会存在数据不一致情况。

go 的数据类型大小与对齐保障

Size and alignment guarantees

如下表格所示:

type size in bytes alignment guarantees
byte, uint8, int8 1 1
uint16, int16 2 2
uint32, int32, float32, rune 4 4
uint64, int64, float64, complex64 8 8
complex128 16 8
int, uint, uintprt w - 字,跟随系统(cpu架构)位数 跟随系统
string 2w - 双字 跟随系统
slice 3w - 3字 跟随系统
interface 2w - 双字 跟随系统
struct{}, [0]T{} 0 0

官方对(数字)数据类型最低对齐保证

  1. 对于任何类型的变量 xunsafe.Alignof(x) 的结果最小为1
  2. 对于一个结构体类型的变量 xunsafe.Alignof(x) 的结果为 x 的所有字段中最大定义字段值(字段占用大小)
  3. 对于一个数据类型的变量 xunsafe.Alignof(x) 的结果元素的变量的值(占用大小)

NOTE: 特别注意,空结构体和空数组占用都为0(ziro-size),官方说明可能会使用同个地址

再看例子

以上例子输出为

32
0
40

这里大家是不是很疑惑,既然空结构体占用大小为0,为什么总大小确是40?这是空结构体的特殊场景, 作为最后一个字段需要使用0填充做对齐。 如果不填充会发生什么? 很简单,如果不填充,使用这个空结构体的指针会发生指向其他数据段而后发生段错误( segment fault )。

再谈空结构体例子

type S1 struct {  
   A struct{}  
   B int32  
}  
  
type S2 struct {  
   B int32  
   A struct{}  
}  
  
type S3 struct {  
   A struct{ X int }  
   B int32  
}  
  
func main() {  
   s1 := S1{}  
   s2 := S2{}  
   s3 := S3{}  
   fmt.Printf("s1 SizeOf(s1)=%d SizeOf(s1.A)=%d SizeOf(s1.B)=%d &s1=%p &s1.A=%p &s1.B=%p\n", unsafe.Sizeof(s1), unsafe.Sizeof(s1.A), unsafe.Sizeof(s1.B), &s1, &s1.A, &s1.B)  
   fmt.Printf("s2 SizeOf(s2)=%d SizeOf(s2.A)=%d SizeOf(s2.B)=%d &s2=%p &s2.A=%p &s2.B=%p\n", unsafe.Sizeof(s2), unsafe.Sizeof(s2.A), unsafe.Sizeof(s2.B), &s2, &s2.A, &s2.B)  
   fmt.Printf("s3 SizeOf(s3)=%d SizeOf(s3.A)=%d SizeOf(s3.B)=%d &s3=%p &s3.A=%p &s3.B=%p\n", unsafe.Sizeof(s3), unsafe.Sizeof(s3.A), unsafe.Sizeof(s3.B), &s3, &s3.A, &s3.B)  
}

//Oouput:
//s1 SizeOf(s1)=4 SizeOf(s1.A)=0 SizeOf(s1.B)=4 &s1=0xc0000b2004 &s1.A=0xc0000b2004 &s1.B=0xc0000b2004
//s2 SizeOf(s2)=8 SizeOf(s2.A)=0 SizeOf(s2.B)=4 &s2=0xc0000b2008 &s2.A=0xc0000b200c &s2.B=0xc0000b2008
//s3 SizeOf(s3)=16 SizeOf(s3.A)=8 SizeOf(s3.B)=4 &s3=0xc0000b2010 &s3.A=0xc0000b2010 &s3.B=0xc0000b2018


这个例子完输出可以对比下, 以下总结空结构体的填充,空数组一样。

  1. 空结构体放在最后一个字段需要使用0填充,如果上一个字段非对齐,则可以一起对齐
  2. 空结构体非最后一个字段,则地址为下一个字段的地址,编译器优化处理了
  3. 有字段的结构体,当是默认值时,地址与空结构体相同处理, 编译器优化处理了

重排优化

知道了内存对齐,能做什么呢? 看以下例子, 调整字段的位置

type S1 struct {  
   A uint32  
   B uint64  
   C uint64  
   D uint64  
   E struct{}  
}  
type S2 struct {  
   E struct{}  
   A uint32  
   B uint64  
   C uint64  
   D uint64  
}  
  
func main() {  
   fmt.Println(unsafe.Sizeof(S1{}))  
   fmt.Println(unsafe.Sizeof(S2{}))  
}

//Output:
//40
//32

如果有其他小数据类型的字段呢?

type S1 struct {  
   A uint32  
   B uint64  
   F int8  
   C uint64  
   G int8  
   D uint64  
   E struct{}  
}  
type S2 struct {  
   E struct{}  
   F int8  
   G int8  
   A uint32  
   B uint64  
   C uint64  
   D uint64  
}  
  
func main() {  
   fmt.Println(unsafe.Sizeof(S1{}))  
   fmt.Println(unsafe.Sizeof(S2{}))  
}

//Output:
//56
//32

引申扩展

1. set的实现

go 是没有 set 的数据结构,只能使用 map 替代方式,都是基于 hash 表, 其中 map 的值自己定义,一般人的 value 值为 1,0,true,... 等,大家想想有没更好的方式来做值呢? 答案是肯定有的, 就是 空struct,占用0字节大小,即使填充了,编译器也会优化使用同一个内存地址。

type _set map[string]struct{}  
  
func main() {  
   set := make(_set)  
   set["a"] = struct{}{}  
   a, _ := set["a"]  
     
   set["b"] = struct{}{}  
   b, _ := set["b"]  
     
   set["c"] = struct{}{}  
   c, _ := set["c"]  
   fmt.Printf("&a=%p &b=%p &c=%p", &a, &b, &c)  
}
//Output:
//&a=0x1165060 &b=0x1165060 &c=0x1165060

2. go的内存模型

还是以第一个例子为例, 连续声明两个 S 的变量, 其各自地址会是什么?

type S struct {  
   A uint32  
   B uint64  
   C uint64  
   D uint64  
   E struct{}  
}  
  
func main() {  
   var s1, s2 S  
   fmt.Printf("s1 SizeOf(s1)=%d &s1=%p\n", unsafe.Sizeof(s1), &s1)  
   fmt.Printf("s2 SizeOf(s2)=%d &s2=%p\n", unsafe.Sizeof(s2), &s2)  
   fmt.Printf("&s2(%p)-&s1(%p)=%d\n", &s2, &s1, uintptr(unsafe.Pointer(&s2))-uintptr(unsafe.Pointer(&s1)))  
}

//Output:
//s1 SizeOf(s1)=40 &s1=0xc000022090
//s2 SizeOf(s2)=40 &s2=0xc0000220c0
//&s2(0xc0000220c0)-&s1(0xc000022090)=48

以上输出是否是预期的? S 大小是40字节,但s2的地址确是在48字节的偏移量上?这是为什么?

其实这里涉及到了 go 的内存模式,在 src/runtime/sizeclasses.go 中可以看到定义,申请40字节的内存空间,实际是分配了48字节的内存空间,8字节相当于是浪费的。 总之,任何的内存模型都做不到完全利用,都是存在各种内存碎片。