在 Go 中恰到好處的內存對齊

image

原文地址:在 Go 中恰到好處的內存對齊html

問題

type Part1 struct {
    a bool
    b int32
    c int8
    d int64
    e byte
}

在開始以前,但願你計算一下 Part1 共佔用的大小是多少呢?golang

func main() {
    fmt.Printf("bool size: %d\n", unsafe.Sizeof(bool(true)))
    fmt.Printf("int32 size: %d\n", unsafe.Sizeof(int32(0)))
    fmt.Printf("int8 size: %d\n", unsafe.Sizeof(int8(0)))
    fmt.Printf("int64 size: %d\n", unsafe.Sizeof(int64(0)))
    fmt.Printf("byte size: %d\n", unsafe.Sizeof(byte(0)))
    fmt.Printf("string size: %d\n", unsafe.Sizeof("EDDYCJY"))
}

輸出結果:數組

bool size: 1
int32 size: 4
int8 size: 1
int64 size: 8
byte size: 1
string size: 16

這麼一算,Part1 這一個結構體的佔用內存大小爲 1+4+1+8+1 = 15 個字節。相信有的小夥伴是這麼算的,看上去也沒什麼毛病佈局

真實狀況是怎麼樣的呢?咱們實際調用看看,以下:性能

type Part1 struct {
    a bool
    b int32
    c int8
    d int64
    e byte
}

func main() {
    part1 := Part1{}
    
    fmt.Printf("part1 size: %d, align: %d\n", unsafe.Sizeof(part1), unsafe.Alignof(part1))
}

輸出結果:學習

part1 size: 32, align: 8

最終輸出爲佔用 32 個字節。這與前面所預期的結果徹底不同。這充分地說明了先前的計算方式是錯誤的。爲何呢?優化

在這裏要提到 「內存對齊」 這一律念,纔可以用正確的姿式去計算,接下來咱們詳細的講講它是什麼spa

內存對齊

有的小夥伴可能會認爲內存讀取,就是一個簡單的字節數組擺放調試

image

上圖表示一個坑一個蘿蔔的內存讀取方式。但實際上 CPU 並不會以一個一個字節去讀取和寫入內存。相反 CPU 讀取內存是一塊一塊讀取的,塊的大小能夠爲 二、四、六、八、16 字節等大小。塊大小咱們稱其爲內存訪問粒度。以下圖:code

image

在樣例中,假設訪問粒度爲 4。 CPU 是以每 4 個字節大小的訪問粒度去讀取和寫入內存的。這纔是正確的姿式

爲何要關心對齊

  • 你正在編寫的代碼在性能(CPU、Memory)方面有必定的要求
  • 你正在處理向量方面的指令
  • 某些硬件平臺(ARM)體系不支持未對齊的內存訪問

另外做爲一個工程師,你也頗有必要學習這塊知識點哦 :)

爲何要作對齊

  • 平臺(移植性)緣由:不是全部的硬件平臺都可以訪問任意地址上的任意數據。例如:特定的硬件平臺只容許在特定地址獲取特定類型的數據,不然會致使異常狀況
  • 性能緣由:若訪問未對齊的內存,將會致使 CPU 進行兩次內存訪問,而且要花費額外的時鐘週期來處理對齊及運算。而自己就對齊的內存僅須要一次訪問就能夠完成讀取動做

image

在上圖中,假設從 Index 1 開始讀取,將會出現很崩潰的問題。由於它的內存訪問邊界是不對齊的。所以 CPU 會作一些額外的處理工做。以下:

  1. CPU 首次讀取未對齊地址的第一個內存塊,讀取 0-3 字節。並移除不須要的字節 0
  2. CPU 再次讀取未對齊地址的第二個內存塊,讀取 4-7 字節。並移除不須要的字節 五、六、7 字節
  3. 合併 1-4 字節的數據
  4. 合併後放入寄存器

從上述流程可得出,不作 「內存對齊」 是一件有點 "麻煩" 的事。由於它會增長許多耗費時間的動做

而假設作了內存對齊,從 Index 0 開始讀取 4 個字節,只須要讀取一次,也不須要額外的運算。這顯然高效不少,是標準的空間換時間作法

默認係數

