詳解Go是如何優(yōu)雅的進行內(nèi)存管理
前言
Go語言拋棄C/C++中的開發(fā)者管理內(nèi)存的方式,實現(xiàn)了主動申請與主動釋放管理,增加了逃逸分析和垃圾回收,將開發(fā)者從內(nèi)存管理中釋放出來。
所以我們在日常編寫代碼的時候不需要精通內(nèi)存的管理,它確實很復(fù)雜。但是另一方面,如果你掌握了Go內(nèi)存管理的基本概念和知識點,可以讓你寫出更高質(zhì)量的,更壓榨機器性能的代碼;另外,還能幫助你更快更精準得定位Bug,快速解決問題。所以,作為進階的Go開發(fā),了解掌握Go的內(nèi)存管理還是很有必要的。
相關(guān)背景
存儲金字塔
馮·諾依曼計算機體系中的存儲器,是用于存儲程序和數(shù)據(jù)的?,F(xiàn)代計算機系統(tǒng)中,一般都是采用“CPU寄存器-CPU高速緩存-內(nèi)存-硬盤”的存儲器結(jié)構(gòu)。自上而下容量逐漸增大,速度逐漸減慢,單位價格逐漸降低。

1、CPU寄存器:存儲CPU正在使用的數(shù)據(jù)或指令。
2、CPU高速緩存:存儲CPU近期要用到的數(shù)據(jù)和指令。
3、內(nèi)存:存儲正在運行或者將要運行的程序和數(shù)據(jù)。
4、硬盤:存儲暫時不使用或者不能直接使用的程序和數(shù)據(jù)
虛擬內(nèi)存

物理內(nèi)存:是指實際通過物理內(nèi)存而獲得的內(nèi)存空間。
虛擬內(nèi)存:與物理內(nèi)存相反,是指根據(jù)系統(tǒng)需要從硬盤中虛擬的劃出一部分存儲空間。
虛擬內(nèi)存技術(shù)就是對內(nèi)存的一種抽象,有了這層抽象之后,程序運行進程的總大小可以超過實際可用的物理內(nèi)存大小。每個進程都有自己的獨立虛擬地址空間,然后通過CPU和MMU把虛擬內(nèi)存地址轉(zhuǎn)換為實際物理地址。
TCMalloc
TCMalloc全稱是Thread Cache Malloc,是google為C語言開發(fā)的內(nèi)存分配算法,是Go內(nèi)存分配的起源。
TCMalloc內(nèi)存分配算法的核心思想是把內(nèi)存分為多級管理,從而降低鎖的粒度,它將可用的堆內(nèi)存采用二級分配的方式進行管理,每個線程都會自行維護一個獨立的線程內(nèi)存池,進行內(nèi)存分配時優(yōu)先從該線程內(nèi)存池中分配, 當線程內(nèi)存池不足時才會向全局內(nèi)存池申請,以避免不同線程對全局內(nèi)存池的頻繁競爭 ,進一步的降低了內(nèi)存并發(fā)訪問的粒度。
Go的內(nèi)存分配算法是基于TCMalloc內(nèi)存分配算法實現(xiàn)的,借鑒了TCmalloc的思想。

幾個重要概念
Page: 操作系統(tǒng)對內(nèi)存的管理同樣是以頁為單位,但TCMalloc中的Page和操作系統(tǒng)的中頁是倍數(shù)關(guān)系,x64下Page大小為8KB。
Span:一組連續(xù)的Page被叫做Span,是TCMalloc內(nèi)存管理的基本單位,有不同大小的Span,比如2個Page大的Span,16個Page大的Span。
ThreadCache:每個線程各自的Cache,每個ThreadCache包含多個不同規(guī)格的Span鏈表,叫做SpanList,內(nèi)存分配的時候,可以根據(jù)要分配的內(nèi)存大小,快速選擇不同大小的SpanList,在SpanList上選擇合適的Span,每個線程都有自己的ThreadCache,所以ThreadCache是無鎖訪問的。
CentralCache:中心Cache,所有線程共享的Cache,也是保存的SpanList,數(shù)量和ThreadCache中數(shù)量相同,當ThreadCache中內(nèi)存不足時,可以從CentralCache中獲取,當ThreadCache中內(nèi)存太多時,可以放回CentralCache,由于CentralCache是線程共享的,所以它的訪問需要加鎖。
PageHeap:堆內(nèi)存的抽象,同樣當CentealCache中內(nèi)存太多或太少時,都可從PageHeap中放回或獲取,同樣,PageHeap的訪問也是需要加鎖的。
管理分配
核心思想
Go在程序啟動的時候,會分配一塊連續(xù)的內(nèi)存(虛擬的地址空間,還沒有真正地分配內(nèi)存),切成小塊后自己進行管理,對內(nèi)存的分配遵循以下思想。
1.每次從操作系統(tǒng)申請一大塊內(nèi)存, 以減少系統(tǒng)調(diào)用。
2.將申請到的大塊內(nèi)存按照特定大小預(yù)先切分成小塊, 構(gòu)成鏈表。
3.為對象分配內(nèi)存時, 只需從大小合適的鏈表提取一個小塊即可。
4.回收對象內(nèi)存時, 將該小塊內(nèi)存重新歸還到原鏈表, 以便復(fù)用。
5.如閑置內(nèi)存過多, 則嘗試歸還部分內(nèi)存給操作系統(tǒng), 降低整體開銷。

