在 Go 语言中,正常的 struct 就是一个普通的内存块,必定是要占用一块内存的,并且结构体的大小是要经过边界,长度是对其的。

当你需要一个结构体,但是却丝毫不关系里面的内容,声明一个最小的结构体,也需要占用 1 字节,这是就要用到今天的主角——空结构体了。

“空结构体”是不占内存的,size 为 0;运行下面的代码可以得出,空结构体是不占用内存的。

1
2
3
4
5
6
7
8
9
10
package main

import (
"fmt"
"unsafe"
)

func main() {
fmt.Println(unsafe.Sizeof(struct{}{}))
}

本质上来讲,使用空结构体的初衷只有一个:节省内存,但是更多的情况,节省的内存其实很有限,这种情况使用空结构体的考量其实是:根本不关心结构体变量的值

什么原理

特殊变量:zerobase

空结构体时没有内存大小的结构体。这句话是没有错,但是更准确的来说,其实是有一个特殊起点的,那就是 zerobase 变量,这是一个 uintptr 全局变量,占用 8 个字节。

当在任何地方定义无数个 struct {} 类型的变量,编译器都只是把这个 zerobase 变量的地址给出去。换句话说,在 GO 里面,涉及到所有内存 size 为 0 的内存分配,那么就是用的同一个地址 &zerobase

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package main

import "fmt"

type emptyStruct struct{}

func main() {
a := struct{}{}
b := struct{}{}
c := emptyStruct{}

fmt.Printf("%p\n", &a)
fmt.Printf("%p\n", &b)
fmt.Printf("%p\n", &c)
}
1
2
3
4
$ go run main.go
0x57bb60
0x57bb60
0x57bb60

无论如何定义空结构体,其变量都是一样的。

内存特殊管理

编译器在编译期间,识别到 struct {} 这种特殊类型的内存分配,会统统分配出 runtime.zerobase 的地址出去,这个代码逻辑是在 mallocgc 函数里面:

代码如下:

1
2
3
4
5
6
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
// 分配 size 为 0 的结构体,把全局变量 zerobase 的地址给出去即可;
if size == 0 {
return unsafe.Pointer(&zerobase)
}
// ...

golang 使用 mallocgc 分配内存的时候,如果 size 为 0 的时候,统一返回的都是全局变量 zerobase 的地址。

有这种全局唯一的特殊的地址也方便后面一些逻辑的特殊处理。

定义的各种姿势

原生定义

1
a := struct{}{}

struct{} 可以就认为是一种类型,a 变量就是 struct {} 类型的一种变量,地址为 runtime.zerobase ,大小为 0 ,不占内存。

重定义类型

golang 使用 type 关键字定义新的类型,比如:

1
type emptyStruct struct{}

定义出来的 emptyStruct 是新的类型,具有对应的 type 结构,但是性质 struct{} 完全一致,编译器对于 emptryStruct 类型的内存分配,也是直接给 zerobase 地址的。

匿名嵌套类型

struct{} 作为一个匿名字段,内嵌其他结构体。这种情况是怎么样的?

匿名嵌套方式一

1
2
3
4
type emptyStruct struct{}
type Object struct {
emptyStruct
}

匿名嵌套方式二

1
2
3
type Object1 struct {
_ struct {}
}

记住一点,空结构体还是空结构体,类型变量本身绝对不分配内存( size=0 ),所以编译器对以上的 ObjectObject1 两种类型的处理和空结构体类型是一致的,分配地址为 runtime.zerobase 地址,变量大小为0,不占任何内存大小。

内置字段

内置字段的场景没有什么特殊的,主要是地址和长度的对齐要考虑。还是只需要注意 3 个要点:

  • 空结构体的类型不占内存大小;
  • 地址偏移要和自身类型对齐;
  • 整体类型长度要和最长的字段类型长度对齐;

我们分 3 种场景讨论这个问题:

场景一:struct {} 在最前面

这种场景非常好理解,struct {} 字段类型在最前面,这种类型不占空间,所以自然第二个字段的地址和整个变量的地址一致。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
go复制代码// Object1 类型变量占用 1 个字节
type Object1 struct {
s struct {}
b byte
}

// Object2 类型变量占用 8 个字节
type Object2 struct {
s struct {}
n int64
}

o1 := Object1{ }
o2 := Object2{ }