在不一樣平臺上的編譯器都有本身默認的 「對齊係數」,可經過預編譯命令 #pragma pack(n) 進行變動,n 就是代指 「對齊係數」。通常來說,咱們經常使用的平臺的係數以下:

  • 32 位:4
  • 64 位:8

另外要注意,不一樣硬件平臺佔用的大小和對齊值均可能是不同的。所以本文的值不是惟一的,調試的時候需按本機的實際狀況考慮

成員對齊

func main() {
    fmt.Printf("bool align: %d\n", unsafe.Alignof(bool(true)))
    fmt.Printf("int32 align: %d\n", unsafe.Alignof(int32(0)))
    fmt.Printf("int8 align: %d\n", unsafe.Alignof(int8(0)))
    fmt.Printf("int64 align: %d\n", unsafe.Alignof(int64(0)))
    fmt.Printf("byte align: %d\n", unsafe.Alignof(byte(0)))
    fmt.Printf("string align: %d\n", unsafe.Alignof("EDDYCJY"))
    fmt.Printf("map align: %d\n", unsafe.Alignof(map[string]string{}))
}

輸出結果:

bool align: 1
int32 align: 4
int8 align: 1
int64 align: 8
byte align: 1
string align: 8
map align: 8

在 Go 中能夠調用 unsafe.Alignof 來返回相應類型的對齊係數。經過觀察輸出結果,可得知基本都是 2^n,最大也不會超過 8。這是由於我手提(64 位)編譯器默認對齊係數是 8,所以最大值不會超過這個數

總體對齊

在上小節中,提到告終構體中的成員變量要作字節對齊。那麼想固然身爲最終結果的結構體,也是須要作字節對齊的

對齊規則

  • 結構體的成員變量,第一個成員變量的偏移量爲 0。日後的每一個成員變量的對齊值必須爲編譯器默認對齊長度#pragma pack(n))或當前成員變量類型的長度unsafe.Sizeof),取最小值做爲當前類型的對齊值。其偏移量必須爲對齊值的整數倍
  • 結構體自己,對齊值必須爲編譯器默認對齊長度#pragma pack(n))或結構體的全部成員變量類型中的最大長度,取最大數的最小整數倍做爲對齊值
  • 結合以上兩點,可得知若編譯器默認對齊長度#pragma pack(n))超過結構體內成員變量的類型最大長度時,默認對齊長度是沒有任何意義的

分析流程

接下來咱們一塊兒分析一下,「它」 到底經歷了些什麼,影響了 「預期」 結果

成員變量 類型 偏移量 自身佔用
a bool 0 1
字節對齊 1 3
b int32 4 4
c int8 8 1
字節對齊 9 7
d int64 16 8
e byte 24 1
字節對齊 25 7
總佔用大小 - - 32

成員對齊

  • 第一個成員 a

    • 類型爲 bool
    • 大小/對齊值爲 1 字節
    • 初始地址,偏移量爲 0。佔用了第 1 位
  • 第二個成員 b

    • 類型爲 int32
    • 大小/對齊值爲 4 字節
    • 根據規則 1,其偏移量必須爲 4 的整數倍。肯定偏移量爲 4,所以 2-4 位爲 Padding。而當前數值從第 5 位開始填充,到第 8 位。以下:axxx|bbbb
  • 第三個成員 c

    • 類型爲 int8
    • 大小/對齊值爲 1 字節
    • 根據規則1,其偏移量必須爲 1 的整數倍。當前偏移量爲 8。不須要額外對齊,填充 1 個字節到第 9 位。以下:axxx|bbbb|c...
  • 第四個成員 d

    • 類型爲 int64
    • 大小/對齊值爲 8 字節
    • 根據規則 1,其偏移量必須爲 8 的整數倍。肯定偏移量爲 16,所以 9-16 位爲 Padding。而當前數值從第 17 位開始寫入,到第 24 位。以下:axxx|bbbb|cxxx|xxxx|dddd|dddd
  • 第五個成員 e

    • 類型爲 byte
    • 大小/對齊值爲 1 字節
    • 根據規則 1,其偏移量必須爲 1 的整數倍。當前偏移量爲 24。不須要額外對齊,填充 1 個字節到第 25 位。以下:axxx|bbbb|cxxx|xxxx|dddd|dddd|e...

總體對齊

在每一個成員變量進行對齊後,根據規則 2,整個結構體自己也要進行字節對齊,由於可發現它可能並非 2^n,不是偶數倍。顯然不符合對齊的規則