內(nèi)存管理由mcache、mcentral、mheap組成一個三級管理結(jié)構(gòu),本質(zhì)上都是對mspan的管理,三者用于不同的目的來共同配合管理所有mspan。
mspan
mspan是Go中內(nèi)存管理的基本單元,是由一片連續(xù)的8kB的page組成的內(nèi)存塊。但是小對象和大對象分配的位置不用,大對象在mheap上分配,mheap向操作系統(tǒng)申請新內(nèi)存時,是向虛擬內(nèi)存申請;小對象使用mcache的tiny分配器分配。
一組連續(xù)的Page組成1個Span,go把內(nèi)存分為67個大小不同的span,并且大小是不固定的。


源碼文件src/runtime/sizeclasses.go對67種span的定義(源碼版本為go-1.17.1,本文下所有源碼展示均為此版本)
延伸擴展:67種定義列表里面有一列的名稱叫做"max waste",代表的是這個span下可能出現(xiàn)的最大內(nèi)存浪費比例。舉個例子解釋,看第4個規(guī)格的情況:
class bytes/obj bytes/span objects tail waste max waste min align
4 32 8192 256 0 21.88% 324的對象最小內(nèi)存長度為25字節(jié)(因為小于25的只會申請3或3以下的,要不到4),所以如果每個object都被25字節(jié)的對象申請,此時內(nèi)存浪費最大,對應(yīng)浪費率為:(32-25)/32 = 21.88%。
再通過觀察整個列表可以看到,"max waste"一列并非線性遞減的,熟悉Linux的同學(xué)應(yīng)該猜到原因了,沒錯,這個設(shè)計跟大名鼎鼎的伙伴算法是非常相似的。
伙伴算法(buddy算法),就是將內(nèi)存分成若干塊,然后以最適合的方式滿足程序內(nèi)存需求的一種內(nèi)存管理算法,伙伴算法是盡可能地在提高內(nèi)存利用率的同時減少內(nèi)存碎片。但是算法中,一個很小的塊往往會阻礙一個大塊的合并,一個系統(tǒng)中,對內(nèi)存塊的分配,大小是隨機的,一片內(nèi)存中僅一個小的內(nèi)存塊沒有釋放,旁邊兩個大的就不能合并,這也是造成上面現(xiàn)象的根因。(完整解讀伙伴算法需要非常大的篇幅和難度,本文就不展開了,文章最后有參考鏈接,讀者可自行研究)
回來主題,上面說到的Spans有3種類型:
空閑-span,沒有對象,可以釋放回操作系統(tǒng),或重用于堆分配,或重用于堆棧內(nèi)存。
正在使用-span,至少有一個堆對象,可能有更多的空間。
棧-span,用于 goroutine 堆棧。此跨度可以存在于堆棧中或堆中,但不能同時存在。

