詳解內(nèi)存對(duì)齊
前言
哈嘍,大家好,我是
asong。好久不見,上周停更了一周,因?yàn)楣ぷ饔悬c(diǎn)忙,好在這周末閑了下來(lái),就趕緊來(lái)肝文嘍。今天我們來(lái)聊一聊一道常見的面試八股文——內(nèi)存對(duì)齊,我們平常在業(yè)務(wù)開發(fā)中根本不care內(nèi)存對(duì)齊,但是在面試中,這就是一個(gè)高頻考點(diǎn),今天我們就一起來(lái)看一看到底什么是內(nèi)存對(duì)齊。
前情概要
在了解內(nèi)存對(duì)齊之前,先來(lái)明確幾個(gè)關(guān)于操作系統(tǒng)的概念,更加方面我們對(duì)內(nèi)存對(duì)齊的理解。
內(nèi)存管理:我們都知道內(nèi)存是計(jì)算中重要的組成之一,內(nèi)存是與
CPU進(jìn)行溝通的橋梁,用于暫存CPU中的運(yùn)算數(shù)據(jù)、以及與硬盤等外部存儲(chǔ)器交換的數(shù)據(jù)。早期,程序是直接運(yùn)行在物理內(nèi)存上的,直接操作物理內(nèi)存,但是會(huì)存在一些問題,比如使用效率低、地址空間不隔離等問題,所以就出現(xiàn)了虛擬內(nèi)存,虛擬內(nèi)存就是在程序和物理內(nèi)存之間引入了一個(gè)中間層,這個(gè)中間層就是虛擬內(nèi)存,這樣就達(dá)到了對(duì)進(jìn)程地址和物理地址的隔離。在linux系統(tǒng)中,將虛擬內(nèi)存劃分為用戶空間和內(nèi)核空間,用戶進(jìn)程只能訪問用戶空間的虛擬地址,只有通過(guò)系統(tǒng)調(diào)用、外設(shè)中斷或異常才能訪問內(nèi)核空間,我們主要來(lái)看一下用戶空間,用戶空間被分為5個(gè)不同內(nèi)存區(qū)域:
內(nèi)存的知識(shí)先介紹個(gè)大概,對(duì)于本文的理解應(yīng)該夠了,我們接著介紹操作系統(tǒng)幾個(gè)其他概念。
代碼段:存放可執(zhí)行文件的操作指令,只讀 數(shù)據(jù)段:用來(lái)存放可執(zhí)行文件中已初始化全局變量,存放靜態(tài)變量和全局變量 BSS段:用來(lái)存未初始化的全局變量 棧區(qū):用來(lái)存臨時(shí)創(chuàng)建的局部變量 堆區(qū):用來(lái)存動(dòng)態(tài)分配的內(nèi)存段 CPU:中央處理單元(Cntral Pocessing Unit)的縮寫,也叫處理器;CPU是計(jì)算機(jī)的運(yùn)算核心和控制核心,我們?nèi)祟惪恐竽X思考,電腦就是靠著CPU來(lái)運(yùn)算、控制,起到協(xié)調(diào)和控制作用,從功能來(lái)看,CPU 的內(nèi)部由寄存器、控制器、運(yùn)算器和時(shí)鐘四部分組成,各部分之間通過(guò)電信號(hào)連通。CPU和內(nèi)存的工作關(guān)系:當(dāng)我們執(zhí)行一個(gè)程序時(shí),首先由輸入設(shè)備向CPU發(fā)出操作指令,CPU接收到操作指令后,硬盤中對(duì)應(yīng)的程序就會(huì)被直接加載到內(nèi)存中,此后,CPU 再對(duì)內(nèi)存進(jìn)行尋址操作,將加載到內(nèi)存中的指令翻譯出來(lái),而后發(fā)送操作信號(hào)給操作控制器,實(shí)現(xiàn)程序的運(yùn)行或數(shù)據(jù)的處理。存在于內(nèi)存中的目的就是為了CPU能夠過(guò)總線進(jìn)行尋址,取指令、譯碼、執(zhí)行取數(shù)據(jù),內(nèi)存與寄存器交互,然后CPU運(yùn)算,再輸出數(shù)據(jù)至內(nèi)存。

