# Golang內存分配
## 1引言
golang是谷歌2009年發布的開源編程語言,截止目前go的release版本已經到了1.12,golang語言專門針對多處理器系統應用程序的編程進行了優化,使用golang編譯的程序可以媲美C /C++代碼的速度,而且更加安全、支持并行進程。和其他“高級語言”一樣,golang同樣有一套自己的內存管理機制,自主的去完成內存分配、垃圾回收、內存管理等過程,從而避免頻繁的向操作系統申請、釋放內存,有效的提升go語言的處理性能。由于篇幅有限,本文重點針對golang1.12.6版本就內存分配情況進行一下梳理和講解。golang的內存管理是基于tcmalloc模型設計,但又有些差異,局部緩存并不是分配給進程或者線程,而是分配給P(Processor);golang的GC是stop the world,并不是每個進程單獨進行GC;golang語言對span的管理更有效率。
在進入正題之前,我們先回顧一下c語言內存是如何申請的,常用方式是調用malloc函數,指定要分配的大小,直接向操作系統申請,那我們來思考一下這種方式有沒有什么問題?它會涉及到用戶態和內核態的切換過程,那么頻繁的進行用戶態和內核態切換就會帶來很大的耗時,導致性能下降,因此我們必須從語言層面找一種方式減少這么操作,那就是自己做一套內存管理機制。
就內存管理來說,如果要讓我們去設計,思考一下都需要哪些功能模塊才能保證高效穩定?
* 內存池:要減少用戶態和內核態的頻繁切換就需要自己申請一塊內存空間,將之分割成大小規格不同的內存塊來供程序使用,內存池是再適合不過的組成部分了。
* GC:內存管理不光需要使用方便,還要保證內存使用過程能夠節約,畢竟整個系統的內存資源是有限的,那么就需要GC進行動態的垃圾回收,銷毀無用的對象,釋放內存來保證整個程序乃至系統運行平穩。
* 鎖:一個應用程序內部之間存在大量的線程,線程之間資源是共享的,那么要保證同一塊內存使用過程不出現復用或者污染,就必須保證同一時間只能有一個線程進行申請,第一個想到的肯定是鎖,對公共區域的資源一定要加鎖,另一種方式就是內存隔離,這個在golang的mcache中會有體現。
下面我們進入正題,基于上面分析的問題對golang進行一下研究,看看golang到底怎么管理內存的。
## 2基本概念
### 1.什么是span
首先我們來介紹一下span的概念,span是golang內存管理的基本單位,每個span管理指定規格(以page為單位)的內存塊,內存池分配出不同規格的內存塊就是通過span體現出來的,應用程序創建對象就是通過找到對應規格的span來存儲的,下面我們看一下mspan的結構。
go1.12.6\src\runtime\mheap.go


根據源碼和上圖結合來看,會更加容易理解mspan,每一個mspan就是用來給程序分配對象空間的,也就是說一般我們對象都會放到mspan中管理,這里我們重點解釋一下如圖所示的幾個屬性,startAddr是該mspan在arena區域的首地址,freeindex用來表示下一個可能是空對象的位置,也就是說freeindex之前的元素(存儲對象的空間)均是已經被使用的,freeindex之后的元素可能被使用可能沒被使用,allocCache是從freeindex開始對后續元素分配情況進行緩存標記,通過freeindex和allocCache結合進行查找未分配的元素位置效率會更高,我們能快速的找到一個空對象分配給程序使用,而不用全局遍歷。allocBits用來標識該span中所有元素的使用分配情況,gcmarkBits用來sweep過程進行標記垃圾對象的,用于后續gc。
### 2.怎么區分span
那么要想區分不同規格的span,我們必須要有一個標識,每個span通過splanclass標識屬于哪種規格的span,golang的span規格一共有67種,具體查看
go1.12.6\src\runtime\sizeclasses.go,可看到下圖的規格表

