空结构体与内存对齐

为什么需要结构体?

在 Go 语言中,使用整型、字符串、浮点型等基本的数据类型,就可以表示很多事物了。那么为什么还需要结构体呢?使用结构体,可以更方便的抽象出一组具有相同行为和属性的事物

那结构体是什么呢?结构体是 Go 语言中的一种自定义类型,它可以将不同类型的数据字段组合在一起形成一个新的数据类型。可以说结构体是 Go 语言中的一等公民,我们通常会使用面向对象的思想对系统进行建模,需要用到结构体将一类事物抽象表示出来。
比如使用 Person 结构体来抽象表示人类:

// 用 Person 抽象表示人类
type Person struct {
    id     string // 身份证号
    name   string // 姓名
    age    int    // 年龄
    gender int    // 性别:1代表男,0代表女
}

在这里,我们认为人类都拥有id、name、age、gender这些属性,那么我们就可以使用这些属性来表示不同的人:

p1 := Person{"52013xxx", "Ciusyan", 18, 1}
p2 := Person{"13013xxx", "Cherlin", 20, 0}

我在上面创建出来了 p1 和 p2 两个人,我们还可以统一表示它们的行为,比如它们都需要按时吃饭:

// Person 共同的行为
func (p Person) Eat() {
    fmt.Printf("%s 在按时吃饭哟\n", p.name)
}

p1.Eat() // Ciusyan 在吃饭
p2.Eat() // Cherlin 在吃饭

上面只是一个简单的例子,更多和结构体相关的知识,我没有详细表述出来,可以自行了解。

下面我再来总结为什么需要结构体:使用结构体可以更好的抽象和封装,表达出更复杂的类型;它允许你将不同类型的数据字段组合在一起形成一个新的数据类型,可以实现更复杂的数据结构和工具;它还可以组合其它结构体、配合接口,更好的使用面向对象的方式来对系统进行建模。

简单了解了结构体,下面我们进入主题,来看一个特殊的结构体:空结构体

特殊的空结构体

空结构体它特殊在哪里呢?先来看看空结构体长什么样:

type Empty struct {} // Empty 就是一个空结构体

其实空结构体就是没有任何字段的一个结构体,但它也可以拥有方法:

// 也可以拥有方法
func (e Empty) kong() {
    fmt.Println("我是一个 kong 结构体")
}

除了没有属性,不也和其它结构体没啥区别吗?那么它有什么特别的呢?是的,的确没太多的区别。但是它很有用,因为它不占用内存,听我细细道来。

在 Go 语言中,空结构体的大小为 0 字节,不占用内存,但它也有地址。如果空结构体单独出现,它的地址是zerobase ;否则,它会跟随其他变量的内存地址一起。这一点与 C++ 和 Java 有所不同。在 C++ 中,类需要保证每个类的地址是唯一的,因此空类也会占用 1 个字节。而 Java 的情况则不确定,通常 JVM 会为其分配 8 个字节的大小,用于记录类本身的一些信息。

现在看来,空结构体应该还是有一点特殊的对吧。可为什么它这么特殊呢?我们先来了解什么是内存对齐。

zerobase 是 Go 中代表所有内存大小为 0 的变量的地址,是一个固定不变的值

什么是内存对齐?

内存对齐是一种提高内存访问效率的技术。它的原理是,操作系统访问内存时是按照字长(word)为单位的,字长是 CPU 一次能读取的内存数据的大小。比如在 64 位机器上,字长为 8 字节。如果内存数据的地址是字长的整数倍,那么 CPU 就可以一次读取到完整的数据,否则就需要多次访问内存,造成效率降低。内存对齐还可以保证内存数据的原子性,比如在 32 位平台上进行 64 位的原子操作,就必须要求数据是 8 字节对齐的,否则可能会出现 panic。

为了方便内存对齐,Go 语言为变量提供了对齐系数(alignof),表示变量的地址必须是对齐系数的整数倍。可以使用 unsafe.Sizeof() 和 unsafe.Alignof() 分别得到变量所占用的内存大小和变量的对齐系数。