os:os全稱為Operating System,也就是操作操作系統(tǒng),是一組主管并控制計(jì)算機(jī)操作、運(yùn)用和運(yùn)行硬件、軟件資源和提供公共服務(wù)組織用戶交互的相互關(guān)聯(lián)的系統(tǒng)軟件,同時(shí)也是計(jì)算機(jī)系統(tǒng)的內(nèi)核與基石。編譯器:編譯器就是將“一種語(yǔ)言(通常為高級(jí)語(yǔ)言)”翻譯為“另一種語(yǔ)言(通常為低級(jí)語(yǔ)言)”的程序。一個(gè)現(xiàn)代編譯器的主要工作流程:源代碼 (source code) → 預(yù)處理器(preprocessor) → 編譯器 (compiler) → 目標(biāo)代碼 (object code) → 鏈接器 (Linker) → 可執(zhí)行程序(executables)。
寫在最后的一個(gè)知識(shí)點(diǎn):
計(jì)算機(jī)中,最小的存儲(chǔ)單元為字節(jié),理論上任意地址都可以通過(guò)總線進(jìn)行訪問,每次尋址能傳輸?shù)臄?shù)據(jù)大小就跟
CPU位數(shù)有關(guān)。常見的CPU位數(shù)有8位,16位,32位,64位。位數(shù)越高,單次操作執(zhí)行的數(shù)據(jù)量越大,性能也就越強(qiáng)。os的位數(shù)一般與CPU的位數(shù)相匹配,32位CPU可以尋址4GB內(nèi)存空間,也可以運(yùn)行32位的os,同樣道理,64位的CPU可以運(yùn)行32位的os,也可以運(yùn)行64位的os。
何為內(nèi)存對(duì)齊
以下內(nèi)容來(lái)源于網(wǎng)絡(luò)總結(jié):
現(xiàn)代計(jì)算機(jī)中內(nèi)存空間都是按照字節(jié)(byte)進(jìn)行劃分的,所以從理論上講對(duì)于任何類型的變量訪問都可以從任意地址開始,但是在實(shí)際情況中,在訪問特定類型變量的時(shí)候經(jīng)常在特定的內(nèi)存地址訪問,所以這就需要把各種類型數(shù)據(jù)按照一定的規(guī)則在空間上排列,而不是按照順序一個(gè)接一個(gè)的排放,這種就稱為內(nèi)存對(duì)齊,內(nèi)存對(duì)齊是指首地址對(duì)齊,而不是說(shuō)每個(gè)變量大小對(duì)齊。
為何要有內(nèi)存對(duì)齊
主要原因可以歸結(jié)為兩點(diǎn):
有些 CPU可以訪問任意地址上的任意數(shù)據(jù),而有些CPU只能在特定地址訪問數(shù)據(jù),因此不同硬件平臺(tái)具有差異性,這樣的代碼就不具有移植性,如果在編譯時(shí),將分配的內(nèi)存進(jìn)行對(duì)齊,這就具有平臺(tái)可以移植性了CPU每次尋址都是要消費(fèi)時(shí)間的,并且CPU訪問內(nèi)存時(shí),并不是逐個(gè)字節(jié)訪問,而是以字長(zhǎng)(word size)為單位訪問,所以數(shù)據(jù)結(jié)構(gòu)應(yīng)該盡可能地在自然邊界上對(duì)齊,如果訪問未對(duì)齊的內(nèi)存,處理器需要做兩次內(nèi)存訪問,而對(duì)齊的內(nèi)存訪問僅需要一次訪問,內(nèi)存對(duì)齊后可以提升性能。舉個(gè)例子:
假設(shè)當(dāng)前
CPU是32位的,并且沒有內(nèi)存對(duì)齊機(jī)制,數(shù)據(jù)可以任意存放,現(xiàn)在有一個(gè)int32變量占4byte,存放地址在0x00000002 - 0x00000005(純假設(shè)地址,莫當(dāng)真),這種情況下,每次取4字節(jié)的CPU第一次取到[0x00000000 - 0x00000003],只得到變量1/2的數(shù)據(jù),所以還需要取第二次,為了得到一個(gè)int32類型的變量,需要訪問兩次內(nèi)存并做拼接處理,影響性能。如果有內(nèi)存對(duì)齊了,int32類型數(shù)據(jù)就會(huì)按照對(duì)齊規(guī)則在內(nèi)存中,上面這個(gè)例子就會(huì)存在地址0x00000000處開始,那么處理器在取數(shù)據(jù)時(shí)一次性就能將數(shù)據(jù)讀出來(lái)了,而且不需要做額外的操作,使用空間換時(shí)間,提高了效率。
沒有內(nèi)存對(duì)齊機(jī)制:

內(nèi)存對(duì)齊后:

對(duì)齊系數(shù)
每個(gè)特定平臺(tái)上的編譯器都有自己的默認(rèn)"對(duì)齊系數(shù)",常用平臺(tái)默認(rèn)對(duì)齊系數(shù)如下:
32位系統(tǒng)對(duì)齊系數(shù)是4 64位系統(tǒng)對(duì)齊系數(shù)是8
這只是默認(rèn)對(duì)齊系數(shù),實(shí)際上對(duì)齊系數(shù)我們是可以修改的,之前寫C語(yǔ)言的朋友知道,可以通過(guò)預(yù)編譯指令#pragma pack(n)來(lái)修改對(duì)齊系數(shù),因?yàn)?code style="font-size: 14px;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;color: rgb(239, 112, 96);">C語(yǔ)言是預(yù)處理器的,但是在Go語(yǔ)言中沒有預(yù)處理器,只能通過(guò)tags和命名約定來(lái)讓Go的包可以管理不同平臺(tái)的代碼,但是怎么修改對(duì)齊系數(shù),感覺Go并沒有開放這個(gè)參數(shù),找了好久沒有找到,等后面再仔細(xì)看看,找到了再來(lái)更新!
既然對(duì)齊系數(shù)無(wú)法更改,但是我們可以查看對(duì)齊系數(shù),使用Go語(yǔ)言中的unsafe.Alignof可以返回相應(yīng)類型的對(duì)齊系數(shù),使用我的mac(64位)測(cè)試后發(fā)現(xiàn),對(duì)齊系數(shù)都符合2^n這個(gè)規(guī)律,最大也不會(huì)超過(guò)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)))
}
運(yùn)行結(jié)果
string?alignof?is?8
complex128?alignof?is?8
int?alignof?is?8
注意:不同硬件平臺(tái)占用的大小和對(duì)齊值都可能是不一樣的。
結(jié)構(gòu)體的內(nèi)存對(duì)齊規(guī)則
一提到內(nèi)存對(duì)齊,大家都喜歡拿結(jié)構(gòu)體的內(nèi)存對(duì)齊來(lái)舉例子,這里要提醒大家一下,不要混淆了一個(gè)概念,其他類型也都是要內(nèi)存對(duì)齊的,只不過(guò)拿結(jié)構(gòu)體來(lái)舉例子能更好的理解內(nèi)存對(duì)齊,并且結(jié)構(gòu)體中的成員變量對(duì)齊有自己的規(guī)則,我們需要搞清這個(gè)對(duì)齊規(guī)則。
C語(yǔ)言的對(duì)齊規(guī)則與Go語(yǔ)言一樣,所以C語(yǔ)言的對(duì)齊規(guī)則對(duì)Go同樣適用:
對(duì)于結(jié)構(gòu)體的各個(gè)成員,第一個(gè)成員位于偏移為 0的位置,結(jié)構(gòu)體第一個(gè)成員的偏移量(offset)為0,以后每個(gè)成員相對(duì)于結(jié)構(gòu)體首地址的offset都是該成員大小與有效對(duì)齊值中較小那個(gè)的整數(shù)倍,如有需要編譯器會(huì)在成員之間加上填充字節(jié)。除了結(jié)構(gòu)成員需要對(duì)齊,結(jié)構(gòu)本身也需要對(duì)齊,結(jié)構(gòu)的長(zhǎng)度必須是編譯器默認(rèn)的對(duì)齊長(zhǎng)度和成員中最長(zhǎng)類型中最小的數(shù)據(jù)大小的倍數(shù)對(duì)齊。
舉個(gè)例子
根據(jù)上面的對(duì)齊規(guī)則,我們來(lái)分析一個(gè)例子,加深理解:
//?64位平臺(tái),對(duì)齊參數(shù)是8
type?User?struct?{
?A?int32?//?4
?B?[]int32?//?24
?C?string?//?16
?D?bool?//?1
}
func?main()??{
?var?u?User
?fmt.Println("u1?size?is?",unsafe.Sizeof(u))
}
//?運(yùn)行結(jié)果
u?size?is??56
這里我的mac是64位的,對(duì)齊參數(shù)是8,int32、[]int32、string、bool對(duì)齊值分別是4、8、8、1,占用內(nèi)存大小分別是4、24、16、1,我們先根據(jù)第一條對(duì)齊規(guī)則分析User:
第一個(gè)字段類型是 int32,對(duì)齊值是4,大小為4,所以放在內(nèi)存布局中的第一位.第二個(gè)字段類型是 []int32,對(duì)齊值是8,大小為24,按照第一條規(guī)則,偏移量應(yīng)該是成員大小24與對(duì)齊值8中較小那個(gè)的整數(shù)倍,那么偏移量就是8,所以4-7位會(huì)由編譯進(jìn)行填充,一般為0值,也稱為空洞,第9到32位為第二個(gè)字段B.第三個(gè)字段類型是 string,對(duì)齊值是8,大小為16,所以他的內(nèi)存偏移值必須是8的倍數(shù),因?yàn)?code style="font-size: 14px;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;color: rgb(239, 112, 96);">user前兩個(gè)字段就已經(jīng)排到了第32位,所以offset為32正好是8的倍數(shù),不要填充,從32位到48位是第三個(gè)字段C.第四個(gè)字段類型是 bool,對(duì)齊值是1,大小為1,所以他的內(nèi)存偏移值必須是1的倍數(shù),因?yàn)?code style="font-size: 14px;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;color: rgb(239, 112, 96);">user前兩個(gè)字段就已經(jīng)排到了第48位,所以下一位的偏移量正好是48,正好是字段D的對(duì)齊值的倍數(shù),不用填充,可以直接排列到第四個(gè)字段,也就是從48到第49位是第三個(gè)字段D.
根據(jù)第一條規(guī)則分析后,現(xiàn)在結(jié)構(gòu)所占大小為49字節(jié),我們?cè)賮?lái)根據(jù)第二條規(guī)則分析:
根據(jù)第二條規(guī)則,默認(rèn)對(duì)齊值是 8,字段中最大類型程度是24,所以求出結(jié)構(gòu)體的對(duì)齊值是8,我們目前的內(nèi)存長(zhǎng)度是49,不是8的倍數(shù),所以需要補(bǔ)齊,所以最終的結(jié)果就是56,補(bǔ)了7位。

