详解内存对齐
前言
哈嘍,大家好,我是asong。好久不見,上周停更了一周,因為工作有點忙,好在這周末閑了下來,就趕緊來肝文嘍。今天我們來聊一聊一道常見的面試八股文——內存對齊,我們平常在業務開發中根本不care內存對齊,但是在面試中,這就是一個高頻考點,今天我們就一起來看一看到底什么是內存對齊。
前情概要
在了解內存對齊之前,先來明確幾個關于操作系統的概念,更加方面我們對內存對齊的理解。
內存管理:我們都知道內存是計算中重要的組成之一,內存是與CPU進行溝通的橋梁,用于暫存CPU中的運算數據、以及與硬盤等外部存儲器交換的數據。早期,程序是直接運行在物理內存上的,直接操作物理內存,但是會存在一些問題,比如使用效率低、地址空間不隔離等問題,所以就出現了虛擬內存,虛擬內存就是在程序和物理內存之間引入了一個中間層,這個中間層就是虛擬內存,這樣就達到了對進程地址和物理地址的隔離。在linux系統中,將虛擬內存劃分為用戶空間和內核空間,用戶進程只能訪問用戶空間的虛擬地址,只有通過系統調用、外設中斷或異常才能訪問內核空間,我們主要來看一下用戶空間,用戶空間被分為5個不同內存區域:
內存的知識先介紹個大概,對于本文的理解應該夠了,我們接著介紹操作系統幾個其他概念。
代碼段:存放可執行文件的操作指令,只讀
數據段:用來存放可執行文件中已初始化全局變量,存放靜態變量和全局變量
BSS段:用來存未初始化的全局變量
棧區:用來存臨時創建的局部變量
堆區:用來存動態分配的內存段
CPU:中央處理單元(Cntral Pocessing Unit)的縮寫,也叫處理器;CPU是計算機的運算核心和控制核心,我們人類靠著大腦思考,電腦就是靠著CPU來運算、控制,起到協調和控制作用,從功能來看,CPU 的內部由寄存器、控制器、運算器和時鐘四部分組成,各部分之間通過電信號連通。
CPU和內存的工作關系:當我們執行一個程序時,首先由輸入設備向CPU發出操作指令,CPU接收到操作指令后,硬盤中對應的程序就會被直接加載到內存中,此后,CPU 再對內存進行尋址操作,將加載到內存中的指令翻譯出來,而后發送操作信號給操作控制器,實現程序的運行或數據的處理。存在于內存中的目的就是為了CPU能夠過總線進行尋址,取指令、譯碼、執行取數據,內存與寄存器交互,然后CPU運算,再輸出數據至內存。
os:os全稱為Operating System,也就是操作操作系統,是一組主管并控制計算機操作、運用和運行硬件、軟件資源和提供公共服務組織用戶交互的相互關聯的系統軟件,同時也是計算機系統的內核與基石。
編譯器:編譯器就是將“一種語言(通常為高級語言)”翻譯為“另一種語言(通常為低級語言)”的程序。一個現代編譯器的主要工作流程:源代碼 (source code) → 預處理器(preprocessor) → 編譯器 (compiler) → 目標代碼 (object code) → 鏈接器 (Linker) → 可執行程序(executables)。
寫在最后的一個知識點:
計算機中,最小的存儲單元為字節,理論上任意地址都可以通過總線進行訪問,每次尋址能傳輸的數據大小就跟CPU位數有關。常見的CPU位數有8位,16位,32位,64位。位數越高,單次操作執行的數據量越大,性能也就越強。os的位數一般與CPU的位數相匹配,32位CPU可以尋址4GB內存空間,也可以運行32位的os,同樣道理,64位的CPU可以運行32位的os,也可以運行64位的os。
何為內存對齊
以下內容來源于網絡總結:
現代計算機中內存空間都是按照字節(byte)進行劃分的,所以從理論上講對于任何類型的變量訪問都可以從任意地址開始,但是在實際情況中,在訪問特定類型變量的時候經常在特定的內存地址訪問,所以這就需要把各種類型數據按照一定的規則在空間上排列,而不是按照順序一個接一個的排放,這種就稱為內存對齊,內存對齊是指首地址對齊,而不是說每個變量大小對齊。
為何要有內存對齊
主要原因可以歸結為兩點:
有些CPU可以訪問任意地址上的任意數據,而有些CPU只能在特定地址訪問數據,因此不同硬件平臺具有差異性,這樣的代碼就不具有移植性,如果在編譯時,將分配的內存進行對齊,這就具有平臺可以移植性了
CPU每次尋址都是要消費時間的,并且CPU 訪問內存時,并不是逐個字節訪問,而是以字長(word size)為單位訪問,所以數據結構應該盡可能地在自然邊界上對齊,如果訪問未對齊的內存,處理器需要做兩次內存訪問,而對齊的內存訪問僅需要一次訪問,內存對齊后可以提升性能。舉個例子:
假設當前CPU是32位的,并且沒有內存對齊機制,數據可以任意存放,現在有一個int32變量占4byte,存放地址在0x00000002 - 0x00000005(純假設地址,莫當真),這種情況下,每次取4字節的CPU第一次取到[0x00000000 - 0x00000003],只得到變量1/2的數據,所以還需要取第二次,為了得到一個int32類型的變量,需要訪問兩次內存并做拼接處理,影響性能。如果有內存對齊了,int32類型數據就會按照對齊規則在內存中,上面這個例子就會存在地址0x00000000處開始,那么處理器在取數據時一次性就能將數據讀出來了,而且不需要做額外的操作,使用空間換時間,提高了效率。
沒有內存對齊機制:
內存對齊后:
對齊系數
每個特定平臺上的編譯器都有自己的默認"對齊系數",常用平臺默認對齊系數如下:
32位系統對齊系數是4
64位系統對齊系數是8
這只是默認對齊系數,實際上對齊系數我們是可以修改的,之前寫C語言的朋友知道,可以通過預編譯指令#pragma pack(n)來修改對齊系數,因為C語言是預處理器的,但是在Go語言中沒有預處理器,只能通過tags和命名約定來讓Go的包可以管理不同平臺的代碼,但是怎么修改對齊系數,感覺Go并沒有開放這個參數,找了好久沒有找到,等后面再仔細看看,找到了再來更新!
既然對齊系數無法更改,但是我們可以查看對齊系數,使用Go語言中的unsafe.Alignof可以返回相應類型的對齊系數,使用我的mac(64位)測試后發現,對齊系數都符合2^n這個規律,最大也不會超過8。
func?main()??{fmt.Printf("string?alignof?is?%d\n",?unsafe.Alignof(string("a")))fmt.Printf("complex128?alignof?is?%d\n",?unsafe.Alignof(complex128(0)))fmt.Printf("int?alignof?is?%d\n",?unsafe.Alignof(int(0))) } 運行結果 string?alignof?is?8 complex128?alignof?is?8 int?alignof?is?8注意:不同硬件平臺占用的大小和對齊值都可能是不一樣的。
結構體的內存對齊規則
一提到內存對齊,大家都喜歡拿結構體的內存對齊來舉例子,這里要提醒大家一下,不要混淆了一個概念,其他類型也都是要內存對齊的,只不過拿結構體來舉例子能更好的理解內存對齊,并且結構體中的成員變量對齊有自己的規則,我們需要搞清這個對齊規則。
C語言的對齊規則與Go語言一樣,所以C語言的對齊規則對Go同樣適用:
對于結構體的各個成員,第一個成員位于偏移為0的位置,結構體第一個成員的偏移量(offset)為0,以后每個成員相對于結構體首地址的offset都是該成員大小與有效對齊值中較小那個的整數倍,如有需要編譯器會在成員之間加上填充字節。
除了結構成員需要對齊,結構本身也需要對齊,結構的長度必須是編譯器默認的對齊長度和成員中最長類型中最小的數據大小的倍數對齊。
舉個例子
根據上面的對齊規則,我們來分析一個例子,加深理解:
//?64位平臺,對齊參數是8 type?User?struct?{A?int32?//?4B?[]int32?//?24C?string?//?16D?bool?//?1 }func?main()??{var?u?Userfmt.Println("u1?size?is?",unsafe.Sizeof(u)) } //?運行結果 u?size?is??56這里我的mac是64位的,對齊參數是8,int32、[]int32、string、bool對齊值分別是4、8、8、1,占用內存大小分別是4、24、16、1,我們先根據第一條對齊規則分析User:
第一個字段類型是int32,對齊值是4,大小為4,所以放在內存布局中的第一位.
第二個字段類型是[]int32,對齊值是8,大小為24,按照第一條規則,偏移量應該是成員大小24與對齊值8中較小那個的整數倍,那么偏移量就是8,所以4-7位會由編譯進行填充,一般為0值,也稱為空洞,第9到32位為第二個字段B.
第三個字段類型是string,對齊值是8,大小為16,所以他的內存偏移值必須是8的倍數,因為user前兩個字段就已經排到了第32位,所以offset為32正好是8的倍數,不要填充,從32位到48位是第三個字段C.
第四個字段類型是bool,對齊值是1,大小為1,所以他的內存偏移值必須是1的倍數,因為user前兩個字段就已經排到了第48位,所以下一位的偏移量正好是48,正好是字段D的對齊值的倍數,不用填充,可以直接排列到第四個字段,也就是從48到第49位是第三個字段D.
根據第一條規則分析后,現在結構所占大小為49字節,我們再來根據第二條規則分析:
根據第二條規則,默認對齊值是8,字段中最大類型程度是24,所以求出結構體的對齊值是8,我們目前的內存長度是49,不是8的倍數,所以需要補齊,所以最終的結果就是56,補了7位。
成員變量順序對內存對齊帶來的影響
根據上面的規則我們可以看出,成員變量的順序也會影響內存對齊的結果,我們先來看一個例子:
type?test1?struct?{a?bool?//?1b?int32?//?4c?string?//?16 }type?test2?struct?{a?int32?//?4b?string?//?16c?bool?//?1 }func?main()??{var?t1?test1var?t2?test2fmt.Println("t1?size?is?",unsafe.Sizeof(t1))fmt.Println("t2?size?is?",unsafe.Sizeof(t2)) }運行結果:
t1?size?is??24 t2?size?is??32test1的內存布局:
test2的內存布局:
)
通過以上分析,我們可以看出,結構體中成員變量的順序會影響結構體的內存布局,所以在日常開發中大家要注意這個問題,可以節省內存空間。
空結構體字段對齊
Go語言中空結構體的大小為0,如果一個結構體中包含空結構體類型的字段時,通常是不需要進行內存對齊的,舉個例子:
type?demo1?struct?{a?struct{}b?int32 }func?main()??{fmt.Println(unsafe.Sizeof(demo1{})) } 運行結果: 4從運行結果可知結構體demo1占用的內存與字段b占用內存大小相同,所以字段a是沒有占用內存的,但是空結構體有一個特例,那就是當 struct{} 作為結構體最后一個字段時,需要內存對齊。因為如果有指針指向該字段, 返回的地址將在結構體之外,如果此指針一直存活不釋放對應的內存,就會有內存泄露的問題(該內存不因結構體釋放而釋放),所以當struct{}作為結構體成員中最后一個字段時,要填充額外的內存保證安全。
type?demo2?struct?{a?int32b?struct{} }func?main()??{fmt.Println(unsafe.Sizeof(demo2{})) } 運行結果: 8考慮內存對齊的設計
在之前的文章源碼剖析sync.WaitGroup分析sync.waitgroup的源碼時,使用state1來存儲狀態:
//?A?WaitGroup?must?not?be?copied?after?first?use. type?WaitGroup?struct?{noCopy?noCopy//?64-bit?value:?high?32?bits?are?counter,?low?32?bits?are?waiter?count.//?64-bit?atomic?operations?require?64-bit?alignment,?but?32-bit//?compilers?do?not?ensure?it.?So?we?allocate?12?bytes?and?then?use//?the?aligned?8?bytes?in?them?as?state,?and?the?other?4?as?storage//?for?the?sema.state1?[3]uint32 }state1這里總共被分配了12個字節,這里被設計了三種狀態:
其中對齊的8個字節作為狀態,高32位為計數的數量,低32位為等待的goroutine數量
其中的4個字節作為信號量存儲
提供了(wg *WaitGroup) state() (statep *uint64, semap *uint32)幫助我們從state1字段中取出他的狀態和信號量,為什么要這樣設計呢?
因為64位原子操作需要64位對齊,但是32位編譯器不能保證這一點,所以為了保證waitGroup在32位平臺上使用的話,就必須保證在任何時候,64位操作不會報錯。所以也就不能分成兩個字段來寫,考慮到字段順序不同、平臺不同,內存對齊也就不同。因此這里采用動態識別當前我們操作的64位數到底是不是在8字節對齊的位置上面,我們來分析一下state方法:
//?state?returns?pointers?to?the?state?and?sema?fields?stored?within?wg.state1. func?(wg?*WaitGroup)?state()?(statep?*uint64,?semap?*uint32)?{if?uintptr(unsafe.Pointer(&wg.state1))%8?==?0?{return?(*uint64)(unsafe.Pointer(&wg.state1)),?&wg.state1[2]}?else?{return?(*uint64)(unsafe.Pointer(&wg.state1[1])),?&wg.state1[0]} }當數組的首地址是處于一個8字節對齊的位置上時,那么就將這個數組的前8個字節作為64位值使用表示狀態,后4個字節作為32位值表示信號量(semaphore)。同理如果首地址沒有處于8字節對齊的位置上時,那么就將前4個字節作為semaphore,后8個字節作為64位數值。畫個圖表示一下:
)
總結
終于接近尾聲了,內存對齊一直面試中的高頻考點,通過內存對齊可以了解面試者對操作系統知識的了解程度,所以這塊知識還是比較重要的,希望這篇文章能幫助大家答疑解惑,更好的忽悠面試官~。
文中代碼已上傳github:https://github.com/asong2020/Golang_Dream/tree/master/code_demo/memory 歡迎star;
文中有任何問題歡迎留言區探討~;
素質三連(分享、點贊、在看)都是筆者持續創作更多優質內容的動力!我是asong,我們下期見。
總結
- 上一篇: 搞定系统设计 00:开篇
- 下一篇: 赏析 Singleflight 设计