基本类型的对齐

对于基本类型,其对齐系数通常等于变量的大小。比如:

    var a bool
    var b int16
    var c int32
    var d float64
    // 地址:0xc00000e370 占用 1 字节,对齐系数:1
    fmt.Printf("地址:%p 占用 %d 字节,对齐系数:%d\n", &a, unsafe.Sizeof(a), unsafe.Alignof(a))
    // 地址:0xc00000e372 占用 2 字节,对齐系数:2
    fmt.Printf("地址:%p 占用 %d 字节,对齐系数:%d\n", &b, unsafe.Sizeof(b), unsafe.Alignof(b))
    // 地址:0xc00000e374 占用 4 字节,对齐系数:4
    fmt.Printf("地址:%p 占用 %d 字节,对齐系数:%d\n", &c, unsafe.Sizeof(c), unsafe.Alignof(c))
    // 地址:0xc00000e378 占用 8 字节,对齐系数:8
    fmt.Printf("地址:%p 占用 %d 字节,对齐系数:%d\n", &d, unsafe.Sizeof(d), unsafe.Alignof(d))

可以发现,基本类型的变量,对齐系数就等于其占用内存的大小。那么在给其变量分配内存的时候,它们的地址必须是对齐系数的整数倍。如下图所示:

结构体对齐

对于结构体类型,其对齐系数等于其所有字段对齐系数的最大值。比如:

type Person struct {
    id     int64   // 对齐系数 8,占用 8 字节
    age    int16   // 对齐系数 2,占用 2 字节
    height float32 // 对齐系数 4,占用 4 字节
}

那么 Person 的对齐系数 = max {Alignof(id), Alignof(age), Alignof(height)} ,就等于 8。那么在分配一个 Person 对象时,它的地址必须是 8 的整数倍。
结构体类型的变量需要考虑两个方面的内存对齐:内部字段对齐和外部长度填充。内部字段对齐是指结构体中每个字段的偏移量(offset)必须是该字段自身大小和对齐系数中较小值的整数倍。外部长度填充是指结构体所占用的内存大小必须是结构体最大成员长度和操作系统字长较小值的整数倍。

对于外部填充来说,Person 最大的成员是占用 8 字节的 int64,我的电脑是 64 位的机器,所以字长也是 8。那么 Person 的对齐系数等于它俩的最小值,也是 8,所以 Person 对象的内存地址必须是 8 的整数倍。
而对于内部字段偏移,先将上面的 Person 对象的内存用图片来表示后再来解释:

比如我们这里通过结构体的对齐系数确定了结构体的地址是 0X20 ,那么每一个字段相较于结构体偏移多少呢?第一个是 id 字段,它的自身大小是 8,对齐系数也是 8,所以偏移后的地址必须是 8 的整数倍,那么就是0X20;第二个是 age 字段,它的自身大小是 2,对齐系数也是 2,所以偏移后的地址是 2 的整数倍,这里为 0X28;第三个是 height 字段,它自身大小和对齐系数都是 4,所以最终偏移后的地址是 4 的整数倍,这里是 0X2C(这里为 16 进制表示,相当于是十进制的 32)。

你可能有一个疑惑,为什么偏移后的地址必须是字段大小与字段对齐系数较小值的整数倍啊,看我这里的例子,它们的对齐系数和自身大小都一样的,那么偏移的倍数也是一样的嘛。是滴,但是有可能结构体内部还会嵌套结构体和其他更复杂的类型, 那么它们的对齐系数和自身大小可能就不是一样的了。

通过上图,我们也可以得到一个结论:一个结构体所占用的内存大小,可能并不是直接将所有字段所占用的字节数相加。比如上面的 Person 结构体,直接相加时,它占用的内存等于 8 + 4 + 2 = 14 字节。但是因为内存对齐,一个 Person 对象占用的内存实际上是 16 字节。

