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