Go 內(nèi)存對(duì)齊的那些事兒
在討論內(nèi)存對(duì)齊前我們先看一個(gè)思考題,我們都知道Go的結(jié)構(gòu)體在內(nèi)存中是由一塊連續(xù)的內(nèi)存表示的,那么下面的結(jié)構(gòu)體占用的內(nèi)存大小是多少呢?
type ST1 struct {
A byte
B int64
C byte
}
在64位系統(tǒng)下 byte 類型就只占1字節(jié),int64 占用的是8個(gè)字節(jié),按照數(shù)據(jù)類型占的字節(jié)數(shù)推理,很快就能得出結(jié)論:這個(gè)結(jié)構(gòu)體的內(nèi)存大小是10個(gè)字節(jié) (1 + 8 +1 )。這個(gè)推論到底對(duì)不對(duì)呢?我們讓 Golang 自己揭曉一下答案。
package main
import (
"fmt"
"unsafe"
)
type ST1 struct {
A byte
B int64
C byte
}
func main() {
fmt.Println("ST1.A 占用的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Sizeof(ST1{}.A)))
fmt.Println("ST1.A 對(duì)齊的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Alignof(ST1{}.A)))
fmt.Println("ST1.B 占用的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Sizeof(ST1{}.B)))
fmt.Println("ST1.B 對(duì)齊的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Alignof(ST1{}.B)))
fmt.Println("ST1.C 占用的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Sizeof(ST1{}.C)))
fmt.Println("ST1.C 對(duì)齊的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Alignof(ST1{}.C)))
fmt.Println("ST1結(jié)構(gòu)體 占用的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Sizeof(ST1{})))
fmt.Println("ST1結(jié)構(gòu)體 對(duì)齊的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Alignof(ST1{})))
}
## 輸出
ST1.A 占用的字節(jié)數(shù)是:1
ST1.A 對(duì)齊的字節(jié)數(shù)是:1
ST1.B 占用的字節(jié)數(shù)是:8
ST1.B 對(duì)齊的字節(jié)數(shù)是:8
ST1.C 占用的字節(jié)數(shù)是:1
ST1.C 對(duì)齊的字節(jié)數(shù)是:1
ST1結(jié)構(gòu)體 占用的字節(jié)數(shù)是:24
ST1結(jié)構(gòu)體 對(duì)齊的字節(jié)數(shù)是:8
Golang 告訴我們 ST1 結(jié)構(gòu)體占用的字節(jié)數(shù)是24。但是每個(gè)字段占用的字節(jié)數(shù)總共加起來確實(shí)是只有10個(gè)字節(jié),這是怎么回事呢?
因?yàn)樽侄蜝占用的字節(jié)數(shù)是8,內(nèi)存對(duì)齊的字節(jié)數(shù)也是8,A字段所在的8個(gè)字節(jié)里不足以存放字段B,所以只好留下7個(gè)字節(jié)的空洞,在下一個(gè) 8 字節(jié)存放字段B。又因?yàn)榻Y(jié)構(gòu)體ST1是8字節(jié)對(duì)齊的(可以理解為占的內(nèi)存空間必須是8字節(jié)的倍數(shù),且起始地址能夠整除8),所以 C 字段占據(jù)了下一個(gè)8字節(jié),但是又留下了7個(gè)字節(jié)的空洞。

這樣ST1結(jié)構(gòu)體總共占用的字節(jié)數(shù)正好是 24 字節(jié)。
既然知道了 Go 編譯器在對(duì)結(jié)構(gòu)體進(jìn)行內(nèi)存對(duì)齊的時(shí)候會(huì)在字段之間留下內(nèi)存空洞,那么我們把只需要 1 個(gè)字節(jié)對(duì)齊的字段 C 放在需要 8 個(gè)字節(jié)內(nèi)存對(duì)齊的字段 B 前面就能讓結(jié)構(gòu)體 ST1 少占 8 個(gè)字節(jié)。下面我們把 ST1 的 C 字段放在 B 的前面再觀察一下 ST1 結(jié)構(gòu)體的大小。
package main
import (
"fmt"
"unsafe"
)
type ST1 struct {
A byte
C byte
B int64
}
func main() {
fmt.Println("ST1.A 占用的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Sizeof(ST1{}.A)))
fmt.Println("ST1.A 對(duì)齊的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Alignof(ST1{}.A)))
fmt.Println("ST1.B 占用的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Sizeof(ST1{}.B)))
fmt.Println("ST1.B 對(duì)齊的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Alignof(ST1{}.B)))
fmt.Println("ST1.C 占用的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Sizeof(ST1{}.C)))
fmt.Println("ST1.C 對(duì)齊的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Alignof(ST1{}.C)))
fmt.Println("ST1結(jié)構(gòu)體 占用的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Sizeof(ST1{})))
fmt.Println("ST1結(jié)構(gòu)體 對(duì)齊的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Alignof(ST1{})))
}
## 輸出
ST1.A 占用的字節(jié)數(shù)是:1
ST1.A 對(duì)齊的字節(jié)數(shù)是:1
ST1.B 占用的字節(jié)數(shù)是:8
ST1.B 對(duì)齊的字節(jié)數(shù)是:8
ST1.C 占用的字節(jié)數(shù)是:1
ST1.C 對(duì)齊的字節(jié)數(shù)是:1
ST1結(jié)構(gòu)體 占用的字節(jié)數(shù)是:16
ST1結(jié)構(gòu)體 對(duì)齊的字節(jié)數(shù)是:8
重排字段后,ST1 結(jié)構(gòu)體的內(nèi)存布局變成了下圖這樣

僅僅只是調(diào)換了一下順序,結(jié)構(gòu)體 ST1 就減少了三分之一的內(nèi)存占用空間。在實(shí)際編程應(yīng)用時(shí)大部分時(shí)候我們不用太過于注意內(nèi)存對(duì)齊對(duì)數(shù)據(jù)結(jié)構(gòu)空間的影響,不過作為工程師了解內(nèi)存對(duì)齊這個(gè)知識(shí)還是很重要的,它實(shí)際上是一種典型的以空間換時(shí)間的策略。
內(nèi)存對(duì)齊
操作系統(tǒng)在讀取數(shù)據(jù)的時(shí)候并非按照我們想象的那樣一個(gè)字節(jié)一個(gè)字節(jié)的去讀取,而是一個(gè)字一個(gè)字的去讀取。
字是用于表示其自然的數(shù)據(jù)單位,也叫
machine word。字是系統(tǒng)用來一次性處理事務(wù)的一個(gè)固定長度。字長 / 步長 就是一個(gè)字可容納的字節(jié)數(shù),一般 N 位系統(tǒng)的字長是 (N / 8) 個(gè)字節(jié)。
因此,當(dāng) CPU 從存儲(chǔ)器讀數(shù)據(jù)到寄存器,或者從寄存器寫數(shù)據(jù)到存儲(chǔ)器,每次 IO 的數(shù)據(jù)長度是字長。如 32 位系統(tǒng)訪問粒度是 4 字節(jié)(bytes),64 位系統(tǒng)的就是 8 字節(jié)。當(dāng)被訪問的數(shù)據(jù)長度為 n 字節(jié)且該數(shù)據(jù)的內(nèi)存地址為 n 字節(jié)對(duì)齊,那么操作系統(tǒng)就可以高效地一次定位到數(shù)據(jù),無需多次讀取、處理對(duì)齊運(yùn)算等額外操作。
內(nèi)存對(duì)齊的原則是:將數(shù)據(jù)盡量的存儲(chǔ)在一個(gè)字長內(nèi),避免跨字長的存儲(chǔ)。
Go 官方文檔中對(duì)數(shù)據(jù)類型的內(nèi)存對(duì)齊也有如下保證:
對(duì)于任何類型的變量 x,unsafe.Alignof(x) 的結(jié)果最小為1 (類型最小是一字節(jié)對(duì)齊的)。 對(duì)于一個(gè)結(jié)構(gòu)體類型的變量 x,unsafe.Alignof(x) 的結(jié)果為 x 的所有字段的對(duì)齊字節(jié)數(shù)中的最大值。 對(duì)于一個(gè)數(shù)組類型的變量 x , unsafe.Alignof(x) 的結(jié)果和此數(shù)組的元素類型的一個(gè)變量的對(duì)齊字節(jié)數(shù)相等,也就是 unsafe.Alignof(x) == unsafe.Alignof(x[i])。
下面這個(gè)表格列出了每種數(shù)據(jù)類型對(duì)齊的字節(jié)數(shù)
| 數(shù)據(jù)類型 | 對(duì)齊字節(jié)數(shù) |
|---|---|
| bool, byte, unit8 int8 | 1 |
| uint16, int16 | 2 |
| uint32, int32, float32, complex64 | 4 |
| uint64, int64, float64, complex64 | 8 |
| array | 由其元素類型決定 |
| struct | 由其字段類型決定, 最小為1 |
| 其他類型 | 8 |
零字節(jié)類型的對(duì)齊
我們都知道 struct{} 類型占用的字節(jié)數(shù)是 0,但其實(shí)它的內(nèi)存對(duì)齊數(shù)是 1,這么設(shè)定的原因?yàn)榱吮WC當(dāng)它作為結(jié)構(gòu)體的末尾字段時(shí),不會(huì)訪問到其他數(shù)據(jù)結(jié)構(gòu)的地址。比如像下面這個(gè)結(jié)構(gòu)體 ST2
type ST2 struct {
A uint32
B uint64
C struct{}
}
雖然字段 C 占用的字節(jié)數(shù)為0,但是編譯器會(huì)為它補(bǔ) 8 個(gè)字節(jié),這樣就能保證訪問字段 C 的時(shí)候不會(huì)訪問到其他數(shù)據(jù)結(jié)構(gòu)的內(nèi)存地址。
type ST2 struct {
A uint32
B uint64
C struct{}
}
func main() {
fmt.Println("ST2.C 占用的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Sizeof(ST2{}.C)))
fmt.Println("ST2.C 對(duì)齊的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Alignof(ST2{}.C)))
fmt.Println("ST2 結(jié)構(gòu)體占用的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Sizeof(ST2{})))
}
## 輸出
ST2.C 占用的字節(jié)數(shù)是:0
ST2.C 對(duì)齊的字節(jié)數(shù)是:1
ST2 結(jié)構(gòu)體占用的字節(jié)數(shù)是:24
當(dāng)然因?yàn)?C 前一個(gè)字段 B 占據(jù)了整個(gè)字長,如果把 A 和 B 的順序調(diào)換一下,因?yàn)?A 只占 4 個(gè)字節(jié),C 的對(duì)齊字節(jié)數(shù)是 1, 足夠排在這個(gè)字剩余的字節(jié)里。這樣一來 ST2 結(jié)構(gòu)體的占用空間就能減少到 16 個(gè)字節(jié)。
type ST2 struct {
B uint64
A uint32
C struct{}
}
func main() {
fmt.Println("ST2.C 占用的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Sizeof(ST2{}.C)))
fmt.Println("ST2.C 對(duì)齊的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Alignof(ST2{}.C)))
fmt.Println("ST2 結(jié)構(gòu)體占用的字節(jié)數(shù)是:" + fmt.Sprint(unsafe.Sizeof(ST2{})))
}
## 輸出
ST2.C 占用的字節(jié)數(shù)是:0
ST2.C 對(duì)齊的字節(jié)數(shù)是:1
ST2 結(jié)構(gòu)體占用的字節(jié)數(shù)是:16
總結(jié)
內(nèi)存對(duì)齊在我理解就是為了計(jì)算機(jī)訪問數(shù)據(jù)的效率,對(duì)于像結(jié)構(gòu)體、數(shù)組等這樣的占用連續(xù)內(nèi)存空間的復(fù)合數(shù)據(jù)結(jié)構(gòu)來說:
數(shù)據(jù)結(jié)構(gòu)占用的字節(jié)數(shù)是對(duì)齊字節(jié)數(shù)的整數(shù)倍。 數(shù)據(jù)結(jié)構(gòu)的邊界地址能夠整除整個(gè)數(shù)據(jù)結(jié)構(gòu)的對(duì)齊字節(jié)數(shù)。
這樣 CPU 既減少了對(duì)內(nèi)存的讀取次數(shù),也不需要再對(duì)讀取到的數(shù)據(jù)進(jìn)行篩選和拼接,是一種典型的以空間換時(shí)間的方法。
希望通過這篇文章能讓你更了解 Go 語言也更了解內(nèi)存對(duì)齊這個(gè)計(jì)算機(jī)操作系統(tǒng)減少內(nèi)存訪問頻率的機(jī)制。