综上所述,内存对齐是一种优化内存访问性能和保证内存操作原子性的技术。在Go语言中,我们可以通过了解变量的对齐系数、偏移量和长度填充等概念,来合理地安排结构体中字段的顺序,从而减少内存空间的浪费和提高内存访问效率。 最后再看一幅将上述的变量放在一起的图:

再回到空结构体

了解了空结构体和内存对齐规则,再来看看空结构体。由于空结构体没有字段,因此不需要内部对齐,这样可以节省空间并提高内存效率。也不会占用任何内存空间。 而且如果一个结构体中包含空结构体类型的字段,那么这个字段也不需要进行字段偏移,也不会影响结构体对齐系数。
比如在刚刚的 Person 中插入一个 empty 字段:

可以看到,这个空的 empty 的地址是紧跟 age 字段的地址的。但是如果这个字段是结构体中最后一个字段,那么为了防止指针指向结构体之外的地址,导致内存泄露,可能也会对结构体后面进行长度填充。
综上,为了提高内存的访问效率和原子性,Go 语言采用了自己的内存对齐方案。对于结构体而言,除了需要保证外部字长的填充以外,还需要保证内部字段的对齐。此外,它也不会与其他内存竞争缓存,进一步提高了内存的效率。

struct{} 的用途

由于空结构体能够节省内存和提升内存效率,通常有两个主要用途。首先,它可以与 HashMap 配合使用,作为 HashSet 来存储数据。另外,它可以与 Channel 配合使用,用于发送空信号而无需携带数据。

1、用 HashMap 实现 HashSet

// 定义一个 Set 类型
type Set map[int64]struct{}

// NewSet 返回一个Setmap[int64]struct{}
func NewSet() Set {
    return map[int64]struct{}{}
}

// Add 添加元素
func (s Set) Add(item int64) {
    s[item] = struct{}{}
}

// Items 获取所有的元素
func (s Set) Items() (items []int64) {
    for k := range s {
        items = append(items, k)
    }
    return
}

上面的 Set 类型,其实就是一个 HashSet,拥有自动去重的能力。

2、用于发送空信号而无需携带数据

func TestChan(t *testing.T) {
    // 准备一个 Channel,只接受信号,不需要数据
    ch := make(chan struct{}, 1)
    // 执行业务方法
    go Business(ch)

    // 做其他事情 ....

    // 监听业务方法是否完毕
    select {
    case <-ch:
        // 业务方法完成后,做一些善后逻辑 ...
        fmt.Println("Ciusyan 收到,并夸奖了小 Cher")
    }
}

func Business(ch chan<- struct{}) {
    time.Sleep(time.Second)
    // 做一些业务
    fmt.Println("Cherlin 把业务执行完了!!!")

    // 业务执行完毕,发送信号通知接收者
    ch <- struct{}{}
}

我们在主协程,准备了一个管道,传递给做业务的协程。当业务方法执行完毕的时候,往管道里塞了一个信号,这个信号没有任何数据,单纯告诉主协程,自己的业务做完了。诸如此类的场景其实还有很多,例如 Context 中的 Done() 方法,也是通过空结构体发送的纯信号,代表着当前 Context 被取消了。只是我这里的例子比较简单。

总结

结构体常用于抽象表示一类事物,可以拥有行为或者状态。
空结构体是一种特殊的结构体,没有任何字段,不会进行内存对齐,也不占用内存,但是有固定的地址 zerobase。
内存对齐有助于提升操作内存的效率和提升内存的原子性。
Go 语言有其自己的内存对齐方案,为类型提供了一个对齐系数方便对齐,变量的内存地址必须是其对齐系数的整数倍。
基本类型的对齐系数通常等于它所占用的字节数,结构体的对齐系数等于其所有字段对齐系数的最大值。
空结构体的除了需要考虑内部字段偏移,还需要考虑外部长度填充。
空结构体可以将 HashMap 改造成 HashSet 来使用;还可以配合 Channel 发送纯信号。

转自:https://juejin.cn/post/7244809769794207801