根據規則 2,可得出對齊值爲 8。如今的偏移量爲 25,不是 8 的整倍數。所以肯定偏移量爲 32。對結構體進行對齊

結果

Part1 內存佈局:axxx|bbbb|cxxx|xxxx|dddd|dddd|exxx|xxxx

小結

經過本節的分析,可得知先前的 「推算」 爲何錯誤?

是由於實際內存管理並不是 「一個蘿蔔一個坑」 的思想。而是一塊一塊。經過空間換時間(效率)的思想來完成這塊讀取、寫入。另外也須要兼顧不一樣平臺的內存操做狀況

巧妙的結構體

在上一小節,可得知根據成員變量的類型不一樣,其結構體的內存會產生對齊等動做。那假設字段順序不一樣,會不會有什麼變化呢?咱們一塊兒來試試吧 :-)

type Part1 struct {
    a bool
    b int32
    c int8
    d int64
    e byte
}

type Part2 struct {
    e byte
    c int8
    a bool
    b int32
    d int64
}

func main() {
    part1 := Part1{}
    part2 := Part2{}

    fmt.Printf("part1 size: %d, align: %d\n", unsafe.Sizeof(part1), unsafe.Alignof(part1))
    fmt.Printf("part2 size: %d, align: %d\n", unsafe.Sizeof(part2), unsafe.Alignof(part2))
}

輸出結果:

part1 size: 32, align: 8
part2 size: 16, align: 8

經過結果能夠驚喜的發現,只是 「簡單」 對成員變量的字段順序進行改變,就改變告終構體佔用大小

接下來咱們一塊兒剖析一下 Part2,看看它的內部到底和上一位之間有什麼區別,才致使了這樣的結果?

分析流程

成員變量 類型 偏移量 自身佔用
e byte 0 1
c int8 1 1
a bool 2 1
字節對齊 3 1
b int32 4 4
d int64 8 8
總佔用大小 - - 16

成員對齊

  • 第一個成員 e

    • 類型爲 byte
    • 大小/對齊值爲 1 字節
    • 初始地址,偏移量爲 0。佔用了第 1 位
  • 第二個成員 c

    • 類型爲 int8
    • 大小/對齊值爲 1 字節
    • 根據規則1,其偏移量必須爲 1 的整數倍。當前偏移量爲 2。不須要額外對齊
  • 第三個成員 a

    • 類型爲 bool
    • 大小/對齊值爲 1 字節
    • 根據規則1,其偏移量必須爲 1 的整數倍。當前偏移量爲 3。不須要額外對齊
  • 第四個成員 b

    • 類型爲 int32
    • 大小/對齊值爲 4 字節
    • 根據規則1,其偏移量必須爲 4 的整數倍。肯定偏移量爲 4,所以第 3 位爲 Padding。而當前數值從第 4 位開始填充,到第 8 位。以下:ecax|bbbb
  • 第五個成員 d

    • 類型爲 int64
    • 大小/對齊值爲 8 字節
    • 根據規則1,其偏移量必須爲 8 的整數倍。當前偏移量爲 8。不須要額外對齊,從 9-16 位填充 8 個字節。以下:ecax|bbbb|dddd|dddd

總體對齊

符合規則 2,不須要額外對齊

結果

Part2 內存佈局:ecax|bbbb|dddd|dddd

總結

經過對比 Part1Part2 的內存佈局,你會發現二者有很大的不一樣。以下:

  • Part1:axxx|bbbb|cxxx|xxxx|dddd|dddd|exxx|xxxx
  • Part2:ecax|bbbb|dddd|dddd

仔細一看,Part1 存在許多 Padding。顯然它佔據了很多空間,那麼 Padding 是怎麼出現的呢?

經過本文的介紹,可得知是因爲不一樣類型致使須要進行字節對齊,以此保證內存的訪問邊界

那麼也不難理解,爲何調整結構體內成員變量的字段順序就能達到縮小結構體佔用大小的疑問了,是由於巧妙地減小了 Padding 的存在。讓它們更 「緊湊」 了。這一點對於加深 Go 的內存佈局印象和大對象的優化很是有幫

固然了,沒什麼特殊問題,你能夠不關注這一塊。但你要知道這塊知識點 😄

參考

相關文章
相關標籤/搜索