内存怎么分配?

  • &o1&o1.s 是一致的,变量 o1 的内存大小对齐到 1 字节;
  • &o2&o2.s 是一致的,变量 o2 的内存大小对齐到 8 字节;

这种分配是满足对齐规则的,编译器也不会对这种 struct {} 字段做任何特殊的字节填充。

场景二:struct {} 在中间

1
2
3
4
5
6
7
8
// Object1 类型变量占用 16 个字节
type Object1 struct {
b byte
s struct{}
b1 int64
}

o1 := Object1{ }
  • 按照对齐规则,变量 o1 占用 16 个字节;
  • &o1.s&o1.b1 相同;

编译器不会对 struct { } 做任何字节填充。

场景三:struct {} 在最后

这个场景稍微注意下,因为编译器遇到之后会做特殊的字节填充补齐,如下;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
go复制代码type Object1 struct {
b byte
s struct{}
}

type Object2 struct {
n int64
s struct{}
}

type Object3 struct {
n int16
m int16
s struct{}
}

type Object4 struct {
n int16
m int64
s struct{}
}

o1 := Object1 { }
o2 := Object2 { }
o3 := Object3 { }
o4 := Object4 { }

编译器在遇到这种 struct {}最后一个字段的场景,会进行特殊填充,struct { } 作为最后一个字段,会被填充对齐到前一个字段的大小,地址偏移对齐规则不变;

可以现在心里思考下,o1o2o3o4 这四个对象的内存分配分别占多少空间?下面解密:

  • 变量 o1 大小为 2 字节;
  • 变量 o2 大小为 16 字节;
  • 变量 o3 大小为 6 字节;
  • 变量 o4 大小为 24 字节;

这种情况,需要先把 struct {} 按照前一个字段的长度分配 padding 内存,然后整个变量按照地址和长度的对齐规则不变。

空结构体的作用

因为空结构体不占据内存空间,因此被广泛作为各种场景下的占位符使用。一是节省资源,二是空结构体本身就具备很强的语义,即这里不需要任何值,仅作为占位符。

实现 Set

Go 语言标准库没有提供 Set 的实现,通常使用 map 来代替。事实上,对于集合来说,只需要 map 的键,而不需要值。即使是将值设置为 bool 类型,也会多占据 1 个字节,那假设 map 中有一百万条数据,就会浪费 1MB 的空间。

因此呢,将 map 作为集合(Set)使用时,可以将值类型定义为空结构体,仅作为占位符使用即可。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
type Set map[string]struct{}

func (s Set) Has(key string) bool {
_, ok := s[key]
return ok
}

func (s Set) Add(key string) {
s[key] = struct{}{}
}

func (s Set) Delete(key string) {
delete(s, key)
}

func main() {
s := make(Set)
s.Add("Tom")
s.Add("Sam")
fmt.Println(s.Has("Tom"))
fmt.Println(s.Has("Jack"))
}

不发送数据的 channel

有时候使用 channel 不需要发送任何的数据,只用来通知子协程(goroutine)执行任务,或只用来控制协程并发度。这种情况下,使用空结构体作为占位符就非常合适了。struct{} 通常作为一个信号来传输,并不关注其中内容。channel 本质的数据结构是一个管理结构加上一个 ringbuffer ,如果 struct{} 作为元素的话,ringbuffer 就是 0 分配的。

1
2
3
4
5
6
7
8
9
10
11
func worker(ch chan struct{}) {
<-ch
fmt.Println("do something")
close(ch)
}

func main() {
ch := make(chan struct{})
go worker(ch)
ch <- struct{}{}
}

这种场景我们思考下,是否一定是非 struct{} 不可?其实不是,而且也不多这几个字节的内存,所以这种情况真的就只是不关心 chan 的元素值而已,所以才用的 struct{}

struct {} 作为 receiver

receiver 这个是 golang 里 struct 具有的基础特点。空结构体本质上作为结构体也是一样的,可以作为 receiver 来定义方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
type emptyStruct struct{}

func (e *emptyStruct) FuncB(n, m int) {
}
func (e emptyStruct) FuncA(n, m int) {
}

func main() {
a := emptyStruct{}

n := 1
m := 2

a.FuncA(n, m)
a.FuncB(n, m)
}