成員變量順序?qū)?nèi)存對(duì)齊帶來(lái)的影響
根據(jù)上面的規(guī)則我們可以看出,成員變量的順序也會(huì)影響內(nèi)存對(duì)齊的結(jié)果,我們先來(lái)看一個(gè)例子:
type?test1?struct?{
?a?bool?//?1
?b?int32?//?4
?c?string?//?16
}
type?test2?struct?{
?a?int32?//?4
?b?string?//?16
?c?bool?//?1
}
func?main()??{
?var?t1?test1
?var?t2?test2
?fmt.Println("t1?size?is?",unsafe.Sizeof(t1))
?fmt.Println("t2?size?is?",unsafe.Sizeof(t2))
}
運(yùn)行結(jié)果:
t1?size?is??24
t2?size?is??32
test1的內(nèi)存布局:

test2的內(nèi)存布局:
)
通過(guò)以上分析,我們可以看出,結(jié)構(gòu)體中成員變量的順序會(huì)影響結(jié)構(gòu)體的內(nèi)存布局,所以在日常開發(fā)中大家要注意這個(gè)問題,可以節(jié)省內(nèi)存空間。
空結(jié)構(gòu)體字段對(duì)齊
Go語(yǔ)言中空結(jié)構(gòu)體的大小為0,如果一個(gè)結(jié)構(gòu)體中包含空結(jié)構(gòu)體類型的字段時(shí),通常是不需要進(jìn)行內(nèi)存對(duì)齊的,舉個(gè)例子:
type?demo1?struct?{
?a?struct{}
?b?int32
}
func?main()??{
?fmt.Println(unsafe.Sizeof(demo1{}))
}
運(yùn)行結(jié)果:
4
從運(yùn)行結(jié)果可知結(jié)構(gòu)體demo1占用的內(nèi)存與字段b占用內(nèi)存大小相同,所以字段a是沒有占用內(nèi)存的,但是空結(jié)構(gòu)體有一個(gè)特例,那就是當(dāng) struct{} 作為結(jié)構(gòu)體最后一個(gè)字段時(shí),需要內(nèi)存對(duì)齊。因?yàn)槿绻兄羔樦赶蛟撟侄? 返回的地址將在結(jié)構(gòu)體之外,如果此指針一直存活不釋放對(duì)應(yīng)的內(nèi)存,就會(huì)有內(nèi)存泄露的問題(該內(nèi)存不因結(jié)構(gòu)體釋放而釋放),所以當(dāng)struct{}作為結(jié)構(gòu)體成員中最后一個(gè)字段時(shí),要填充額外的內(nèi)存保證安全。
type?demo2?struct?{
?a?int32
?b?struct{}
}
func?main()??{
?fmt.Println(unsafe.Sizeof(demo2{}))
}
運(yùn)行結(jié)果:
8
考慮內(nèi)存對(duì)齊的設(shè)計(jì)
在之前的文章源碼剖析sync.WaitGroup分析sync.waitgroup的源碼時(shí),使用state1來(lái)存儲(chǔ)狀態(tài):
//?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個(gè)字節(jié),這里被設(shè)計(jì)了三種狀態(tài):
其中對(duì)齊的 8個(gè)字節(jié)作為狀態(tài),高32位為計(jì)數(shù)的數(shù)量,低32位為等待的goroutine數(shù)量其中的 4個(gè)字節(jié)作為信號(hào)量存儲(chǔ)
提供了(wg *WaitGroup) state() (statep *uint64, semap *uint32)幫助我們從state1字段中取出他的狀態(tài)和信號(hào)量,為什么要這樣設(shè)計(jì)呢?
因?yàn)?code style="font-size: 14px;padding: 2px 4px;border-radius: 4px;margin-right: 2px;margin-left: 2px;background-color: rgba(27, 31, 35, 0.05);font-family: "Operator Mono", Consolas, Monaco, Menlo, monospace;word-break: break-all;color: rgb(239, 112, 96);">64位原子操作需要64位對(duì)齊,但是32位編譯器不能保證這一點(diǎn),所以為了保證waitGroup在32位平臺(tái)上使用的話,就必須保證在任何時(shí)候,64位操作不會(huì)報(bào)錯(cuò)。所以也就不能分成兩個(gè)字段來(lái)寫,考慮到字段順序不同、平臺(tái)不同,內(nèi)存對(duì)齊也就不同。因此這里采用動(dòng)態(tài)識(shí)別當(dāng)前我們操作的64位數(shù)到底是不是在8字節(jié)對(duì)齊的位置上面,我們來(lái)分析一下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]
?}
}
當(dāng)數(shù)組的首地址是處于一個(gè)8字節(jié)對(duì)齊的位置上時(shí),那么就將這個(gè)數(shù)組的前8個(gè)字節(jié)作為64位值使用表示狀態(tài),后4個(gè)字節(jié)作為32位值表示信號(hào)量(semaphore)。同理如果首地址沒有處于8字節(jié)對(duì)齊的位置上時(shí),那么就將前4個(gè)字節(jié)作為semaphore,后8個(gè)字節(jié)作為64位數(shù)值。畫個(gè)圖表示一下:
)
總結(jié)
終于接近尾聲了,內(nèi)存對(duì)齊一直面試中的高頻考點(diǎn),通過(guò)內(nèi)存對(duì)齊可以了解面試者對(duì)操作系統(tǒng)知識(shí)的了解程度,所以這塊知識(shí)還是比較重要的,希望這篇文章能幫助大家答疑解惑,更好的忽悠面試官~。
文中代碼已上傳github:https://github.com/asong2020/Golang_Dream/tree/master/code_demo/memory 歡迎star;
文中有任何問題歡迎留言區(qū)探討~;
素質(zhì)三連(分享、點(diǎn)贊、在看)都是筆者持續(xù)創(chuàng)作更多優(yōu)質(zhì)內(nèi)容的動(dòng)力!我是asong,我們下期見。