源碼文件src/runtime/mheap.go對mspan結(jié)構(gòu)體的定義
type mspan struct {
next *mspan // 鏈表后向指針
prev *mspan // 鏈表前向指針
list *mSpanList // 雙端隊列的head(已無實際用途)
startAddr uintptr // span起始位置的地址指針
npages uintptr // 可供分配的頁數(shù)
...
manualFreeList gclinkptr // 在mSpanManual的空閑對象
allocCache uint64 // 在freeindex處的allocBits的緩存
...
allocBits *gcBits // 標記span中的elem哪些是被使用的,哪些是未被使用的
gcmarkBits *gcBits // 標記span中的elem哪些是被標記的,哪些是未被標記的
speciallock mutex // 互斥鎖
}管理組件說明
內(nèi)存管理器由mcache, mcentral, mheap3種組件構(gòu)成: 三級管理結(jié)構(gòu)是為了方便對span進行管理,加速對span對象的訪問和分配,這三個結(jié)構(gòu)在runtime中分別有對應(yīng)的mcache.go、mcentral.go、mheap.go文件。
mcache:保存的是各種大小的Span,并按Span class分類,小對象直接從mcache分配內(nèi)存,它起到了緩存的作用,并且可以無鎖訪問Go中是每個P擁有1個mcache,因為在Go程序中,當前最多有GOMAXPROCS個線程在運行,所以最多需要GOMAXPROCS個mcache就可以保證各線程對mcache的無鎖訪問。
mcentral:是所有線程共享的緩存,需要加鎖訪問,它按Span class對Span分類,串聯(lián)成鏈表,當mcache的某個級別Span的內(nèi)存被分配光時,它會向mcentral申請1個當前級別的Span。
mheap:是堆內(nèi)存的抽象,把從OS(系統(tǒng))申請出的內(nèi)存頁組織成Span,并保存起來。當mcentral的Span不夠用時會向mheap申請,mheap的Span不夠用時會向OS申請,向OS的內(nèi)存申請是按頁來的,然后把申請來的內(nèi)存頁生成Span組織起來,同樣也是需要加鎖訪問的。mheap主要用于大對象的內(nèi)存分配,以及管理未切割的mspan,用于給mcentral切割成小對象。

熟悉的金字塔,熟悉的結(jié)構(gòu)
通俗的理解:mcache, mcentral, mheap就是對ThreadCache, CentralCache, PageHeap的繼承沿用和基于go體系的優(yōu)化處理版本。
分配流程
Go的內(nèi)存分配器在分配對象時,根據(jù)對象的大小,分成三類:小對象(<=16B)、一般對象(>16B && <=32KB)、大對象(>32KB)。

源碼文件src/runtime/malloc.go根據(jù)分配對象的大小選擇對應(yīng)的空間申請
大體上的分配流程:
1.>32KB 的對象,直接從mheap上分配。
2.<=16B 的對象使用mcache的tiny分配器分配。
3.>16B && <=32KB 的對象,首先計算對象的規(guī)格大小,然后使用mcache中相應(yīng)規(guī)格大小的mspan分配。
如果mcache沒有相應(yīng)規(guī)格大小的mspan,則向mcentral申請; 如果mcentral沒有相應(yīng)規(guī)格大小的mspan,則向mheap申請; 如果mheap中也沒有合適大小的mspan,則向OS申請。

源碼文件src/runtime/mheap.go內(nèi)存分配初始化過程
小結(jié)
Go內(nèi)存管理源自TCMalloc,優(yōu)秀作品源于繼承和優(yōu)化(在這里我自己想到了一句話:如果說我比別人看得更遠些,那是因為我站在了巨人的肩上--牛頓)。但它比TCMalloc還多了2件東西:逃逸分析(后面篇幅會提及)和垃圾回收。
總結(jié)一下它在底層設(shè)計上著重用到的2個重要觀念:
使用緩存提高效率:在存儲的整個體系中到處可見緩存的思想,利用緩存減少了系統(tǒng)調(diào)用的次數(shù),降低了鎖的粒度、減少加鎖的次數(shù),提高了管理效率。
以空間換時間:空間換時間是一種常用的性能優(yōu)化思想,數(shù)據(jù)庫的索引/許多數(shù)據(jù)結(jié)構(gòu)的本質(zhì)就是空間換時間。
關(guān)聯(lián)知識點
逃逸分析
Go堆內(nèi)存所使用的內(nèi)存頁與goroutine的棧所使用的內(nèi)存頁是交織在一起的,帶GC(垃圾回收)功能的GO語言會對位于堆上的對象進行自動管理。當某個對象不可達時,即沒有其對象引用它時,它將會被回收并被重用(三色標記)。但GC是會給程序帶來性能損耗的,尤其是當堆內(nèi)存上有大量待掃描的堆內(nèi)存對象時,將會給GC帶來過大的壓力,從而消耗更多的計算和存儲資源。于是開發(fā)者們都想盡量減少在堆上的內(nèi)存分配,可以在棧上分配的變量盡量留在棧上。
逃逸分析(escape analysis)就是在程序編譯階段根據(jù)程序代碼中的數(shù)據(jù)流,對代碼中哪些變量需要在棧上分配,哪些變量需要在堆上分配進行靜態(tài)分析的方法。
分析準則: 逃逸分析是在編譯器完成的,也就是只存在于編譯階段;如果變量在函數(shù)外部沒有引用,則優(yōu)先放到棧中;如果變量在函數(shù)外部存在引用,則必定放在堆中。