receiver 这种写法是 golang 支撑面向对象的基础,本质上的实现也是非常简单,常规情况(普通的结构体)可以翻译成:

1
2
3
4
func FuncA (e *emptyStruct, n, m int) {
}
func FuncB (e emptyStruct, n, m int) {
}

编译器只是把对象的值或地址作为第一个参数传给这个参数而已,就这么简单。 但是在这里要提一点,空结构体稍微有一点点不一样,空结构体应该翻译成:

1
2
3
4
func FuncA (e *emptyStruct, n, m int) {
}
func FuncB (n, m int) {
}

看下汇编代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
00000000004525d0 <main.main>:
4525d0: 64 48 8b 0c 25 f8 ff mov %fs:0xfffffffffffffff8,%rcx
4525d9: 48 3b 61 10 cmp 0x10(%rcx),%rsp
4525dd: 76 63 jbe 452642 <main.main+0x72>
4525df: 48 83 ec 30 sub $0x30,%rsp
4525e3: 48 89 6c 24 28 mov %rbp,0x28(%rsp)
4525e8: 48 8d 6c 24 28 lea 0x28(%rsp),%rbp
4525ed: 48 c7 44 24 18 01 00 movq $0x1,0x18(%rsp)
4525f6: 48 c7 44 24 20 02 00 movq $0x2,0x20(%rsp)
4525ff: 48 8b 44 24 18 mov 0x18(%rsp),%rax
452604: 48 89 04 24 mov %rax,(%rsp) // n 变量值压栈(第一个参数)
452608: 48 c7 44 24 08 02 00 movq $0x2,0x8(%rsp) // m 变量值压栈(第二个参数)
452611: e8 aa ff ff ff callq 4525c0 <main.emptyStruct.FuncA>
452616: 48 8d 44 24 18 lea 0x18(%rsp),%rax
45261b: 48 89 04 24 mov %rax,(%rsp) // $rax 里面是 zerobase 的值,压栈(第一个参数);
45261f: 48 8b 44 24 18 mov 0x18(%rsp),%rax
452624: 48 89 44 24 08 mov %rax,0x8(%rsp) // n 变量值压栈(第二个参数)
452629: 48 8b 44 24 20 mov 0x20(%rsp),%rax
45262e: 48 89 44 24 10 mov %rax,0x10(%rsp) // m 变量值压栈(第三个参数)
452633: e8 78 ff ff ff callq 4525b0 <main.(*emptyStruct).FuncB>
452638: 48 8b 6c 24 28 mov 0x28(%rsp),%rbp
45263d: 48 83 c4 30 add $0x30,%rsp
452641: c3 retq
452642: e8 b9 7a ff ff callq 44a100 <runtime.morestack_noctxt>
452647: eb 87 jmp 4525d0 <main.main>

通过这段代码证实几个点:

  1. receiver 其实就是一种语法糖,本质上就是作为第一个参数传入函数;
  2. receiver 为值的场景,不需要传空结构体做第一个参数,因为空结构体没有值;
  3. receiver 为一个指针的场景,对象地址作为第一个参数传入函数,函数调用的时候,编译器传入 zerobase 的值(编译期间就可以确认);

在二进制编译之后,一般 e.FuncA 的调用,第一个参数是直接压入 &zerobase 到栈里。

总结几个知识点:

  • receiver 本质上是非常简单的一个通用思路,就是把对象值或地址作为第一参数传入函数;
  • 函数参数压栈方式从前往后(可以调试看下);
  • 对象值作为 receiver 的时候,涉及到一次值拷贝;
  • golang 对于值做 receiver 的函数定义,会根据现实需要情况可能会生成了两个函数,一个值版本,一个指针版本(思考:什么是“需要情况”?就是有 interface 的场景 );
  • 空结构体在编译期间就能识别出来的场景,编译器会对既定的事实,可以做特殊的代码生成;

可以这么说,编译期间,关于空结构体的参数基本都能确定,那么代码生成的时候,就可以生成对应的静态代码。

总结

通过学习上面空指针的各种使用场景,我们能得出结论,一般只会在以下两种情况会使用到空结构体:

  1. 需要一个占位符,并不关系数据的内容。
  2. 节省空间。

空结构体就像一些牛马一样,又让人家干活,又不给人家内存地址。是你,是我,是大家。

参考