一文帶你了解Go中的內(nèi)存對齊
前言
在一次工作中,需要使用 Go 調(diào)用 DLL 文件,其中就涉及到內(nèi)存對齊的相關(guān)知識,如果自定義的結(jié)構(gòu)體內(nèi)存布局和所調(diào)用的 DLL 結(jié)構(gòu)體內(nèi)存布局不一致,就會無法正確調(diào)用。所以,一旦涉及到較為底層的編程,特別是與硬件交互,內(nèi)存對齊是一個必修的課題。
基礎(chǔ)知識
在正式了解內(nèi)存對齊前,我們先來看一個方法 unsafe.Sizeof(),它可以獲取任意一個變量占據(jù)的內(nèi)存大小,即這個變量在內(nèi)存中所占據(jù)的字節(jié)數(shù)。Go 內(nèi)置的變量類型占據(jù)內(nèi)存大小情況如下:
類型 | 字節(jié)數(shù) |
---|---|
bool | 1 |
string | 2 * 計算機字長/8 (64位16個字節(jié),32位8個字節(jié)) |
int、uint、uintptr | 計算機字長/8 (64位8個字節(jié),32位4個字節(jié)) |
*T, map, func, chan | 計算機字長/8 (64位8個字節(jié),32位4個字節(jié)) |
intN, uintN, floatN, complexN | N/8個字節(jié)(int32是4個字節(jié),float64是8個字節(jié)) |
interface | 2 * 計算機字長/8 (64位16個字節(jié),32位8個字節(jié)) |
[]T | 3 * 計算機字長/8 (64位24個字節(jié),32位12個字節(jié)) |
對于切片類型而言,字節(jié)數(shù)是固定的24字節(jié)或者12字節(jié)(32位系統(tǒng)上),而對于數(shù)組類型而言,它的大小是元素數(shù)量 * 元素類型字節(jié)數(shù):
var ( slice []int8 array [3]int8 ) fmt.Printf("切片:%v\n", unsafe.Sizeof(slice)) fmt.Printf("數(shù)組:%v\n", unsafe.Sizeof(array)) // 結(jié)果 切片:24 數(shù)組:3
對于復(fù)合結(jié)構(gòu),也就是結(jié)構(gòu)體,其情況就會變的復(fù)雜起來:
type MemStruct struct { b bool // 1 i8 int8 // 1 i16 int16 // 2 i32 int32 // 4 } type MemStruct2 struct { b bool // 1 i8 int8 // 1 i32 int32 // 4 } func TestStruct(t *testing.T) { fmt.Println("MemStruct:", unsafe.Sizeof(MemStruct{})) fmt.Println("MemStruct2:", unsafe.Sizeof(MemStruct2{})) } // 結(jié)果 MemStruct: 8 MemStruct2: 8
嗯,看完代碼,是不是發(fā)現(xiàn)了哪里不對,MemStruct 和 MemStruct2 兩個結(jié)構(gòu)體的 SizeOf 值居然是一樣的?MemStruct 還好理解, 1 + 1 + 2 + 4 = 8 當(dāng)然沒問題,那 MemStruct2 是怎么回事?這個問題我們暫且按下不表, 先來看一下 CPU 是怎么訪問內(nèi)存的。
CPU 訪問內(nèi)存
CPU 訪問內(nèi)存時,并不是逐個字節(jié)訪問,而是按照字長位單位訪問,比如 32 位系統(tǒng),CPU 一次性讀取 4 字節(jié),64位則一次性讀取 8 字節(jié)。對于上文的 MemStruct2 結(jié)構(gòu)體,假使它在內(nèi)存中是這樣的(實際上不是,這里是為了方便理解):
那么,此時我們以 4 字長來讀取 Int32 變量,CPU 就必須要讀取兩次內(nèi)存,然后將兩次讀取的結(jié)果進行整理,最終得到完整的數(shù)據(jù):
讀取一個變量需要訪問兩次內(nèi)存訪問?這既不優(yōu)雅也不高效,而且不利于變量操作的原子性。那么,Go 編譯器是怎么解決這個問題呢,可能你也想到了,我們把結(jié)構(gòu)體內(nèi)存調(diào)整一下,在 Int8 之后填充兩個空字節(jié):
經(jīng)過調(diào)整,我們就可以一次性的讀取出 Int32。這種調(diào)整方式有一個響亮的名字——內(nèi)存對齊。內(nèi)存對齊是 Go 編譯器來完成的,它對于程序員是透明的。我們的 MemStruct2 結(jié)構(gòu)體之所以會多出 2 個字節(jié),正是因為內(nèi)存對齊的原因。
為什么需要內(nèi)存對齊
如上文所說,內(nèi)存對齊可以保障變量被 CPU 一次性的讀取出來,這可以減少CPU訪問內(nèi)存的次數(shù),加大CPU訪問內(nèi)存的吞吐量。一次性的讀取變量,也保證變量的原子操作性。除此之外,有些硬件平臺不支持訪問任意地址的任意數(shù)據(jù),如果不進行內(nèi)存對齊,編程語言就喪失了平臺可移植性。內(nèi)存對齊賦予了編程語言的可移植性。
當(dāng)然,內(nèi)存對齊也有一些缺點:
- 因為會置空一些內(nèi)存,所以會造成一定量的內(nèi)存浪費;
- 會增加編譯器的復(fù)雜度,編譯器需要根據(jù)不同的平臺和指令集來確定合適的對齊方式,并且需要處理一些特殊的情況,比如位域、聯(lián)合體、指針等。
對齊保證
unsafe.Alignof(x)
返回一個類型的對齊系數(shù),對于 Go 的基礎(chǔ)類型來說,這個值會取 計算機字長/8
和 unsafe.Sizeof(x)
中較小的一個值。即 min(計算機字長/8,unsafe.Sizeof(x))
:
func TestAlignOf(t *testing.T) { var ( s string i8 int8 ) fmt.Printf("string sizeof:%v, alignof: %v\n", unsafe.Sizeof(s), unsafe.Alignof(s)) // min(8, 1) = 1 fmt.Printf("int8 sizeof:%v, alignof: %v\n", unsafe.Sizeof(i8), unsafe.Alignof(i8)) // min(8, 16) = 16 }
對于 64 位操作系統(tǒng)來說,計算機字長64/8 = 8。int8 的 SizeOf 是 1,與 8 對比較小,所以 int8 的對齊系數(shù)就是1;string 的 SizeOf 是16,與 8 相比較大,所以 string 的對齊系數(shù)就是 8。
對于數(shù)組和結(jié)構(gòu)體類型來說,情況則有些特殊。在 The Go Programming Language Specification 一文中提到了三點,其中后兩點說的就是結(jié)構(gòu)體和數(shù)組:
- For a variable x of any type: unsafe.Alignof(x) is at least 1.
- For a variable x of struct type: unsafe.Alignof(x) is the largest of all the values unsafe.Alignof(x.f) for each field f of x, but at least 1.
- For a variable x of array type: unsafe.Alignof(x) is the same as the alignment of a variable of the array's element type.
這段話翻譯過來就是:
- 對于任意類型的變量 x ,unsafe.Alignof(x) 至少為 1;
- 對于 struct 結(jié)構(gòu)體類型的變量 x,計算 x 每一個字段 f 的 unsafe.Alignof(x.f),unsafe.Alignof(x) 等于其中的最大值;
- 對于 array 數(shù)組類型的變量 x,unsafe.Alignof(x) 等于構(gòu)成數(shù)組的元素類型的對齊倍數(shù)。
第一點容易理解,沒有哪個類型對齊系數(shù)會小于1的,不然那不就亂套了嗎。第二點的意思就是說對于任意結(jié)構(gòu)體而言,它的對齊系數(shù)會等于它所包含字段中對齊系數(shù)最大的那一個:
type MemStruct struct { b bool // alignof: 1 i8 int8 // alignof: 1 i32 int32 // alignof: 4 s string // alignof: 8 } func TestStruct(t *testing.T) { fmt.Printf("MemStruct的對齊系數(shù):{%v}, 等于string的對齊系數(shù):{%v}", unsafe.Alignof(MemStruct{}), unsafe.Alignof(string("1"))) } // 結(jié)果 MemStruct的對齊系數(shù):{8}, 等于string的對齊系數(shù):{8}
第三點就是說對于數(shù)組類型而言,它的對齊系數(shù)等于它構(gòu)成元素類型的對齊系數(shù):
func TestArray(t *testing.T) { var ( it interface{} arr [3]interface{} ) fmt.Printf("數(shù)組interface{}的對齊系數(shù):{%v}, 等于interface的對齊系數(shù):{%v}", unsafe.Alignof(arr), unsafe.Alignof(it)) } // 結(jié)果 數(shù)組interface{}的對齊系數(shù):{8}, 等于interface的對齊系數(shù):{8}
以上兩個例子的基于 64 位操作系統(tǒng)。
結(jié)構(gòu)體對齊技巧
合理的布局可以減少內(nèi)存浪費,假使我們現(xiàn)在有一個結(jié)構(gòu)體有 int8、int16、int32 三個字段,那么這三個字段在結(jié)構(gòu)體的順序會影響結(jié)構(gòu)體的內(nèi)存占用嗎?我們來看一個例子:
type S1 struct { i8 int8 i16 int16 i32 int32 } type S2 struct { i8 int8 i32 int32 i16 int16 } func TestLeastMem(t *testing.T) { fmt.Printf("S1的占用: %v\n", unsafe.Sizeof(S1{})) fmt.Printf("S2的占用: %v\n", unsafe.Sizeof(S2{})) } // 結(jié)果 S1的占用: 8 S2的占用: 12
可以看到,S1 明顯占用的內(nèi)存更少。讓我們來分析一下,S1 和 S2 的對齊系數(shù)都等于其子字段 int16 的對齊系數(shù),也就是 4。對于S1,經(jīng)過內(nèi)存對齊,它們在內(nèi)存中的布局是這樣的:
對于 S1:
- i8 是第一個字段,默認已經(jīng)對齊,從 0 開始占據(jù) 1 個字節(jié);
- i16 是第二個字段,對齊系數(shù)為 2,因此,必須填充 1 個字節(jié),其偏移量才是 2 的倍數(shù),從 2 開始占據(jù) 2 字節(jié);
- i32 是第三個字段,對齊系數(shù)為 4,此時,內(nèi)存已經(jīng)是對齊的,從第 4 開始占據(jù) 4 字節(jié)即可;
因此 S1 在內(nèi)存占用了 8 個字節(jié),浪費了 1 個字節(jié)。
對于 S2:
- i8 是第一個字段,默認已經(jīng)對齊,從 0 開始占據(jù) 1 個字節(jié);
- i32 是第二個字段,對齊系數(shù)為 4,因此,必須填充 3 個字節(jié),其偏移量才是 4 的倍數(shù),從第 4 開始占據(jù) 4 字節(jié);
- i16 是第三個字段,對齊系數(shù)為 2,此時,內(nèi)存已經(jīng)是對齊的,從第 8 開始占據(jù) 2 字節(jié)即可。
因此 S2 在內(nèi)存占用了 12 個字節(jié),浪費了 5 個字節(jié)。
空結(jié)構(gòu)體對齊保證
對于空結(jié)構(gòu)體,其 Sizeof 為0,Alignof 是 1,一般其作為其他結(jié)構(gòu)體字段時,不需要內(nèi)存對齊,但有一種情況除外:在結(jié)構(gòu)體末尾。為什么呢?我們先要知道一件事情:因為空結(jié)構(gòu)體的 Size 為0,所以編譯器會把 zerobase 的地址分配出去,這體現(xiàn)在 src/runtime/malloc.go 878 行中(Go 1.20.4):
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer { ... if size == 0 { return unsafe.Pointer(&zerobase) } ... }
zerobase 是 Go 定義的一個 uintptr 的特殊全局變量,占據(jù) 8 個字節(jié)。因為所有空接口體的地址都指向 zerobase,所以所有空結(jié)構(gòu)體的內(nèi)存地址都是一樣的!這樣做就可以使所有的空結(jié)構(gòu)體有一個獨一無二的內(nèi)存地址,不與 nil 混淆,而且多個空結(jié)構(gòu)體不會占用額外的內(nèi)存。空結(jié)構(gòu)體有內(nèi)存地址卻不占用內(nèi)存,這個概念很重要!
有了這個概念,我們就比較容易理解為什么空結(jié)構(gòu)體在末尾需要內(nèi)存對齊了。當(dāng)空結(jié)構(gòu)體類型作為結(jié)構(gòu)體的最后一個字段時,如果有指向該字段的指針,那么就會返回該結(jié)構(gòu)體之外的地址,導(dǎo)致內(nèi)存泄露。為了避免這種情況就需要進行一次內(nèi)存對齊,且內(nèi)存占用大小和前一個變量的大小保持一致:
type emptyStruct struct{} type S1 struct { empty emptyStruct i8 int8 } type S2 struct { i8 int8 empty emptyStruct } type S3 struct { i16 int16 empty emptyStruct } type S4 struct { i16 int16 i8 int8 empty emptyStruct } func TestSpaceStructMem(t *testing.T) { fmt.Printf("S1的占用: %v\n", unsafe.Sizeof(S1{})) fmt.Printf("S2的占用: %v\n", unsafe.Sizeof(S2{})) fmt.Printf("S3的占用: %v\n", unsafe.Sizeof(S3{})) fmt.Printf("S4的占用: %v\n", unsafe.Sizeof(S4{})) // S3 空結(jié)構(gòu)從第二位開始,往后補充兩個字節(jié) fmt.Printf("S3的空結(jié)構(gòu)體偏移量: %v\n", unsafe.Offsetof(S3{}.empty)) // S4 空結(jié)構(gòu)從第三位開始,往后補充一個字節(jié) fmt.Printf("S4的空結(jié)構(gòu)體偏移量: %v\n", unsafe.Offsetof(S4{}.empty)) } // 結(jié)果 S1的占用: 1 S2的占用: 2 S3的占用: 4 S4的占用: 4 S3的空結(jié)構(gòu)體偏移量: 2 S4的空結(jié)構(gòu)體偏移量: 3
以上就是一文帶你了解Go中的內(nèi)存對齊的詳細內(nèi)容,更多關(guān)于go內(nèi)存對齊的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
基于go interface{}==nil 的幾種坑及原理分析
這篇文章主要介紹了基于go interface{}==nil 的幾種坑及原理分析,具有很好的參考價值,希望對大家有所幫助。一起跟隨小編過來看看吧2021-04-04淺析Go項目中的依賴包管理與Go?Module常規(guī)操作
這篇文章主要為大家詳細介紹了Go項目中的依賴包管理與Go?Module常規(guī)操作,文中的示例代碼講解詳細,對我們深入了解Go語言有一定的幫助,需要的可以跟隨小編一起學(xué)習(xí)一下2023-10-10VSCode1.4 搭建Golang的開發(fā)調(diào)試環(huán)境(遇到很多問題)
這篇文章主要介紹了VSCode1.4 搭建Golang的開發(fā)調(diào)試環(huán)境(遇到很多問題),文中通過示例代碼介紹的非常詳細,對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價值,需要的朋友們下面隨著小編來一起學(xué)習(xí)學(xué)習(xí)吧2020-04-04