写点什么

Golang 最细节篇— struct{} 空结构体究竟是啥?

发布于: 2021 年 05 月 28 日

[toc]

原创不易,更多干货,欢迎关注公众号:奇伢云存储

背景

golang 正常的 struct 就是普通的一个内存块,必定是占用一小块内存的,并且结构体的大小是要经过边界,长度的对齐的,但是“空结构体”是不占内存的,size 为 0;


提示:以下都是基于 go1.13.3 linux/amd64 分析。


普通的结构体定义如下:


// 类型变量对齐到 8 字节;type Tp struct {    a uint16    b uint32}
复制代码


按照内存对齐规则,这个结构体占用 8 个字节的内存。


空结构体:


var s struct{}// 变量 size 是 0 ;fmt.Println(unsafe.Sizeof(s))
复制代码


该空结构体的变量占用内存 0 字节。


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

原理解密

特殊变量:zerobase

空结构体是没有内存大小的结构体。这句话是没有错的,但是更准确的来说,其实是有一个特殊起点的,那就是 zerobase 变量,这是一个 uintptr 全局变量,占用 8 个字节。当在任何地方定义无数个 struct {} 类型的变量,编译器都只是把这个 zerobase 变量的地址给出去。换句话说,在 golang 里面,涉及到所有内存 size 为 0 的内存分配,那么就是用的同一个地址 &zerobase


举个例子:


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)}
复制代码


dlv 调试分析一下:


(dlv) p &a(*struct {})(0x57bb60)(dlv) p &b(*struct {})(0x57bb60)(dlv) p &c(*main.emptyStruct)(0x57bb60)(dlv) p &runtime.zerobase(*uintptr)(0x57bb60)
复制代码


小结:空结构体的变量的内存地址都是一样的。

内存管理特殊处理

mallocgc


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


代码如下:


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 的地址。


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

定义的各种姿势

原生定义

a := struct{}{}
复制代码


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

重定义类型

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


type emptyStruct struct{}
复制代码


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

匿名嵌套类型

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


匿名嵌套方式一


type emptyStruct struct{}type Object struct {    emptyStruct}
复制代码


匿名嵌套方式二


type Object1 struct {    _ struct {}}
复制代码


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

内置字段

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


  • 空结构体的类型不占内存大小;

  • 地址偏移要和自身类型对齐;

  • 整体类型长度要和最长的字段类型长度对齐;


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


场景一:struct {} 在最前面


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


// 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 {} 在中间


// Object1 类型变量占用 16 个字节type Object1 struct {  b  byte  s  struct{}  b1 int64}
o1 := Object1{ }
复制代码


  • 按照对齐规则,变量 o1 占用 16 个字节;

  • &o1.s&o1.b1 相同;


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


场景三:struct {} 在最后


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


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 内存,然后整个变量按照地址和长度的对齐规则不变。

struct {} 作为 receiver

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


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 支撑面向对象的基础,本质上的实现也是非常简单,常规情况(普通的结构体)可以翻译成:


func FuncA (e *emptyStruct, n, m int) {}func FuncB (e  emptyStruct, n, m int) {}
复制代码


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


func FuncA (e *emptyStruct, n, m int) {}func FuncB (n, m int) {}
复制代码


极其简单的代码,对应的汇编实际代码如下:


FuncA,FuncB 就这么简单,如下:


00000000004525b0 <main.(*emptyStruct).FuncB>:  4525b0:  c3                     retq   
00000000004525c0 <main.emptyStruct.FuncA>: 4525c0: c3 retq
复制代码


main 函数


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 的场景 );

  • 空结构体在编译期间就能识别出来的场景,编译器会对既定的事实,可以做特殊的代码生成;


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

配合使用姿势

空结构体 struct{ } 为什么会存在的核心理由就是为了节省内存。当你需要一个结构体,但是却丝毫不关系里面的内容,那么就可以考虑空结构体。golang 核心的几个复合结构 mapchanslice 都能结合 struct{} 使用。

map & struct{}

mapstruct {} 一般的结合姿势是这样的:


// 创建 mapm := make(map[int]struct{})// 赋值m[1] = struct{}{}// 判断 key 键存不存在_, ok := m[1]
复制代码


一般 mapstruct {} 的结合使用场景是:只关心 key,不关注值。比如查询 key 是否存在就可以用这个数据结构,通过 ok 的值来判断这个键是否存在,map 的查询复杂度是 O(1) 的,查询很快。


你当然可以用 map[int]bool 这种类型来代替,功能也一样能实现,很多人考虑使用 map[int]struct{} 这种使用方式真的就是为了省点内存,当然大部分情况下,这种节省是不足道哉的,所以究竟要不要这样使用还是要看具体场景。

chan & struct{}

channelstruct{} 结合是一个最经典的场景,struct{} 通常作为一个信号来传输,并不关注其中内容。chan 的分析在前几篇文章有详细说明。chan 本质的数据结构是一个管理结构加上一个 ringbuffer ,如果 struct{} 作为元素的话,ringbuffer 就是 0 分配的。


chanstruct{} 结合基本只有一种用法,就是信号传递,空结构体本身携带不了值,所以也只有这一种用法啦,一般来说,配合 no buffer 的 channel 使用。


// 创建一个信号通道waitc := make(chan struct{})
// ...goroutine 1: // 发送信号: 投递元素 waitc <- struct{} // 发送信号: 关闭 close(waitc)
goroutine 2: select { // 收到信号,做出对应的动作 case <-waitc: }
复制代码


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

slice & struct{}

形式上,slice 也结合 struct{}


s := make([]struct{}, 100)
复制代码


我们创建一个数组,无论分配多大,所占内存只有 24 字节(addr, len, cap),但实话说,这种用法没啥实用价值。


创建 slice 其实调用的是 makeslice 来分配内存,其中是调用 malllocgc ,而 mallocgc 我们知道在分配 size 为 0 的内存则是直接返回 zerobase 的地址而已。而 slice 在扩展的时候在遇到这种 size 为 0 的时候,也是直接返回 zerobase 的地址。


func growslice(et *_type, old slice, cap int) slice {    // 如果元素的 size 为 0,那么还是直接赋值了 zerobase 的地址;    if et.size == 0 {        return slice{unsafe.Pointer(&zerobase), old.len, cap}    }}
复制代码

总结

  1. 空结构体也是结构体,只是 size 为 0 的类型而已;

  2. 所有的空结构体都有一个共同的地址:zerobase 的地址;

  3. 空结构体可以作为 receiver ,receiver 是空结构体作为值的时候,编译器其实直接忽略了第一个参数的传递,编译器在编译期间就能确认生成对应的代码;

  4. mapstruct{} 结合使用常常用来节省一点点内存,使用的场景一般用来判断 key 存在于 map

  5. chanstruct{} 结合使用是一般用于信号同步的场景,用意并不是节省内存,而是我们真的并不关心 chan 元素的值;

  6. slicestruct{} 结合好像真的没啥用。。。


原创不易,更多干货,欢迎关注公众号:奇伢云存储



发布于: 2021 年 05 月 28 日阅读数: 412
用户头像

坚持思考,就会很酷 2018.11.21 加入

公众号:奇伢云存储,专注分享计算机基础、Go 语言、云存储、云计算。前后就职于 Sangfor,腾讯,现于某互联网大厂任高级存储研发,专研云存储技术。日常专注于云计算、云存储领域,坚持撰写有思考的技术文章。

评论

发布
暂无评论
Golang最细节篇— struct{} 空结构体究竟是啥?