從 Go 的空 Struct{} 到內存對齊

作者:Ciusyan

https://juejin.cn/post/7244809769794207801

空結構體與內存對齊

爲什麼需要結構體?

在 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 返回一個Set:map[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 被取消了。只是我這裏的例子比較簡單。

總結

  1. 結構體常用於抽象表示一類事物,可以擁有行爲或者狀態。

  2. 空結構體是一種特殊的結構體,沒有任何字段,不會進行內存對齊,也不佔用內存,但是有固定的地址 zerobase。

  3. 內存對齊有助於提升操作內存的效率和提升內存的原子性。

  4. Go 語言有其自己的內存對齊方案,爲類型提供了一個對齊係數方便對齊,變量的內存地址必須是其對齊係數的整數倍。

  5. 基本類型的對齊係數通常等於它所佔用的字節數,結構體的對齊係數等於其所有字段對齊係數的最大值。

  6. 空結構體的除了需要考慮內部字段偏移,還需要考慮外部長度填充。

  7. 空結構體可以將 HashMap 改造成 HashSet 來使用;還可以配合 Channel 發送純信號。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/KlgVcZ6S5FxMiTcA0bHKDA