文章目录
- 1.简介
- 2.核心特性
-
- 2.1 零内存占用
- 2.2 值比较语义
- 2.3 类型隔离
- 2.4 值地址
- 3.作用
-
- 3.1 实现集合(Set)
- 3.2 不发送数据的信道
- 3.3 无状态方法接收者
- 3.4 作为 context 的 value 的 key
- 4.小结
- 参考文献
1.简介
在 Go 语言中,空结构体是一个不包含任何字段的特殊结构体。
空结构体的定义如下:
type EmptyStruct struct{}
2.核心特性
2.1 零内存占用
在 Go 中,我们可以使用 unsafe.Sizeof
计算出一个数据类型实例需要占用的字节数。
package main
import (
"fmt"
"unsafe"
)
func main() {
fmt.Println(unsafe.Sizeof(struct{}{}))
}
运行输出:
$ go run main.go
0
可以看到,Go 中空结构体 struct{} 是不占用内存空间,不像 C/C++ 中空结构体仍占用 1 字节。
2.2 值比较语义
空结构体实例可进行相等性比较,因为没有值,所以同类型空结构体的所有实例在值上是相等的(a == b 恒为 true)。
type EmptyStruct struct{}
var a = EmptyStruct{}
var b = EmptyStruct{}
// 恒等
a == b
2.3 类型隔离
不同名称的空结构体代表不同类型。
type EmptyStructA struct{}
type EmptyStructB struct{}
var a = EmptyStructA{}
var b = EmptyStructB{}
// invalid operation: a == b (mismatched types EmptyStructA and EmptyStructB)
fmt.Println(a == b)
2.4 值地址
空结构体的地址可能相等也可能不等。
当空结构体变量未发生逃逸时,它们的地址是不等的;当发生逃逸时,它们的地址是相等的,都指向了 runtime.zerobase
。
详见 Go编程语言规范 所说的:
Pointers to distinct zero-size variables may or may not be equal.
Go 语言比较操作符比较规则:
注意: 不论逃逸还是未逃逸,我们都不应该对空结构体类型变量指向的内存地址是否一样,做任何预期。
下面以实例来验证。
package main
import (
"fmt"
)
type Empty struct{}
func main() {
a := &Empty{}
fmt.Printf("%p\n", a)
b := &Empty{}
fmt.Printf("%p\n", b)
fmt.Println(a == b)
}
很多人认为 a 和 b 是 2 个不同的对象实例,便认为 a 和 b 具备不同的内存地址,故而判断 a==b 的结果为 false。我们看一下实际输出:
0x2c8460
0x2c8460
true
上面的 a 与 b 地址相同的原因是二者发生了内存逃逸,变量被分配到堆内存空间,所以地址都指向了 runtime.zerobase
。
倘若我们去掉任意一个(或者将打印内存的地址都去掉也一样),则 a==b 的判断输出,就是 false。再看一下代码:
package main
import (
"fmt"
)
type Empty struct{}
func main() {
a := &Empty{}
// fmt.Printf("%p\n", a)
b := &Empty{}
fmt.Printf("%p\n", b)
fmt.Println(a == b)
}
运行输出:
0x677460
false
那么,可以看出是 fmt.Printf
影响了最终结果的判断。我们看一下上面代码的内存逃逸情况分析:
$ go build -gcflags='-m -l' main.go
# command-line-arguments
./main.go:10:10: &Empty{} does not escape
./main.go:12:10: &Empty{} escapes to heap
./main.go:13:15: ... argument does not escape
./main.go:14:16: ... argument does not escape
./main.go:14:19: a == b escapes to heap
可以看到,变量 b 从栈内存逃逸到了堆内存上。而变量 a 没有逃逸。因为对 b 使用了 fmt.Printf
而 a 没有。由此可以简单判断,是fmt.Printf
导致变量产生了内存由栈向堆的逃逸。
这里有两个问题需要解释一下:
问题1. 为什么 fmt.Printf 会导致变量的内存逃逸?
fmt.Printf
的参数是可变长度的 interface{} 类型(空接口)。当你传递一个变量给 fmt.Printf 时,Go 会将该变量 隐式转换为接口值。空接口值的底层实现包含两部分:
- 类型信息(指向具体类型的指针)
- 值信息(指向实际数据的指针)
fmt.Printf 导致变量内存逃逸的核心原因在于:
接口的隐式转换需要保证值的生命周期。
反射和动态类型导致编译器无法静态分析变量作用域。
问题2. 为什么逃逸到了堆内存,2 个变量就一样了?
这是因为,堆上内存分配调用了 runtime 包的 newobject 函数。而 newobject 函数其实本质上会调用 runtime 包内的 mallocgc 函数。这个函数有点特别:
// Allocate an object of size bytes.
// Small objects are allocated from the per-P cache's free lists.
// Large objects (> 32 kB) are allocated straight from the heap.
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer {
if gcphase == _GCmarktermination {
throw("mallocgc called with gcphase == _GCmarktermination")
}
// 关键部分,如果要分配内存的变量不占用实际内存,则直接用 golang 的全局变量 zerobase 的地址。
if size == 0 {
return unsafe.Pointer(&zerobase)
}
// ...
}
函数比较长,我做了截取。这函数内有一个判断。 如果要分配内存的变量不占用实际内存,则直接用 golang 的全局变量 zerobase 的地址。而我们的变量 a 和变量 b 有一个共同特点,就是它们是“空 struct”,空 struct 不占用内存空间。
所以,a 和 b 是空 struct,再做内存分配的时候,使用了 golang 内部全局私有变量 runtime.zerobase
的内存地址。
如何验证 a 和 b 都使用的是 runtime.zerobase 内存地址?
我们可以使用 //go:linkname
编译器指令(Compiler Directive),用于在代码中显式声明一个符号(如函数或变量)与其他包中的未导出(unexported)符号进行链接。
语法:
//go:linkname 当前符号 目标符号的全路径
注意:必须导入 unsafe 包(指令本身并不使用 unsafe,但编译器要求)。
import _ "unsafe"
下面打印空结构体对象 和 runtime.zerobase 的内存地址。
package main
import (
"fmt"
_ "unsafe"
)
//go:linkname zerobase runtime.zerobase
var zerobase uintptr // 使用 go:linkname 编译指令,将 zerobase 变量与其未导出的 runtime.zerobase 符号进行链接
type Empty struct{}
func main() {
a := &Empty{}
b := &Empty{}
fmt.Printf("%p\n", a)
fmt.Printf("%p\n", b)
fmt.Printf("%p\n", &zerobase)
}
运行输出(每次结果不一样):
$ go run main.go
0x10f7460
0x10f7460
0x10f7460
从上面输出的内容可以看到,空结构体对象当发生逃逸时,其内存地址与全局变量 runtime.zerobase
的地址相同。
3.作用
因为空结构体不占据内存空间,因此被广泛作为各种场景下的占位符使用。
一是节省资源,二是空结构体本身就具备很强的语义,即这里不需要任何值,仅作为占位符,达到的代码即注释的效果。
3.1 实现集合(Set)
Golang 标准库没有提供 Set 的实现,通常使用 map 来代替。事实上,对于集合来说,只需要 map 的键,而不需要值。即使是将值设置为 bool 类型,也会多占据 1 个字节,那假设 map 中有一百万条数据,就会浪费 1MB 的空间。
因此呢,将 map 作为集合(Set)使用时,可以将值类型定义为空结构体,仅作为占位符使用即可。
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("foo")
s.Add("bar")
fmt.Println(s.Has("foo"))
fmt.Println(s.Has("bar"))
}
如果想使用 Set 的完整功能,如初始化(通过切片构建一个 Set)、Add、Del、Clear、Contains 等操作,可以使用开源库 golang-set。
3.2 不发送数据的信道
有时使用 channel 不需要发送任何数据,只用来通知子协程(goroutine)执行任务,或只用来控制协程的并发。
这种情况下,使用空结构体作为占位符就非常合适了。
func worker(ch chan struct{}) {
<-ch
fmt.Println("do something")
}
func main() {
ch := make(chan struct{})
go worker(ch)
ch <- struct{}{}
close(ch)
}
3.3 无状态方法接收者
在 Go 中,方法是一种将函数与特定类型相关联的机制。如果我们不需要访问方法中的任何接收器字段,那么可以使用空结构体作为接收器类型。
type Door struct{}
func (d Door) Open() {
fmt.Println("Open the door")
}
func (d Door) Close() {
fmt.Println("Close the door")
}
上面例子中的 Door,事实上 Door 可以用任何数据类型而不是空结构体,例如:
type Door int
type Door bool
只不过无论是 int 还是 bool 都会浪费额外的内存。因此,在这种情况下,声明为空结构体最合适。
3.4 作为 context 的 value 的 key
空结构体实例可进行相等性比较,因为没有值,所以同类型空结构体的所有实例在值上是相等的。
所以我们可以使用空结构体作为携带值的 context 的 value 的 key。
这样做的好处时,是可以随时随地采用结构体字面量访问 context 中的 value,而不需要引用某个全局变量。
package main
import (
"context"
"fmt"
)
// 定义空结构体作为键类型
type EmptyStructA struct{}
type EmptyStructB struct{}
func main() {
ctx := context.Background()
// 将数据附加到 context。
ctx = context.WithValue(ctx, EmptyStructA{}, "EmptyStructA data")
ctx = context.WithValue(ctx, EmptyStructB{}, "EmptyStructA data")
// 检索数据,可以直接使用结构体字面量检索,而无需引用全局变量。
if value, ok := ctx.Value(EmptyStructA{}).(string); ok {
fmt.Println(value)
}
if value, ok := ctx.Value(EmptyStructB{}).(string); ok {
fmt.Println(value)
}
}
运行输出:
EmptyStructA data
EmptyStructA data
4.小结
Golang 中空结构体 struct{} 是一种特殊的数据类型,不包含任何字段,其内存占用大小为零字节。这一特性使得它在需要节省内存或表示无数据状态的场景中非常有用。
虽然空结构体 struct{} 看似简单,但其零内存占用和多功能性使其在 Go 编程中有着广泛的应用,特别是在内存优化和并发控制场景中。正确利用空结构体可以带来代码效率的提升和资源的节约。
参考文献
The Go Programming Language Specification
空结构体 - 深入Go语言之旅
一个奇怪的golang等值判断问题