背景
原理解密
定義的各種姿勢
`struct {}` 作為 receiver
配合使用姿勢
`map` & `struct{}`
`chan` & `struct{}`
`slice` & `struct{}`
總結
golang 正常的 struct 就是普通的一個內存塊,必定是佔用一小塊內存的,並且結構體的大小是要經過邊界,長度的對齊的,但是「空結構體」是不佔內存的,size 為 0;
提示:以下都是基於 go1.13.3 linux/amd64 分析。
普通的結構體定義如下:
// 類型變量對齊到 8 字節;
type Tp struct {
a uint16
b uint32
}按照內存對齊規則,這個結構體佔用 8 個字節的內存。關於內存分配的基礎知識可以翻看:Golang 數據結構到底是怎麼回事?gdb調一調?,golang 內存管理分析。
空結構體:
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 ),所以編譯器對以上的 Object,Object1 兩種類型的處理和空結構體類型是一致的,分配地址為 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{ }編譯器不會對 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 { } 作為最後一個欄位,會被填充對齊到前一個欄位的大小,地址偏移對齊規則不變;
可以現在心裡思考下,o1,o2,o3,o4 這四個對象的內存分配分別佔多少空間?下面解密:
這種情況,需要先把 struct {} 按照前一個欄位的長度分配 padding 內存,然後整個變量按照地址和長度的對齊規則不變。
struct {} 作為 receiverreceiver 這個是 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 retqmain 函數
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>通過這段代碼證實幾個點:
receiver 其實就是一種語法糖,本質上就是作為第一個參數傳入函數;receiver 為值的場景,不需要傳空結構體做第一個參數,因為空結構體沒有值;receiver 為一個指針的場景,對象地址作為第一個參數傳入函數,函數調用的時候,編譯器傳入 zerobase 的值(編譯期間就可以確認);在二進位編譯之後,一般 e.FuncA 的調用,第一個參數是直接壓入 &zerobase 到棧裡。
總結幾個知識點:
receiver 本質上是非常簡單的一個通用思路,就是把對象值或地址作為第一參數傳入函數;對象值作為 receiver 的時候,涉及到一次值拷貝;golang 對於值做 receiver 的函數定義,會根據現實需要情況可能會生成了兩個函數,一個值版本,一個指針版本(思考:什麼是「需要情況」?就是有 interface 的場景 );空結構體在編譯期間就能識別出來的場景,編譯器會對既定的事實,可以做特殊的代碼生成;可以這麼說,編譯期間,關於空結構體的參數基本都能確定,那麼代碼生成的時候,就可以生成對應的靜態代碼。
程序 debug 技巧和工具介紹可以翻看:golang 調試分析的高階技巧。
空結構體 struct{ } 為什麼會存在的核心理由就是為了節省內存。當你需要一個結構體,但是卻絲毫不關係裡面的內容,那麼就可以考慮空結構體。golang 核心的幾個複合結構 map ,chan ,slice 都能結合 struct{} 使用。
map & struct{}map 和 struct {} 一般的結合姿勢是這樣的:
// 創建 map
m := make(map[int]struct{})
// 賦值
m[1] = struct{}{}
// 判斷 key 鍵存不存在
_, ok := m[1]一般 map 和 struct {} 的結合使用場景是:只關心 key,不關注值。比如查詢 key 是否存在就可以用這個數據結構,通過 ok 的值來判斷這個鍵是否存在,map 的查詢複雜度是 O(1) 的,查詢很快。
你當然可以用 map[int]bool 這種類型來代替,功能也一樣能實現,很多人考慮使用 map[int]struct{} 這種使用方式真的就是為了省點內存,當然大部分情況下,這種節省是不足道哉的,所以究竟要不要這樣使用還是要看具體場景。
chan & struct{}channel 和 struct{} 結合是一個最經典的場景,struct{} 通常作為一個信號來傳輸,並不關注其中內容。chan 的分析在前幾篇文章有詳細說明。chan 本質的數據結構是一個管理結構加上一個 ringbuffer ,如果 struct{} 作為元素的話,ringbuffer 就是 0 分配的。
chan 和 struct{} 結合基本只有一種用法,就是信號傳遞,空結構體本身攜帶不了值,所以也只有這一種用法啦,一般來說,配合 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}
}
}
總結空結構體也是結構體,只是 size 為 0 的類型而已;所有的空結構體都有一個共同的地址:zerobase 的地址;空結構體可以作為 receiver ,receiver 是空結構體作為值的時候,編譯器其實直接忽略了第一個參數的傳遞,編譯器在編譯期間就能確認生成對應的代碼;map 和 struct{} 結合使用常常用來節省一點點內存,使用的場景一般用來判斷 key 存在於 map;chan 和 struct{} 結合使用是一般用於信號同步的場景,用意並不是節省內存,而是我們真的並不關心 chan 元素的值;slice 和 struct{} 結合好像真的沒啥用。。。