深入淺出內存管理:空間分配及逃逸分析
1. 引言
內存管理,是開發(fā)者在程序編寫和調優(yōu)的過程中不可繞開的話題,也是走向資深程序員必須要了解的計算機知識。
有經驗的面試官會從內存管理的掌握程度去考察一個候選人的技術水平,這里面涉及到的知識可能包括操作系統(tǒng)、計算機組成原理以及編程語言的底層實現(xiàn)等。
說到內存,其實就是存儲器,我們可以從馮.諾依曼的計算機結構來了解存儲器的概念:
圖片
什么?馮諾依曼你都不知道,是不是和我一樣,計算機基礎的課程沒有好好學呀?
別急!接下來我們由淺入深講到的內容,就算不了解計算機底層原理的同學也可以弄懂,一起接著往下看吧~
總之,存儲器是計算機中不可或缺的一部分,內存管理,其實就是對存儲器的存儲空間管理。
接下來,我們會從內存分類、以及 Go 語言的內存空間分配上,結合常見的逃逸分析場景,來學習內存管理相關的知識。
2. 虛擬內存
2.1 虛擬內存和物理內存的區(qū)別
我們都知道,以前的計算機存儲器空間很小,我們在運行計算機程序的時候物理尋址的范圍非常有限。
比如,在 32 位的機器上,尋址范圍只有 2 的 32 次方,也就是 4G。
并且,對于程序來說,這是固定的,我們可以想象一下,如果每開一個計算機進程就給它們分配 4G 的物理內存,那資源消耗就太大了。
圖片
資源的利用率也是一個巨大的問題,沒有分配到資源的進程就只能等待,當一個進程結束以后再把等待的進程裝入內存,而這種頻繁地裝入內存操作效率也很低。
并且,由于指令都是可以訪問物理內存的,那么任何進程都可以修改內存中其它進程的數(shù)據(jù),甚至修改內核地址空間的數(shù)據(jù),這是非常不安全的。
由于物理內存使用時,資源消耗大、利用率低及不安全的問題。因此,引入了虛擬內存。
虛擬內存是計算機系統(tǒng)內存管理的一種技術,通過分配虛擬的邏輯內存地址,讓每個應用程序都認為自己擁有連續(xù)可用的內存空間。
而實際上,這些內存空間通常是被分隔開的多個物理內存碎片,還有部分暫時存儲在外部磁盤存儲器上,在需要時進行數(shù)據(jù)交換。
2.2 虛擬內存轉換
既然計算機用到的都是虛擬內存,那我們如何拿到真實的物理內存地址呢?答案就是內存映射,即如何把虛擬地址(又被稱作邏輯地址)轉換成物理地址。
圖片
在 Linux 操作系統(tǒng)下,內存最先有兩種管理方式,分別是頁式存儲管理和段式存儲管理,其中:
- 頁式存儲能有效地解決內存碎片,提高內存利用率;
- 分段式存儲管理能反映程序的邏輯結構,并有利于段的共享;
通俗來講就是內存有兩種單位,一種是分頁,一種是分段。分頁就是把整個虛擬和物理內存空間切割成很多塊固定尺寸的大小,虛擬地址和物理地址間通過頁表來進行映射:
圖片
分頁內存都是預先劃分好的,所以不會產生間隙非常小的內存碎片,分配時利用率比較高。
而分段就不一樣了,它是基于程序的邏輯來分段的,由于程序屬性可能大不相同,所以分段的大小也會大小不一。
分段管理時,虛擬地址和物理地址間通過段表來進行映射:
圖片
不難發(fā)現(xiàn),分段內存管理的切分不是均勻的,而是根據(jù)不同的程序所占用的內存來分配。
這樣帶來的問題是,假設程序1的內存(1G)用完了釋放后,另一個程序4(假設內存需要1000M)裝到物理內存中可能還剩余 24M 內存,如果系統(tǒng)中有大量的這種內存碎片,那整體的內存利用率就會很低。
于是,段頁式內存管理方式出現(xiàn)了,它將以上兩種存儲管理方法結合起來:即先把用戶程序分成若干個段,為每一個段分配一個段名,再把每個段分成若干個頁。
在段頁式系統(tǒng)中,為了實現(xiàn)從邏輯地址到物理地址的轉換,系統(tǒng)中需要同時配置段表和頁表,利用段表和頁表進行從用戶地址到物理內存空間的映射。
系統(tǒng)為每個進程創(chuàng)建一張段表,每個分段上有一個頁表。段表包括段號、頁表長度和頁表始址,頁表包含頁號和塊號。
圖片
在地址轉換時,首先通過段表查到頁表地址,再通過頁表獲取頁幀號,最終形成物理地址。
虛擬內存到物理內存的映射,是操作系統(tǒng)層面去管理的。而我們在開發(fā)時,涉及到的內存管理,往往只是軟件程序去調用虛擬內存時要做的工作:
圖片
接下來,我們從虛擬內存的構成來分析下軟件開發(fā)中的內存管理。
3. 內存管理
程序在虛擬內存上被分為棧區(qū)、堆區(qū)、數(shù)據(jù)區(qū)、全局數(shù)據(jù)區(qū)、代碼段五個部分。
而內存的管理,就是對內存空間進行合理化使用,主要是堆區(qū)(Heap)和棧區(qū)(Stack)這兩個重要區(qū)域的分配使用。
3.1 堆與棧
虛擬內存里有兩塊比較重要的地址空間,分別為堆和??臻g。對于 C++ 等底層的編程語言,棧上的內存空間由編譯器統(tǒng)一管理,而堆上的內存空間需要程序員來手動管理進行分配和回收。
在 Go 語言中,棧上的內存空間也是由編譯器來統(tǒng)一管理,而堆上的內存空間由編譯器和垃圾收集器共同管理進行分配和回收,這給我們程序員帶來了極大的便利性。
在棧上分配和回收內存的開銷很低,只需要 2 個指令:PUSH 和 POP。PUSH 將數(shù)據(jù)壓入棧中,POP 釋放空間,消耗的僅是將數(shù)據(jù)拷貝到內存的時間。
而在堆上分配內存時,不僅分配的時候慢,而且垃圾回收的時候也比較費勁,比如說 Go 在 1.8 以后就用到了三色標記法+混合寫屏障的技術來做垃圾回收??傮w來看,堆內存分配比棧內存分配導致的開銷要大很多。
3.2 堆棧內存分配
1)內存分配的挑戰(zhàn)
- 像 C/C++ 這類由用戶程序申請內存空間,可能會頻繁地進行內存申請和回收,但每次內存分配時都需要進行系統(tǒng)調用(即只有進入內核態(tài)才可以申請內存),就會導致系統(tǒng)的性能很低。
- 除此之外,還可能會有多線程(Go語言里面也有協(xié)程)去訪問同一個地址空間的情況,這時就必定需要對內存進行加鎖,帶來的開銷也會比較大。
- 初始化時堆內存是一整塊連續(xù)的內存,但隨著系統(tǒng)運行過程中不斷申請回收內存,可能會產生許多的內存碎片,導致內存的使用效率降低。
程序進行內存分配時,為了應對以上最常見的三種問題,Go 語言結合谷歌的 TCMalloc(ThreadCacheMalloc) 內存回收方法,做了一些改進。
同時,TCMalloc 和 Go 進行內存分配時都會引入線程緩存(mcentral of P)、中心緩存(mcentral)和頁堆(mheap)三個組件進行分級管理內存。如圖所示:
圖片
線程緩存屬于每一個獨立的線程或協(xié)程,里面存儲了每個線程所用的內存塊 span,由于內存塊的大小不一,所以有上百個內存塊類別 span class,這些內存塊里面分別管理不同大小的內存空間(比如 8KB、16KB、32KB...)。由于不涉及多線程,所以不需要使用互斥鎖來保護內存,以減少鎖競爭帶來的性能損耗。
當線程緩存的空間不夠時,會使用中心緩存作為小對象內存的分配,中心緩存和線程緩存的每個 span class 一一對應,并且中心緩存的每個 span class 中有兩個內存塊,分別存儲了分配過內存的空間和滿內存空間,以提升內存分配的效率。如果中心緩存還不滿足,就向頁堆進行空間申請。
為了提升空間的利用率,當遇到中大對象(>=32KB)分配時,內存分配器會選擇頁堆直接進行分配。
Go 語言內存分配的核心是使用多級緩存將對象根據(jù)大小分類,并按照類別來實施不同的分配策略。如上圖所示,應用程序在申請內存時會根據(jù)對象的大?。═iny 小對象或者 Large and medium 中大對象),向不同的組件去申請內存空間。
2)棧內存分配
棧區(qū)的內存一般由編譯器自動分配和釋放,一般來說,棧區(qū)存儲著函數(shù)入參以及局部變量,這些數(shù)據(jù)會隨著函數(shù)的創(chuàng)建而創(chuàng)建,函數(shù)的返回而消亡,一般不會在程序中長期存在。
這種線性的內存分配策略有著極高地效率,但是工程師也往往不能控制棧內存的分配,這部分工作基本都是由編譯器完成的。
??臻g在運行時中包含兩個重要的全局變量,分別是 runtime.stackpool 和 runtime.stackLarge,這兩個變量分別表示全局的棧緩存和大棧緩存,前者可以分配小于 32KB 的內存,后者用來分配大于 32KB 的??臻g:
圖片
棧分配時,根據(jù)線程緩存和申請棧的大小,Go 語言會通過三種不同的方法分配棧空間:
- 如果??臻g較小,使用全局棧緩存或者線程緩存上固定大小的空閑鏈表分配內存;
- 如果棧空間較大,從全局的大棧緩存 runtime.stackLarge 中獲取內存空間;
- 如果??臻g較大并且 runtime.stackLarge 空間不足,在堆上申請一片大小足夠內存空間。
在 Go1.4 以后,最小的棧內存大小為 2KB,即一個 goroutine 協(xié)程的大小。所以,當程序里的協(xié)程數(shù)量超過棧內存可分配的最大值后,就會分配在堆空間里面。也就是說,雖然 Go 語言里面可以用 go 關鍵字分配不限數(shù)量的 goroutine 協(xié)程,但是在性能上,我們分配的 goroutine 個數(shù)最好不要超過棧空間的最大值。
假設,棧內存的最大值為 8MB,那分配的 goroutine 數(shù)量最好不要超過 4000 個(8MB/2KB)。
4. 逃逸分析
4.1 Go如何做逃逸分析
在 C 語言和 C++ 這類需要手動管理內存的編程語言中,將對象或者結構體分配到棧上或者堆上是由工程師來決定的,這也為工程師的工作帶來的挑戰(zhàn):如何精準地為每一個變量分配合理的空間,提升整個程序的運行效率和內存使用效率。但是 C 和 C++ 的這種手動分配內存會導致如下的兩個問題:
- 不需要分配到堆上的對象分配到了堆上 — 浪費內存空間;
- 需要分配到堆上的對象分配到了棧上 — 產生野指針、影響內存安全;
與野指針相比,浪費內存空間反而是小問題。在 C 語言中,棧上的變量被函數(shù)作為返回值返回給調用方是一個常見的錯誤,在如下所示的代碼中,棧上的變量 i 被錯誤返回:
int *dangling_pointer() {
int i = 2;
return &i;
}
當 dangling_pointer 函數(shù)返回后,它的本地變量會被編譯器回收(棧上空間的機制),調用方獲取的是危險的野指針。如果程序里面出現(xiàn)大量不合法的指針值,在大型項目中是比較難以發(fā)現(xiàn)和定位的。
當所指向的對象被釋放或者收回,但是對該指針沒有作任何的修改,以至于該指針仍舊指向已經回收的內存地址,此情況下該指針便稱野指針,或稱懸空指針、迷途指針。——wiki百科
那么,在 Go 語言里面,編譯器該如何知道某個變量需要分配在堆,還是棧上而避免出現(xiàn)這種問題呢?
編譯器決定內存分配位置的方式,就稱之為逃逸分析。逃逸分析由編譯器完成,作用于編譯階段。在編譯器優(yōu)化中,逃逸分析是用來決定指針動態(tài)作用域的方法。Go 語言的編譯器使用逃逸分析決定哪些變量應該在棧上分配,哪些變量應該在堆上分配。
其中包括使用 new、make 和字面量等方法隱式分配的內存,Go 語言的逃逸分析遵循以下兩個不變性:
- 指向棧對象的指針不能存在于堆中;
- 指向棧對象的指針不能在棧對象回收后存活。
什么意思呢?我們來翻譯一下:
- 首先,如果堆的指針指向了棧對象,那么棧對象的內存就需要分配到堆上;
- 如果棧對象回收后,指針還存活,那么這個對象就只能分配到堆上。
我們在進行內存分配時,編譯器會遵循上述兩個原則,對我們申請的變量或對象進行內存分配到棧上或者是堆上。
換言之,當我們分配內存時,違反了上述兩個原則之一,本來想分配到棧上的變量可能就會“逃逸”到堆上,被稱作內存逃逸。如果程序中出現(xiàn)大量的內存逃逸,勢必會帶來意外的負面影響:比如垃圾回收緩慢,內存溢出等問題。
4.2 四種逃逸場景
Go 語言中,由于以下四種情況,棧上的內存可能會發(fā)生逃逸。
1. 指針逃逸
指針逃逸很容易理解,我們在函數(shù)中創(chuàng)建一個對象時,對象的生命周期隨著函數(shù)結束而結束,這時候對象的內存就分配在棧上。
而如果返回了一個對象的指針,這種情況下,函數(shù)雖然退出了,但指針還在,對象的內存不能隨著函數(shù)結束而回收,因此只能分配在堆上。
package main
type User struct {
ID int64
Name string
Avatar string
}
// 要想不發(fā)生逃逸,返回 User 對象即可。
func GetUserInfo() *User {
return &User{
ID: 666666,
Name: "sim lou",
Avatar: "https://www.baidu.com/avatar/666666",
}
}
func main() {
u := GetUserInfo()
println(u.Name)
}
上面例子中,如果返回的是 User 對象,而非對象指針 *User,那么它就是一個局部變量,會分配在棧上;反之,指針作為引用,在 main 函數(shù)中還會繼續(xù)使用,因此內存只能分配到堆上。
我們可以用編譯器命令 go build -gcflags -m main.go 來查看變量逃逸的情況:
圖片
&User{...} escapes to heap 即表示對象逃逸到堆上了。
2. interface{} 動態(tài)類型逃逸
在 Go 語言中,空接口即 interface{} 可以表示任意的類型,如果函數(shù)參數(shù)為 interface{},編譯期間很難確定其參數(shù)的具體類型,也會發(fā)生逃逸。比如 Println 函數(shù),入參是一個 interface{} 空類型:
func Println(a ...interface{}) (n int, err error)
這時,返回的是一個 User 對象,也會發(fā)生對象逃逸,但逃逸節(jié)點是 fmt.Println 函數(shù)使用時:
func GetUserInfo() User {
return User{
ID: 666666,
Name: "sim lou",
Avatar: "https://www.baidu.com/avatar/666666",
}
}
func main() {
u := GetUserInfo()
fmt.Println(u.Name) // 對象發(fā)生逃逸
}
3. ??臻g不足
操作系統(tǒng)對內核線程使用的??臻g是有大小限制的,64 位 Linux 系統(tǒng)上通常是 8 MB。可以使用 ulimit -a 命令查看機器上棧允許占用的內存的大小。
root@cvm_172_16_10_34:~ # ulimit -a
-s: stack size (kbytes) 8192
-u: processes 655360
-n: file descriptors 655360
因為??臻g通常比較小,因此遞歸函數(shù)實現(xiàn)不當時,容易導致棧溢出。
對于 Go 語言來說,運行時(runtime) 嘗試在 goroutine 需要的時候動態(tài)地分配??臻g,goroutine 的初始棧大小為 2 KB。當 goroutine 被調度時,會綁定內核線程執(zhí)行,??臻g大小也不會超過操作系統(tǒng)的限制。
對 Go 編譯器而言,超過一定大小的局部變量將逃逸到堆上,不同的 Go 版本的大小限制可能不一樣。我們來做一個實驗(注意,分配 int[] 時,int 占 8 字節(jié),所以 8192 個 int 就是 64 KB):
package main
import "math/rand"
func generate8191() {
nums := make([]int, 8192) // <= 64KB
for i := 0; i < 8192; i++ {
nums[i] = rand.Int()
}
}
func generate8192() {
nums := make([]int, 8193) // > 64KB
for i := 0; i < 8193; i++ {
nums[i] = rand.Int()
}
}
func generate(n int) {
nums := make([]int, n) // 不確定大小
for i := 0; i < n; i++ {
nums[i] = rand.Int()
}
}
func main() {
generate8191()
generate8192()
generate(1)
}
編譯結果如下:
圖片
可以發(fā)現(xiàn),make([]int, 8192) 沒有發(fā)生逃逸,make([]int, 8193) 和 make([]int, n) 逃逸到堆上。也就是說,當切片占用內存超過一定大小,或無法確定當前切片長度時,對象占用內存將在堆上分配。
4. 閉包
一個函數(shù)和對其周圍狀態(tài)(lexical environment,詞法環(huán)境)的引用捆綁在一起(或者說函數(shù)被引用包圍),這樣的組合就是閉包(closure)。也就是說,閉包讓你可以在一個內層函數(shù)中訪問到其外層函數(shù)的作用域。
— 閉包
Go 語言中,當使用閉包函數(shù)時,也會發(fā)生內存逃逸??匆粍t示例代碼:
package main
func Increase() func() int {
n := 0
return func() int {
n++
return n
}
}
func main() {
in := Increase()
fmt.Println(in()) // 1
fmt.Println(in()) // 2
}
Increase() 函數(shù)的返回值是一個閉包函數(shù),該閉包函數(shù)訪問了外部變量 n,那變量 n 將會一直存在,直到 in 被銷毀。很顯然,變量 n 占用的內存不能隨著函數(shù) Increase() 的退出而回收,因此將會逃逸到堆上。
4.3 利用逃逸分析提升性能
傳值VS傳指針
傳值會拷貝整個對象,而傳指針只會拷貝指針地址,指向的對象是同一個。傳指針可以減少值的拷貝,但是會導致內存分配逃逸到堆中,增加垃圾回收(GC)的負擔。在對象頻繁創(chuàng)建和刪除的場景下,傳遞指針導致的 GC 開銷可能會嚴重影響性能。
一般情況下,對于需要修改原對象值,或占用內存比較大的結構體,選擇傳指針。對于只讀的占用內存較小的結構體,直接傳值能夠獲得更好的性能。
5. 小結
內存分配是程序運行時內存管理的核心邏輯,Go 程序運行時的內存分配器使用類似 TCMalloc 的分配策略將對象根據(jù)大小分類,并設計多層緩存的組件提高內存分配器的性能。
理解 Go 語言內存分配器的設計與實現(xiàn)原理,可以幫助我們理解不同編程語言在設計內存分配器時做出的不同選擇。
棧內存是應用程序中重要的內存空間,它能夠支持本地的局部變量和函數(shù)調用,棧空間中的變量會與棧一同創(chuàng)建和銷毀,這部分內存空間不需要工程師過多的干預和管理,現(xiàn)代的編程語言通過逃逸分析減少了我們的工作量,理解棧空間的分配對于理解 Go 語言的運行時有很大的幫助。