![](https://img.laitimes.com/img/__Qf2AjLwojIjJCLyojI0JCLiAjM2EzLcd3LcJzLcJzdllmVldWYtl2Pn5GcuYTMhRGO4I2YmZWMyEmZmVTN4Q2YzY2MwMGOyYWOlVWYvwFNyAjMyQTMtUGall3LcVmdhNXLwRHdo9CXt92YucWbpRWdvx2Yx5yazF2Lc9CX6MHc0RHaiojIsJye.png)
導語 | 現代進階程式設計語言管理記憶體的方式分自動和手動兩種。手動管理記憶體的典型代表是C和C++,編寫代碼過程中需要主動申請或者釋放記憶體;而Java和Go等語言使用自動的記憶體管理系統,由記憶體配置設定器和垃圾收集器來代為配置設定和回收記憶體,開發者隻需關注業務代碼而無需關注底層記憶體配置設定和回收,雖然語言幫我們處理了這部分,但是還是有必要去了解一下底層的架構設計和執行邏輯,這樣可以更好的掌握一門語言,本文主要以go記憶體管理為切入點再到go垃圾回收,系統地講解了go自動記憶體管理系統的設計和原理。
一、TCMalloc
go記憶體管理是借鑒了TCMalloc的設計思想,TCMalloc全稱Thead-Caching Malloc,是google開發的記憶體配置設定器,為了友善了解下面的go記憶體管理,有必要要先熟悉一下TCMalloc。
(一)Page
作業系統對記憶體管理以頁為機關,TCMalloc也是這樣,隻不過TCMalloc裡的Page大小與作業系統裡的大小并不一定相等,而是倍數關系。
(二) Span
一組連續的Page被稱為Span,比如可以有4個頁大小的Span,也可以有8個頁大小的Span,Span比Page高一個層級,是為了友善管理一定大小的記憶體區域,Span是TCMalloc中記憶體管理的基本機關。
(三) ThreadCache
每個線程各自的Cache,一個Cache包含多個空閑記憶體塊連結清單,每個連結清單連接配接的都是記憶體塊,同一個連結清單上記憶體塊的大小是相同的,也可以說按記憶體塊大小,給記憶體塊分了個類,這樣可以根據申請的記憶體大小,快速從合适的連結清單選擇空閑記憶體塊。由于每個線程有自己的ThreadCache,是以ThreadCache通路是無鎖的。
(四)CentralCache
是所有線程共享的緩存,也是儲存的空閑記憶體塊連結清單,連結清單的數量與ThreadCache中連結清單數量相同,當ThreadCache記憶體塊不足時,可以從CentralCache取,當ThreadCache記憶體塊多時,可以放回CentralCache。由于CentralCache是共享的,是以它的通路是要加鎖的。
(五)PageHeap
PageHeap是堆記憶體的抽象,PageHeap存的也是若幹連結清單,連結清單儲存的是Span,當CentralCache沒有記憶體的時,會從PageHeap取,把1個Span拆成若幹記憶體塊,添加到對應大小的連結清單中,當CentralCache記憶體多的時候,會放回PageHeap。
(六)TCMalloc對象配置設定
小對象直接從ThreadCache配置設定,若ThreadCache不夠則從CentralCache中擷取記憶體,CentralCache記憶體不夠時會再從PageHeap擷取記憶體,大對象在PageHeap中選擇合适的頁組成span用于存儲資料。
二、GO記憶體管理
經過上一節對TCMalloc記憶體管理的描述,對接下來了解go的記憶體管理會有大緻架構的熟悉,go記憶體管理架構取之TCMalloc不過在細節上有些出入,先來看一張go記憶體管理的架構圖:
(一)Page
和TCMalloc中page相同,上圖中最下方淺藍色長方形代表一個page。
(二)Span
與TCMalloc中的Span相同,Span是go記憶體管理的基本機關,代碼中為mspan,一組連續的Page組成1個Span,是以上圖一組連續的淺藍色長方形代表的是一組Page組成的1個Span,另外,1個淡紫色長方形為1個Span。
(三)mcache
mcache與TCMalloc中的ThreadCache類似,mcache儲存的是各種大小的Span,并按Span class分類,小對象直接從mcache配置設定記憶體,它起到了緩存的作用,并且可以無鎖通路。但mcache與ThreadCache也有不同點,TCMalloc中是每個線程1個ThreadCache,Go中是每個P擁有1個mcach,因為在Go程式中,目前最多有GOMAXPROCS個線程在運作,是以最多需要GOMAXPROCS個mcache就可以保證各線程對mcache的無鎖通路,下圖是G,P,M三者之間的關系:
(四)mcentral
mcentral與TCMalloc中的CentralCache類似,是所有線程共享的緩存,需要加鎖通路,它按Span class對Span分類,串聯成連結清單,當mcache的某個級别Span的記憶體被配置設定光時,它會向mcentral申請1個目前級别的Span。但mcentral與CentralCache也有不同點,CentralCache是每個級别的Span有1個連結清單,mcache是每個級别的Span有2個連結清單。
(五)mheap
mheap與TCMalloc中的PageHeap類似,它是堆記憶體的抽象,把從OS(系統)申請出的記憶體頁組織成Span,并儲存起來。當mcentral的Span不夠用時會向mheap申請,mheap的Span不夠用時會向OS申請,向OS的記憶體申請是按頁來的,然後把申請來的記憶體頁生成Span組織起來,同樣也是需要加鎖通路的。但mheap與PageHeap也有不同點:mheap把Span組織成了樹結構,而不是連結清單,并且還是2棵樹,然後把Span配置設定到heapArena進行管理,它包含位址映射和span是否包含指針等位圖,這樣做的主要原因是為了更高效的利用記憶體:配置設定、回收和再利用。
(六)記憶體配置設定
Go中的記憶體分類并不像TCMalloc那樣分成小、中、大對象,但是它的小對象裡又細分了一個Tiny對象,Tiny對象指大小在1Byte到16Byte之間并且不包含指針的對象。小對象和大對象隻用大小劃定,無其他區分,其中小對象大小在16Byte到32KB之間,大對象大小大于32KB。span規格分類 上面說到go的記憶體管理基本機關是span,且span有不同的規格,要想區分出不同的的span,我們必須要有一個辨別,每個span通過spanclass辨別屬于哪種規格的span,golang的span規格一共有67種,具體如下:
//from runtime.gosizeclasses.go
// class bytes/obj bytes/span objects tail waste max waste// 1 8 8192 1024 0 87.50%// 2 16 8192 512 0 43.75%// 3 32 8192 256 0 46.88%// 4 48 8192 170 32 31.52%// 5 64 8192 128 0 23.44%// 6 80 8192 102 32 19.07%// 7 96 8192 85 32 15.95%// 8 112 8192 73 16 13.56%// 9 128 8192 64 0 11.72%// 10 144 8192 56 128 11.82%// 11 160 8192 51 32 9.73%// 12 176 8192 46 96 9.59%// 13 192 8192 42 128 9.25%// 14 208 8192 39 80 8.12%// 15 224 8192 36 128 8.15%// 16 240 8192 34 32 6.62%// 17 256 8192 32 0 5.86%// 18 288 8192 28 128 12.16%// 19 320 8192 25 192 11.80%// 20 352 8192 23 96 9.88%// 21 384 8192 21 128 9.51%// 22 416 8192 19 288 10.71%// 23 448 8192 18 128 8.37%// 24 480 8192 17 32 6.82%// 25 512 8192 16 0 6.05%// 26 576 8192 14 128 12.33%// 27 640 8192 12 512 15.48%// 28 704 8192 11 448 13.93%// 29 768 8192 10 512 13.94%// 30 896 8192 9 128 15.52%// 31 1024 8192 8 0 12.40%// 32 1152 8192 7 128 12.41%// 33 1280 8192 6 512 15.55%// 34 1408 16384 11 896 14.00%// 35 1536 8192 5 512 14.00%// 36 1792 16384 9 256 15.57%// 37 2048 8192 4 0 12.45%// 38 2304 16384 7 256 12.46%// 39 2688 8192 3 128 15.59%// 40 3072 24576 8 0 12.47%// 41 3200 16384 5 384 6.22%// 42 3456 24576 7 384 8.83%// 43 4096 8192 2 0 15.60%// 44 4864 24576 5 256 16.65%// 45 5376 16384 3 256 10.92%// 46 6144 24576 4 0 12.48%// 47 6528 32768 5 128 6.23%// 48 6784 40960 6 256 4.36%// 49 6912 49152 7 768 3.37%// 50 8192 8192 1 0 15.61%// 51 9472 57344 6 512 14.28%// 52 9728 49152 5 512 3.64%// 53 10240 40960 4 0 4.99%// 54 10880 32768 3 128 6.24%// 55 12288 24576 2 0 11.45%// 56 13568 40960 3 256 9.99%// 57 14336 57344 4 0 5.35%// 58 16384 16384 1 0 12.49%// 59 18432 73728 4 0 11.11%// 60 19072 57344 3 128 3.57%// 61 20480 40960 2 0 6.87%// 62 21760 65536 3 256 6.25%// 63 24576 24576 1 0 11.45%// 64 27264 81920 3 128 10.00%// 65 28672 57344 2 0 4.91%// 66 32768 32768 1 0 12.50%
複制
由上表可見最大的對象是32KB大小,超過32KB大小的由特殊的class表示,該class ID為0,每個class隻包含一個對象。是以上面隻有列出了1-66。記憶體大小轉換,下面還要三個數組,分别是:class_to_size,size_to_class和class_to_allocnpages3個數組,對應下圖上的3個箭頭:
以第一列為例,類别1的對象大小是8bytes,是以class_to_size[1]=8;span大小是8KB,為1頁,是以class_to_allocnpages[1]=1,下圖是go源碼中大小轉換數組。
為對象尋找span,尋找span的流程如下:
- 計算對象所需記憶體大小size。
- 根據size到size class映射,計算出所需的size class。
- 根據size class和對象是否包含指針計算出span class。
- 擷取該span class指向的span。
以配置設定一個包含指針大小為20Byte的對象為例,根據映射表:
// class bytes/obj bytes/span objects tail waste max waste// 1 8 8192 1024 0 87.50%// 2 16 8192 512 0 43.75%// 3 32 8192 256 0 46.88%
複制
size class 3,它的對象大小範圍是(16,32]Byte,20Byte剛好在此區間,是以此對象的size class為3,Size class到span class的計算如下:
// noscan為false代表對象包含指針func makeSpanClass(sizeclass uint8, noscan bool) spanClass { return spanClass(sizeclass<<1) | spanClass(bool2int(noscan))}
複制
是以,對應的span class為:
span class = 3 << 1 | 0 = 6
複制
是以該對象需要的是span class 7指向的span,自此,小對象記憶體配置設定完成。
//from runtime.gomalloc.go
var sizeclass uint8//step1: 确定規格sizeClassif size <= smallSizeMax-8 { sizeclass = size_to_class8[divRoundUp(size, smallSizeDiv)]} else { sizeclass = size_to_class128[divRoundUp(size-smallSizeMax, largeSizeDiv)]}size = uintptr(class_to_size[sizeclass])// size class到span classspc := makeSpanClass(sizeclass, noscan)//step2: 配置設定對應spanClass 的 spanspan = c.alloc[spc]v := nextFreeFast(span)if v == 0 { v, span, shouldhelpgc = c.nextFree(spc)}x = unsafe.Pointer(v)if needzero && span.needzero != 0 { memclrNoHeapPointers(unsafe.Pointer(v), size)}
複制
大對象(>32KB)的配置設定則簡單多了,直接在mheap上進行配置設定,首先計算出需要的記憶體頁數和span class級别,然後優先從free中搜尋可用的span,如果沒有找到,會從scav中搜尋可用的span,如果還沒有找到,則向OS申請記憶體,再重新搜尋2棵樹,必然能找到span。如果找到的span比需求的span大,則把span進行分割成2個span,其中1個剛好是需求大小,把剩下的span再加入到free中去。
三、垃圾回收
(一)标記-清除
标記-清除算法是第一種自動記憶體管理,基于追蹤的垃圾收集算法。算法思想在70年代就提出了,是一種非常古老的算法。記憶體單元并不會在變成垃圾立刻回收,而是保持不可達狀态,直到到達某個門檻值或者固定時間長度。這個時候系統會挂起使用者程式,也就是STW,轉而執行垃圾回收程式。垃圾回收程式對所有的存活單元進行一次全局周遊确定哪些單元可以回收。算法分兩個部分:标記(mark)和清除(sweep)。标記階段表明所有的存活單元,清掃階段将垃圾單元回收。可視化可以參考下圖。
标記-清除算法的優點也就是基于追蹤的垃圾回收算法具有的優點:避免了引用計數算法的缺點(不能處理循環引用,需要維護指針)。缺點也很明顯,需要STW。
(二)三色可達性分析
三色标記算法是對标記階段的改進,原理如下:
- 起初所有對象都是白色。
- 從根出發掃描所有可達對象,标記為灰色,放入待處理隊列。
- 從隊列取出灰色對象,将其引用對象标記為灰色放入隊列,自身标記為黑色。
- 重複上一步,直到灰色對象隊列為空。此時白色對象即為垃圾,進行回收。
三色标記的一個明顯好處是能夠讓使用者程式和mark并發的進行,不過三色标記清除算法本身是不可以并發或者增量執行的,它需要STW,而如果并發執行,使用者程式可能在标記執行的過程中修改對象的指針,導緻可能将本該死亡的對象标記為存活和本該存活的對象标記為死亡,為了解決這種問題,go v1.8之後使用混合寫屏障技術支援并發和增量執行,将垃圾收集的時間縮短至0.5ms以内。
(三)gc觸發
在堆上配置設定大于32K byte對象的時候進行檢測此時是否滿足垃圾回收條件,如果滿足則進行垃圾回收
func mallocgc(size uintptr, typ *_type, needzero bool) unsafe.Pointer { ... shouldhelpgc := false // 配置設定的對象小于 32K byte if size <= maxSmallSize { ... } else { shouldhelpgc = true ... } ... // gcShouldStart() 函數進行觸發條件檢測 if shouldhelpgc && gcShouldStart(false) { // gcStart() 函數進行垃圾回收 gcStart(gcBackgroundMode, false) }}
複制
上面是自動垃圾回收,還有一種主動垃圾回收,通過調用runtime.GC(),這是阻塞式的。
// GC runs a garbage collection and blocks the caller until the// garbage collection is complete. It may also block the entire// program.func GC() { gcStart(gcForceBlockMode, false)}
複制
系統gc觸發條件:觸發條件主要關注下面代碼中的中間部分:forceTrigger||memstats.heap_live>=memstats.gc_trigger。forceTrigger是forceGC的标志,後面半句的意思是目前堆上的活躍對象大于我們初始化時候設定的GC觸發門檻值,在malloc以及free的時候heap_live會一直進行更新。
// gcShouldStart returns true if the exit condition for the _GCoff// phase has been met. The exit condition should be tested when// allocating.//// If forceTrigger is true, it ignores the current heap size, but// checks all other conditions. In general this should be false.func gcShouldStart(forceTrigger bool) bool { return gcphase == _GCoff && (forceTrigger || memstats.heap_live >= memstats.gc_trigger) && memstats.enablegc && panicking == 0 && gcpercent >= 0}
//初始化的時候設定 GC 的觸發門檻值func gcinit() { _ = setGCPercent(readgogc()) memstats.gc_trigger = heapminimum ...}// 啟動的時候通過 GOGC 傳遞百分比 x// 觸發門檻值等于 x * defaultHeapMinimum (defaultHeapMinimum 預設是 4M)func readgogc() int32 { p := gogetenv("GOGC") if p == "off" { return -1 } if n, ok := atoi32(p); ok { return n } return 100}
複制
(四)gc過程
下列源碼是基于go 1.8,由于源碼過長,是以這裡盡量隻關注主流程
- gcStart
// gcStart 是 GC 的入口函數,根據 gcMode 做處理。// 1. gcMode == gcBackgroundMode(背景運作,也就是并行), _GCoff -> _GCmark// 2. 否則 GCoff -> _GCmarktermination,這個時候就是主動 GCfunc gcStart(mode gcMode, forceTrigger bool) { ... //在背景啟動 mark worker if mode == gcBackgroundMode { gcBgMarkStartWorkers() } ... // Stop The World systemstack(stopTheWorldWithSema) ... if mode == gcBackgroundMode { // GC 開始前的準備工作
//處理設定 GCPhase,setGCPhase 還會 開始寫屏障 setGCPhase(_GCmark)
gcBgMarkPrepare() // Must happen before assist enable. gcMarkRootPrepare()
// Mark all active tinyalloc blocks. Since we're // allocating from these, they need to be black like // other allocations. The alternative is to blacken // the tiny block on every allocation from it, which // would slow down the tiny allocator. gcMarkTinyAllocs()
// Start The World systemstack(startTheWorldWithSema) } else { ... }}
複制
- Mark
func gcStart(mode gcMode, forceTrigger bool) { ... //在背景啟動 mark worker if mode == gcBackgroundMode { gcBgMarkStartWorkers() }}
func gcBgMarkStartWorkers() { // Background marking is performed by per-P G's. Ensure that // each P has a background GC G. for _, p := range &allp { if p == nil || p.status == _Pdead { break } if p.gcBgMarkWorker == 0 { go gcBgMarkWorker(p) notetsleepg(&work.bgMarkReady, -1) noteclear(&work.bgMarkReady) } }}// gcBgMarkWorker 是一直在背景運作的,大部分時候是休眠狀态,通過 gcController 來排程func gcBgMarkWorker(_p_ *p) { for { // 将目前 goroutine 休眠,直到滿足某些條件 gopark(...) ... // mark 過程 systemstack(func() { // Mark our goroutine preemptible so its stack // can be scanned. This lets two mark workers // scan each other (otherwise, they would // deadlock). We must not modify anything on // the G stack. However, stack shrinking is // disabled for mark workers, so it is safe to // read from the G stack. casgstatus(gp, _Grunning, _Gwaiting) switch _p_.gcMarkWorkerMode { default: throw("gcBgMarkWorker: unexpected gcMarkWorkerMode") case gcMarkWorkerDedicatedMode: gcDrain(&_p_.gcw, gcDrainNoBlock|gcDrainFlushBgCredit) case gcMarkWorkerFractionalMode: gcDrain(&_p_.gcw, gcDrainUntilPreempt|gcDrainFlushBgCredit) case gcMarkWorkerIdleMode: gcDrain(&_p_.gcw, gcDrainIdle|gcDrainUntilPreempt|gcDrainFlushBgCredit) } casgstatus(gp, _Gwaiting, _Grunning) }) ... }}
複制
Mark階段的标記代碼主要在函數gcDrain()中實作
// gcDrain scans roots and objects in work buffers, blackening grey// objects until all roots and work buffers have been drained.func gcDrain(gcw *gcWork, flags gcDrainFlags) { ... // Drain root marking jobs. if work.markrootNext < work.markrootJobs { for !(preemptible && gp.preempt) { job := atomic.Xadd(&work.markrootNext, +1) - 1 if job >= work.markrootJobs { break } markroot(gcw, job) if idle && pollWork() { goto done } } }
// 處理 heap 标記 // Drain heap marking jobs. for !(preemptible && gp.preempt) { ... //從灰色列隊中取出對象 var b uintptr if blocking { b = gcw.get() } else { b = gcw.tryGetFast() if b == 0 { b = gcw.tryGet() } } if b == 0 { // work barrier reached or tryGet failed. break } //掃描灰色對象的引用對象,标記為灰色,入灰色隊列 scanobject(b, gcw) }}
複制
- Sweep
func gcSweep(mode gcMode) { ... //阻塞式 if !_ConcurrentSweep || mode == gcForceBlockMode { // Special case synchronous sweep. ... // Sweep all spans eagerly. for sweepone() != ^uintptr(0) { sweep.npausesweep++ } // Do an additional mProf_GC, because all 'free' events are now real as well. mProf_GC() mProf_GC() return }
// 并行式 // Background sweep. lock(&sweep.lock) if sweep.parked { sweep.parked = false ready(sweep.g, 0, true) } unlock(&sweep.lock)}
複制
對于并行式清掃,在GC初始化的時候就會啟動 bgsweep(),然後在背景一直循環
func bgsweep(c chan int) { sweep.g = getg()
lock(&sweep.lock) sweep.parked = true c <- 1 goparkunlock(&sweep.lock, "GC sweep wait", traceEvGoBlock, 1)
for { for gosweepone() != ^uintptr(0) { sweep.nbgsweep++ Gosched() } lock(&sweep.lock) if !gosweepdone() { // This can happen if a GC runs between // gosweepone returning ^0 above // and the lock being acquired. unlock(&sweep.lock) continue } sweep.parked = true goparkunlock(&sweep.lock, "GC sweep wait", traceEvGoBlock, 1) }}
func gosweepone() uintptr { var ret uintptr systemstack(func() { ret = sweepone() }) return ret}
複制
不管是阻塞式還是并行式,最終都會調用sweepone()。上面說過go記憶體管理都是基于span的,mheap_是一個全局的變量,所有配置設定的對象都會記錄在mheap_中。在标記的時候,我們隻要找到對對象對應的span進行标記,清掃的時候掃描span,沒有标記的span就可以回收了。
// sweeps one span// returns number of pages returned to heap, or ^uintptr(0) if there is nothing to sweepfunc sweepone() uintptr { ... for { s := mheap_.sweepSpans[1-sg/2%2].pop() ... if !s.sweep(false) { // Span is still in-use, so this returned no // pages to the heap and the span needs to // move to the swept in-use list. npages = 0 } }}
// Sweep frees or collects finalizers for blocks not marked in the mark phase.// It clears the mark bits in preparation for the next GC round.// Returns true if the span was returned to heap.// If preserve=true, don't return it to heap nor relink in MCentral lists;// caller takes care of it.func (s *mspan) sweep(preserve bool) bool { ...}
複制
參考資料:
1.《go語言設計與實作》
作者簡介
冷易
騰訊背景開發工程師
騰訊背景開發工程師,目前負責騰訊醫藥平台後端開發工作,精通java、go底層設計架構,有豐富的高并發性能優化,分布式系統開發經驗。
推薦閱讀
5G正當時,無人駕駛未來将駛向何方?
C++異步:structured concurrency實作解析!
圖文并茂!帶你深度解析Kubernetes
萬卷共知,一書一頁總關情,TVP讀書會帶你突圍閱讀迷障!
溫馨提示:因公衆号平台更改了推送規則,公衆号推送的文章文末需要點一下“贊”和“在看”,新的文章才會第一時間出現在你的訂閱清單裡噢~