命令:go build -gcflags '-m -m -l' xxx.go
內(nèi)存對齊
CPU訪問內(nèi)存時,并不是逐個字節(jié)訪問,而是以字長為單位訪問。這樣是為了是減少CPU訪問內(nèi)存的次數(shù),提升CPU訪問內(nèi)存的吞吐量。如果訪問對象在內(nèi)存的存儲空間是對齊的話,CPU讀取一次即可,否則就要讀取兩次甚至多次,如下圖清晰可見。

對齊規(guī)則
1.第一個成員在與結(jié)構(gòu)體變量偏移量為0的地址處;2.其他成員變量要對齊到對齊數(shù)的整數(shù)倍的地址處;3.結(jié)構(gòu)體總大小為最大對齊數(shù)的整數(shù)倍。
下圖是不同類型的對齊系數(shù)和占用字節(jié)數(shù)

所以我們在日常編碼過程中,要盡量對結(jié)構(gòu)體的變量類型做針對性的順序調(diào)整,以符合對齊原則。
One More Thing
介紹完了Go的情況,最后來簡單看下其他語言的,作者本人對Java不太熟悉,就不摻和了,就用最簡單的描述來講一下相對熟悉的php和python。
php:php也是有一個基本的分配單元叫chunk,chunk分配了512個page,page的大小為4KB。內(nèi)存分配模式也是有3種,small(小于等于3KB),large(大于3KB小于等于2MB-4KB內(nèi)存),huge(大于2MB-4KB內(nèi)存),GC機制是引用計數(shù)方式,對堆區(qū)zend_mm_heap的管控就相對非常隨意了(純個人心得理解)。
python:py最大的特色是有個內(nèi)存池,可以減少內(nèi)存碎片化,提高執(zhí)行效率?;厥諜C制也是引用計數(shù),但是它有標記/清除和分代回收兩個輔助功能。
綜合3種語言對比,可以看到既有共同交集的地方,也有各自的私有屬性特色,各自的管理分配方式用到自己的語言環(huán)境下都能發(fā)揮最大的作用和效率。這也驗證了一句至高的哲學(xué):方案設(shè)計或者架構(gòu)理念,是沒有最優(yōu)秀最完美的,但是會有最適合最貼近使用場景的。
以上就是詳解Go是如何優(yōu)雅進行內(nèi)存管理的詳細內(nèi)容,更多關(guān)于Go內(nèi)存管理的資料請關(guān)注腳本之家其它相關(guān)文章!
相關(guān)文章
Go結(jié)合JavaScript實現(xiàn)抓取網(wǎng)頁中的圖像鏈接
這篇文章主要為大家詳細介紹了Go語言如何結(jié)合JavaScript實現(xiàn)抓取網(wǎng)頁中的圖像鏈接,文中的示例代碼講解詳細,有需要的小伙伴可以跟隨小編一起學(xué)習(xí)一下2023-11-11
簡單聊聊Golang中defer預(yù)計算參數(shù)
在golang當中defer代碼塊會在函數(shù)調(diào)用鏈表中增加一個函數(shù)調(diào)用,下面這篇文章主要給大家介紹了關(guān)于Golang中defer預(yù)計算參數(shù)的相關(guān)資料,文中通過實例代碼介紹的非常詳細,需要的朋友可以參考下2022-03-03
Go|使用Options模式和建造者模式創(chuàng)建對象實戰(zhàn)
這篇文章主要介紹了Go使用Options模式和建造者模式創(chuàng)建對象實戰(zhàn)詳解,有需要的朋友可以借鑒參考下,希望能夠有所幫助,祝大家多多進步,早日升職加薪2023-04-04
CentOS 32 bit安裝golang 1.7的步驟詳解
Go是Google開發(fā)的一種編譯型,并發(fā)型,并具有垃圾回收功能的編程語言。在發(fā)布了6個rc版本之后,Go 1.7終于正式發(fā)布了。本文主要介紹了在CentOS 32 bit安裝golang 1.7的步驟,文中給出了詳細的步驟,相信對大家的學(xué)習(xí)和理解具有一定的參考借鑒價值,下面來一起看看吧。2016-12-12
Golang中struct{}和struct{}{}的區(qū)別解析
這篇文章主要介紹了Golang中struct{}和struct{}{}的區(qū)別,通過實例代碼給大家介紹的非常詳細,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友可以參考下2023-03-03