其中:
* class:分類id或者規格id,也就是spanclass,表示該span可存儲的對象規格類型
* bytes/obj:該列代表能存儲每個對象的字節數,也就是說可以存儲多大的對象,字段是elemsize
* bytes/span:每個span占用堆的字節數,也即頁數*頁大小,npages*8KB
* objects:每個span可分配的元素個數,或者說可存儲的對象個數,也就是nelems,也即(bytes/spans)/(bytes/obj)
* tail bytes:每個span產生的內存碎片,也即(bytes/span)%(bytes/obj)
* max waste:最大浪費比例,(bytes/obj-最小使用量)*objects/(bytes/span)*100,比如classId=2最小使用量是9bytes,則max waste=(16-9)*512/8192*100=43.75%
通過上表,我們可以很清楚的知道在創建一個對象時候,需要去選哪一個splanclass的span去獲取內存空間,一個span能存多少這樣大小的對象等等信息,非常清晰而又盡可能節約的去使用內存。另外上表可見最大的對象是32KB大小,超過32KB大小的由特殊的class表示,該class ID為0,每個class只包含一個對象。所以上面只有列出了1-66。
## 3內存管理組件
闡述完一些基本概念,我們可以知道對象是存在span中,大家肯定會疑惑那span放在哪,怎么把這些各種規格孤立的span串起來?下面我們來說一下golang的內存管理組件,內存分配是由內存分配器完成,分配器由3種組件構成:mcache、mcentral、mheap,我們來詳細講一下每個組件。
我們知道golang之所有有很強的并發能力,依賴于它的G-P-M并發模型,

### 1.mcache
mcache就綁在并發模型的P上,也就是說我們每一個P都會有一個mcahe綁定,用來給協程分配對象存儲空間的。下面具體看一下mcache的結構
go1.12.6\src\runtime\mcache.go

可以看到在mcache結構體中并沒有鎖存在,這是因為每個P都會綁定一個mcache,而每個P同時只會處理一個groutine,而且不同P之間是內存隔離的,因此不存在競爭情況。關鍵字段都已經在代碼中解釋了,這里我們重點關注一下alloc [numSpanClasses]*mspan,由于SpanClasses一共有67種,為了滿足指針對象和非指針對象,這里為每種規格的span同時準備scan和noscan兩個,因此一共有134個mspan緩存鏈表,分別用于存儲指針對象和非指針對象,這樣對非指針對象掃描的時候不需要繼續掃描它是否引用其他對象,GC掃描對象的時候對于noscan的span可以不去查看bitmap區域來標記子對象,這樣可以大幅提升標記的效率。另外mcache在初始化時是沒有任何mspan資源的,在使用過程中會動態地申請,不斷的去填充alloc[numSpanClasses]*mspan,通過雙向鏈表連接,如下圖所示:

通過圖示我們可以看到alloc[numSpanClasses]*mspan管理了很多不同規格不同類型的span,golang對于[16B,32KB]的對象會使用這部分span進行內存分配,所以所有在這區間大小的對象都會從alloc這個數組里尋找,看下源碼:

而對于更小的對象,我們叫它tiny對象,golang會通過tiny和tinyoffset組合尋找位置分配內存空間,這樣可以更好的節約空間。源碼如下:

### 2.mcentral
剛才我們提到mcache中的mspan都是動態申請的,那到底是去哪里申請呢?其實當空間不足的時候,mcache會去mcentral中申請對應規格的mspan,我們來繼續看一下mcentral,先來看一下結構,go1.12.6\src\runtime\mcentral.go

看到mcentral的結構體會覺得很簡單,首先與mcache有一個明顯區別,就是有鎖存在,由于mcentral是公共資源,會有多個mcache向它申請mspan,因此必須加鎖,另外,mcentral與mcache不同,由于P綁定了很多Goroutine,在P上會處理不同大小的對象,mcache就需要包含各種規格的mspan,但mcentral不同,同一個mcentral只負責一種規格的mspan就夠了,mcache就像一個市政府,mcentral就像國家部委,市政府需要管管轄區域內的所有方面的事情,而每個部委很專一,只管一方面,市政府需要哪方面資源,就去和對應部委對接就可以了。mcentral也是用spanclass進行標記規格類型,該規格的所有未被使用的空閑mspan會掛載到nonempty鏈表上,已經被mcache拿走,未歸還的會掛載到empty鏈表上,歸還后會再掛載到nonempty上,用圖表示如下,以規格sizeClass=1為例:

每一個mSpanList都掛著同一規格mspan雙向鏈表,當然這個鏈表也不是固定大小的,都會動態變化的。
### 3.mheap
mcentral的nonempty也有用完的時候,當nonempty為空,再被申請的時候,也就是mcentral空間不足了,那么它會向mheap申請新的頁,下面我們看一下mheap結構。
go1.12.6\src\runtime\mheap.go

通個看這個結構,可以感覺到mheap相對復雜一些,重要字段我已經在代碼中注釋,我們知道每個golang程序啟動時候會向操作系統申請一塊虛擬內存空間,僅僅是虛擬內存空間,真正需要的時候才會發生缺頁中斷,向系統申請真正的物理空間,在golang1.11版本以后,申請的內存空間會放在一個heapArena數組里,由arenas [1 << arenaL1Bits]*[1 << arenaL2Bits]*heapArena表示,用于應用程序內存分配,下面展示一下數組中一塊heapArena虛擬內存空間區域分配,

分為三個區域,分別是:
* spans區域:存放span指針地址的地方,每個指針大小是8Byte
* bitmap區域:用于標記arena區域中哪些地址保存了對象,并且對象中哪些地址包含了指針,主要用于GC
* arena區域:heap區域,程序內存分配的地方,管理的最小基本單位是頁,golang一個page的大小是:8KB
下面看一下每個區域的大小情況
heapArena結構體如下:

關鍵字段計算定義如下:

通過源碼可以看出spans大小等于arenaSize/8KB,可以理解為有多少page就準備出對應數量的“地址格子”,來充分保證能存下所有的span地址。
對于bitmap區域,由于bitmap是用來標記每個地址空間的使用情況,我們知道指針大小是8Byte,因此需要arenaSize/8個,一個bitmap可以標記四個地址,因此再除4。

如上圖所示,是bitmap區域一個字節對arena區域的標記情況情況,高四位標記四個內存地址使用情況,低四位標記存儲的是否是指針。對于arenaSize,根據源碼公式,在64位非windows系統分配大小是64MB,windows 64位是4MB。
介紹完三個區域,我們再來看一下central [numSpanClasses],它就是管理的所有規格mcentral的集合,同樣是134種,pad對齊填充用于確保mcentrals以CacheLineSize個字節數分隔,所以每一個MCentral.lock都可以獲取自己的緩存行。而fixalloc類型的相關成員都是用來分配span、mache等對象的內存分配器,這里大家不要搞暈,具體來講,以span舉例,每一個span也需要空間存儲,這個就是在spanalloc這個二叉樹堆上存儲,拿到這個對象,將startAddr指向arena區域內的npages的內存空間才是給mcache使用的,或者說給P進行對象分配的。另外,由于mheap也是公共資源,一定也要有鎖的存在。
下面結合圖看一下:

從上圖可以更清楚的看到,一個mheap會有134種mcentral,而每一種規格的mcentral會掛載該規格的mspan鏈表。
前面我們講過tiny對象和小對象的內存分配,那大于32KB的對象怎么辦呢?golang將大于32KB的對象定義為大對象,直接通過mheap分配。這些大對象的申請是以一個全局鎖為代價的,所以同時只能服務一個P申請,大對象內存分配一定是頁(8KB)的整數倍。結合源碼再看一下:

可以看出不管多大對象,一切的空間都是從mheap獲取的,那mheap要是不足了呢?就只能向操作系統申請了。
## 4內存分配規則
講完內存管理組件,我們再來總結一下內存分配規則:
* tiny對象內存分配,直接向mcache的tiny對象分配器申請,如果空間不足,則向mcache的tinySpanClass規格的span鏈表申請,如果沒有,則向mcentral申請對應規格mspan,依舊沒有,則向mheap申請,最后都用光則向操作系統申請。
* 小對象內存分配,先向本線程mcache申請,發現mspan沒有空閑的空間,向mcentral申請對應規格的mspan,如果mcentral對應規格沒有,向mheap申請對應頁初始化新的mspan,如果也沒有,則向操作系統申請,分配頁。
* 大對象內存分配,直接向mheap申請spanclass=0,如果沒有則向操作系統申請。
流程圖如下:

部分內存申請源碼源碼如下:
mcache向mcentral申請,調用go1.12.6\src\runtime\mcache.go refill方法

mcentral空間不足,向mheap申請分配頁創建新的mspan,調用go1.12.6\src\runtime\mcental.go grow方法

mheap空間不足會調用go1.12.6\src\runtime\mheap.go grow方法進行系統申請

gc改進
通過上節流程圖和代碼,我們可以清晰的知道一個對象內存申請的整個過程,那思考一下這個流程是否完善,我們都知道golang通過gc進行垃圾回收,而完整的gc需要兩次stop the world,如果我們完全依賴gc去垃圾回收是不是影響整個程序的性能,我們假設一個場景,mcentral的span一直不夠用,那會不斷的去向mheap去申請page空間,導致mheap的使用率很快就觸發到gc的閾值,啟動gc處理過程,頻繁的gc就會導致頻繁的程序停服,極大的會影響程序服務性能,那golang的做法是怎么樣的呢?,在1.12版本里面golang對mheap結構添加了reclaimCredit成員變量,每次mcentral向mheap申請新的page空間創建span的時候,都會先去掃描arenas里面的heapArena,去清理垃圾對象回收相同page數量的空間,由于掃描到的垃圾對象不可能正好等于相同page,多清理的page大小就會存到到reclaimCredit里面,下一次再掃描arenas的時候會先去抵消reclaimCredit,如果不夠才會去掃描heapArena。通過這種方式有效的防止mheap使用率過快增長,下面是整個流程圖:

同理,我們知道當mheap不夠用的時候,會去向操作系統申請內存空間,如果增長過快,也會造成整個操作系統的不穩定,golang對這部分也做了處理,1.12版本mheap引入scavengeCredit這個成員變量,當向操作系統申請內存空間的時候,會先去掃描free這個二叉樹堆,span從大到小的掃描,釋放所需大小的空間給os,多余釋放的到小會存儲到scavengeCredit中,下次再次掃描的時候會先扣除這個值。下面是整個流程圖:

## 5結尾
到此也就基本講完了golang的內存分配的整個環節,本文也是受php內存管理啟發,進行了一下golang源碼深入研究,由于篇幅關系并沒有把源碼中的各種細節進行詳細講解,僅對整體流程進行梳理和闡述,對關鍵源碼進行注釋和解釋,希望能給對golang感興趣的伙伴給予一定幫助,如需更具體的了解,可以根據這個大流程進行源碼學習。本文基于1.12.6版本源碼一點點梳理,不足之處還請各位不吝雅正。
(文/朱清偉 編/張利利)
- 序言
- Swoole協程之旅
- 深入理解swoole協程實現
- Swoole Server淺析
- Golang內存分配
- Golang Map實現原理分析
- Golang Slice實現原理分析
- Golang Channel實現原理分析
- Golang Interface實現原理分析
- Nginx中的紅黑樹
- 初探Nginx HTTP處理流程
- Nginx源碼 - 啟動流程概述
- Nginx 源碼 - 配置文件解析
- Nginx源碼 - 進程工作模式
- Nginx源代碼分析 共享內存
- 剖析頁面耗時思路解析
- 移動端適配最佳實踐
- 實時計算在學而思網校的落地
- WebRTC編譯國內加速鏡像
- 學而思網校1v1家長端在Flutter中的實踐
- 基于sentence pair文本相似度識別算法在網校的應用
- 從渲染原理談前端性能優化
- Vue 源碼 - import vue 干了什么
- 從Google V8引擎剖析Promise實現
- Qt 信號和槽源碼分析
- Elasticsearch寫過程源碼淺析(協調節點)
- Apache Kylin Cube 構建原理剖析與優化
- 哈希表在Redis中的實現與應用
- 使用dpdk優化容器網絡
- 一文讀懂Service Mesh技術
- 經典算法問題之平面點集最小外接圓
- 底層優化之位運算的技巧
- 范數的概念
- 深度學習中過擬合問題屠龍刀 —— 正則化
- 學而思網校技術團隊Hackathon1.0大紀實