Golang 空结构体的底层原理和其使用

在 Go 中,普通结构体通常占据一个内存块。但有一种特殊情况:如果是空结构体,其大小为零。这怎么可能?空结构体有什么用?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
type Test struct {
A int
B string
}

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

/*
24
0
*/

空结构的秘密

特殊变量:零基数

空结构体是没有内存大小的结构体。这种说法是正确的,但更准确地说,它有一个特殊的起点:zerobase 变量。这是一个占 8 字节的 uintptr 全局变量。每当定义无数个 struct {} 变量时,编译器都会分配这个 zerobase 变量的地址。换句话说,在 Go 语言中,任何大小为 0 的内存分配都使用相同的地址 &zerobase。

Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
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)
}

// 0x58e360
// 0x58e360
// 0x58e360

空结构体变量的内存地址都是相同的。这是因为编译器在遇到这种特殊类型的内存分配时,会在编译过程中分配 &zerobase。这一逻辑存在于 mallocgc 函数中:

1
2
3
4
5
6
7
//go:linkname mallocgc  
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
...
if size == 0 {
return unsafe.Pointer(&zerobase)
}
...

这就是 Empty struct 的秘密。利用这个特殊变量,我们可以实现许多功能。

空结构和内存对齐

通常情况下,如果空结构体是较大结构体的一部分,则不会占用内存。但是,当空结构体是最后一个字段时,就会触发内存对齐。

Example

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
type A struct {
x int
y string
z struct{}
}
type B struct {
x int
z struct{}
y string
}

func main() {
println(unsafe.Alignof(A{}))
println(unsafe.Alignof(B{}))
println(unsafe.Sizeof(A{}))
println(unsafe.Sizeof(B{}))
}

/**
8
8
32
24
**/

当存在指向字段的指针时,返回的地址可能在结构体之外,如果释放结构体时没有释放该内存,则可能导致内存泄漏。因此,当空结构体是另一个结构体的最后一个字段时,为了安全起见,会分配额外的内存。如果空结构体位于结构体的开头或中间,则其地址与下面的变量相同。

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
type A struct {  
x int
y string
z struct{}
}
type B struct {
x int
z struct{}
y string
}

func main() {
a := A{}
b := B{}
fmt.Printf("%p\n", &a.y)
fmt.Printf("%p\n", &a.z)
fmt.Printf("%p\n", &b.y)
fmt.Printf("%p\n", &b.z)
}

/**
0x1400012c008
0x1400012c018
0x1400012e008
0x1400012e008
**/

空结构使用案例

空结构 struct struct{} 存在的核心原因是为了节省内存。当你需要一个结构但不关心其内容时,可以考虑使用空结构。Go 的核心复合结构,如 map、chan 和 slice,都可以使用 struct{}。

map & struct{}

1
2
3
4
5
6
// Create map
m := make(map[int]struct{})
// Assign value
m[1] = struct{}{}
// Check if key exists
_, ok := m[1]

chan & struct{}

典型的情况是将 channel 和 struct{} 结合在一起,其中 struct{} 经常被用作信号,而不关心其内容。正如前几篇文章所分析的,通道的基本数据结构是一个管理结构加一个环形缓冲区。如果 struct{} 被用作元素,则环形缓冲区为零分配。

chan 和 struct{} 放在一起的唯一用途是信号传输,因为空结构体本身不能携带任何值。一般情况下,它不用于缓冲通道。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// Create a signal channel
waitc := make(chan struct{})

// ...
goroutine 1:
// Send signal: push element
waitc <- struct{}{}
// Send signal: close
close(waitc)

goroutine 2:
select {
// Receive signal and perform corresponding actions
case <-waitc:
}

在这种情况下,有必要使用 struct{} 吗?其实不然,节省的内存几乎可以忽略不计。关键在于,我们并不关心 chan 的元素值,因此使用了 struct{}。

总结

  • 空结构体仍然是大小为 0 的结构体。
  • 所有空结构体共享同一个地址:zerobase 的地址。
  • 我们可以利用 empty 结构不占用内存的特性来优化代码,例如使用映射来实现集合和通道。

-------------The End-------------

本文标题:Golang 空结构体的底层原理和其使用

文章作者:cloud sjhan

发布时间:2024年06月20日 - 15:06

最后更新:2024年06月20日 - 15:06

原始链接:https://cloudsjhan.github.io/2024/06/20/Golang-空结构体的底层原理和其使用/

许可协议: 署名-非商业性使用-禁止演绎 4.0 国际 转载请保留原文链接及作者。

cloud sjhan wechat
subscribe to my blog by scanning my public wechat account
坚持原创技术分享,您的支持将鼓励我继续创作!
0%
;