詳解.NET內(nèi)存管理機(jī)制與垃圾回收
探討.NET內(nèi)存管理機(jī)制與垃圾回收,也是對(duì).NET平臺(tái)編程效率的一種提高。了解.NET內(nèi)存管理機(jī)制對(duì)今后對(duì)內(nèi)存的操作,具有十分重要的意義。
1. Stack和Heap
每個(gè)線(xiàn)程對(duì)應(yīng)一個(gè)stack,線(xiàn)程創(chuàng)建的時(shí)候CLR為其創(chuàng)建這個(gè)stack,stack主要作用是記錄函數(shù)的執(zhí)行情況。值類(lèi)型變量(函數(shù)的參數(shù)、局部變量等非成員變量)都分配在stack中,引用類(lèi)型的對(duì)象分配在heap中,在stack中保存heap對(duì)象的引用指針。GC只負(fù)責(zé)heap對(duì)象的釋放,heap內(nèi)存空間管理
Heap內(nèi)存分配
除去pinned object等影響,heap中的內(nèi)存分配很簡(jiǎn)單,一個(gè)指針記錄heap中分配的起始地址,根據(jù)對(duì)象大小連續(xù)的分配內(nèi)存
Stack結(jié)構(gòu)
每個(gè)函數(shù)調(diào)用時(shí),邏輯上在thread stack中會(huì)產(chǎn)生一個(gè)幀(stack frame),函數(shù)返回時(shí)對(duì)應(yīng)的stack frame被釋放掉
用個(gè)簡(jiǎn)單的函數(shù)查看執(zhí)行時(shí)CLR對(duì)棧的處理情況:
- static void Main(string[] args)
- {
- int r = Sum(2, 3, 4, 5, 6);
- }
- private static int Sum(int a, int b, int c, int d, int e)
- {
- return a + b + c + d + e;
- }
JIT編譯后主要匯編代碼如下(其他的情況下匯編代碼可能有所差別,但用這個(gè)簡(jiǎn)單函數(shù)大致看下棧的管理已經(jīng)足夠):
- ;====函數(shù)Main====
- push4 ;第3個(gè)參數(shù)到最后一個(gè)參數(shù)壓棧
- push5
- push6
- movedx,3 ;第1、第2個(gè)參數(shù)分別放入ecx、edx寄存器
- movecx,2
- calldword ptr ds:[00AD96B8h];調(diào)用函數(shù)Sum,執(zhí)行call的時(shí)候返回地址(即下面這條mov語(yǔ)句的地址)自動(dòng)壓棧了
- movdword ptr [ebp-0Ch],eax ;將函數(shù)返回值設(shè)置到局部變量r中(函數(shù)調(diào)用結(jié)束返回值在eax寄存器中)
- ;====函數(shù)Sum====
- pushebp ;保存原始ebp寄存器
- movebp,esp ;將當(dāng)前棧指針保存在ebp中,后面使用ebp對(duì)參數(shù)和局部變量尋址
- subesp,8 ;分配兩個(gè)局部變量
- movdword ptr [ebp-4],ecx ;第1個(gè)參數(shù)放入局部變量
- movdword ptr [ebp-8],edx ;第2個(gè)參數(shù)放入局部變量
- ...... ;CLR的檢查代碼
- moveax,dword ptr [ebp-4];a + b + c + d + e
- addeax,dword ptr [ebp-8];第1個(gè)參數(shù)+第2個(gè)參數(shù)(2+3)
- addeax,dword ptr [ebp+10h];+第3個(gè)參數(shù)(4)
- addeax,dword ptr [ebp+0Ch];+第4個(gè)參數(shù)(5)
- addeax,dword ptr [ebp+8];+第5個(gè)參數(shù)(6)
- movesp,ebp;恢復(fù)棧指針(局部變量被釋放了)
- popebp;恢復(fù)原始的ebp寄存器值
- ret0Ch ;函數(shù)返回. 1: 返回地址自動(dòng)出棧; 2: esp減去0Ch(12個(gè)字節(jié)),即從棧中清除調(diào)用參數(shù); 3: 返回值在eax寄存器中執(zhí)行時(shí)刻的stack狀態(tài)如下(?;刂窞楦叨说刂罚瑮m敒榈投说刂罚?nbsp;
Stack狀態(tài)變化過(guò)程:
a). 調(diào)用者將第3、第4、第5個(gè)參數(shù)壓棧,第1、第2個(gè)參數(shù)分別放入ecx、edx寄存器
b). call指令調(diào)用函數(shù)Sum,并自動(dòng)將函數(shù)返回地址壓棧,代碼跳轉(zhuǎn)到函數(shù)Sum開(kāi)始執(zhí)行
c). 函數(shù)Sum先將寄存器ebp壓棧保存,并將esp放入ebp,用于后面對(duì)參數(shù)和局部變量尋址
d). 定義局部變量以及省略掉的是額外代碼,跟Sum函數(shù)業(yè)務(wù)無(wú)關(guān)
e). 執(zhí)行加法操作,結(jié)果保存在eax寄存器中
f). 恢復(fù)esp寄存器,這樣函數(shù)Sum中所有的局部變量以及其他壓棧操作全部釋放出來(lái)
g). 原始ebp的值出棧,恢復(fù)ebp,這樣棧完全恢復(fù)到進(jìn)入Sum函數(shù)調(diào)用時(shí)的狀態(tài)
h). ret指令執(zhí)行函數(shù)返回,返回值在eax寄存器中,返回地址為call指令壓棧的地址,返回地址自動(dòng)出棧。0Ch指示處理器在函數(shù)返回時(shí)釋放棧中12個(gè)字節(jié),即由被調(diào)用者清除壓棧的參數(shù)。函數(shù)返回之后,本次Sum調(diào)用的棧分配全部釋放
這種調(diào)用約定類(lèi)似__fastcall
結(jié)合引用類(lèi)型變量、值類(lèi)型的ref參數(shù),下面代碼簡(jiǎn)化的stack狀態(tài)如下:
代碼:
- public static void Run(int i)
- {
- int j = 9;
- MyClass1 c = new MyClass1();
- c.x = 8;
- int result = Sum(i, 5, ref j, c);
- }
- public static int Sum(int a, int b, ref int c, MyClass1 obj)
- {
- int r = a + b + c + obj.x;
- return r;
- }
- public class MyClass1
- {
- public int x;
- }
Stack狀態(tài):
任何時(shí)候引用類(lèi)型都分配在heap中,在stack中只是保存對(duì)象的引用地址。Run函數(shù)執(zhí)行完畢之后,heap中的MyClass1對(duì)象c成為可回收的垃圾對(duì)象,在GC時(shí)進(jìn)行回收
#p#
2. Mark-Compact 標(biāo)記壓縮算法
簡(jiǎn)單把.NET的GC算法看作Mark-Compact算法
階段1: Mark-Sweep 標(biāo)記清除階段
先假設(shè)heap中所有對(duì)象都可以回收,然后找出不能回收的對(duì)象,給這些對(duì)象打上標(biāo)記,最后heap中沒(méi)有打標(biāo)記的對(duì)象都是可以被回收的
階段2: Compact 壓縮階段
對(duì)象回收之后heap內(nèi)存空間變得不連續(xù),在heap中移動(dòng)這些對(duì)象,使他們重新從heap基地址開(kāi)始連續(xù)排列,類(lèi)似于磁盤(pán)空間的碎片整理。
Heap內(nèi)存經(jīng)過(guò)回收、壓縮之后,可以繼續(xù)采用前面的heap內(nèi)存分配方法,即僅用一個(gè)指針記錄heap分配的起始地址就可以
主要處理步驟:將線(xiàn)程掛起=>確定roots=>創(chuàng)建reachable objects graph=>對(duì)象回收=>heap壓縮=>指針修復(fù)
可以這樣理解roots:heap中對(duì)象的引用關(guān)系錯(cuò)綜復(fù)雜(交叉引用、循環(huán)引用),形成復(fù)雜的graph,roots是CLR在heap之外可以找到的各種入口點(diǎn)。GC搜索roots的地方包括全局對(duì)象、靜態(tài)變量、局部對(duì)象、函數(shù)調(diào)用參數(shù)、當(dāng)前CPU寄存器中的對(duì)象指針(還有finalization queue)等。主要可以歸為2種類(lèi)型:已經(jīng)初始化了的靜態(tài)變量、線(xiàn)程仍在使用的對(duì)象(stack+CPU register)
Reachable objects:指根據(jù)對(duì)象引用關(guān)系,從roots出發(fā)可以到達(dá)的對(duì)象。例如當(dāng)前執(zhí)行函數(shù)的局部變量對(duì)象A是一個(gè)root object,他的成員變量引用了對(duì)象B,則B是一個(gè)reachable object。從roots出發(fā)可以創(chuàng)建reachable objects graph,剩余對(duì)象即為unreachable,可以被回收。
指針修復(fù)是因?yàn)閏ompact過(guò)程移動(dòng)了heap對(duì)象,對(duì)象地址發(fā)生變化,需要修復(fù)所有引用指針,包括stack、CPU register中的指針以及heap中其他對(duì)象的引用指針。
Debug和release執(zhí)行模式之間稍有區(qū)別,release模式下后續(xù)代碼沒(méi)有引用的對(duì)象是unreachable的,而debug模式下需要等到當(dāng)前函數(shù)執(zhí)行完畢,這些對(duì)象才會(huì)成為unreachable,目的是為了調(diào)試時(shí)跟蹤局部對(duì)象的內(nèi)容
傳給了COM+的托管對(duì)象也會(huì)成為root,并且具有一個(gè)引用計(jì)數(shù)器以兼容COM+的內(nèi)存管理機(jī)制,引用計(jì)數(shù)器為0時(shí)這些對(duì)象才可能成為被回收對(duì)象。
Pinned objects指分配之后不能移動(dòng)位置的對(duì)象,例如傳遞給非托管代碼的對(duì)象(或者使用了fixed關(guān)鍵字),GC在指針修復(fù)時(shí)無(wú)法修改非托管代碼中的引用指針,因此將這些對(duì)象移動(dòng)將發(fā)生異常。pinned objects會(huì)導(dǎo)致heap出現(xiàn)碎片,但大部分情況來(lái)說(shuō)傳給非托管代碼的對(duì)象應(yīng)當(dāng)在GC時(shí)能夠被回收掉
3. Generational 分代算法
程序可能使用幾百M(fèi)、幾G的內(nèi)存,對(duì)這樣的內(nèi)存區(qū)域進(jìn)行GC操作成本很高,分代算法具備一定統(tǒng)計(jì)學(xué)基礎(chǔ),對(duì)GC的性能改善效果比較明顯將對(duì)象按照生命周期分成新的、老的,根據(jù)統(tǒng)計(jì)分布規(guī)律所反映的結(jié)果,可以對(duì)新、老區(qū)域采用不同的回收策略和算法,加強(qiáng)對(duì)新區(qū)域的回收處理力度,爭(zhēng)取在較短時(shí)間間隔、較小的內(nèi)存區(qū)域內(nèi),以較低成本將執(zhí)行路徑上大量新近拋棄不再使用的局部對(duì)象及時(shí)回收掉分代算法的假設(shè)前提條件:
a). 大量新創(chuàng)建的對(duì)象生命周期都比較短,而較老的對(duì)象生命周期會(huì)更長(zhǎng)
b). 對(duì)部分內(nèi)存進(jìn)行回收比基于全部?jī)?nèi)存的回收操作要快
c). 新創(chuàng)建的對(duì)象之間關(guān)聯(lián)程度通常較強(qiáng)。heap分配的對(duì)象是連續(xù)的,關(guān)聯(lián)度較強(qiáng)有利于提高CPU cache的命中率
.NET將heap分成3個(gè)代齡區(qū)域: Gen 0、Gen 1、Gen 2
Heap分為3個(gè)代齡區(qū)域,相應(yīng)的GC有3種方式: # Gen 0 collections, # Gen 1 collections, # Gen 2 collections。如果Gen 0 heap內(nèi)存達(dá)到閥值,則觸發(fā)0代GC,0代GC后Gen 0中幸存的對(duì)象進(jìn)入Gen 1。如果Gen 1的內(nèi)存達(dá)到閥值,則進(jìn)行1代GC,1代GC將Gen 0 heap和Gen 1 heap一起進(jìn)行回收,幸存的對(duì)象進(jìn)入Gen 2。2代GC將Gen 0 heap、Gen 1 heap和Gen 2 heap一起回收
Gen 0和Gen 1比較小,這兩個(gè)代齡加起來(lái)總是保持在16M左右;Gen 2的大小由應(yīng)用程序確定,可能達(dá)到幾G,因此0代和1代GC的成本非常低,2代GC稱(chēng)為full GC,通常成本很高。粗略的計(jì)算0代和1代GC應(yīng)當(dāng)能在幾毫秒到幾十毫秒之間完成,Gen 2 heap比較大時(shí)full GC可能需要花費(fèi)幾秒時(shí)間。大致上來(lái)講.NET應(yīng)用運(yùn)行期間2代、1代和0代GC的頻率應(yīng)當(dāng)大致為1:10:100。
圖為一個(gè)ASP.NET程序運(yùn)行的Performance Moniter,Gen 0 heap size(紅色)平均6M,Gen 1(藍(lán)色)平均5M,Gen 2(黃色)達(dá)到620M,Gen 0+Gen 1平均13.2M,最大19.8M
直觀(guān)上來(lái)看,程序的運(yùn)行由一系列函數(shù)調(diào)用組成,函數(shù)運(yùn)行期間會(huì)創(chuàng)建很多局部對(duì)象,函數(shù)結(jié)束之后也就產(chǎn)生大量待回收的對(duì)象。采用分代算法加強(qiáng)較新代齡的垃圾回收力度,通常能夠極大的提高垃圾回收效率,否則就是極特殊的程序,或者是不合理的對(duì)象關(guān)聯(lián)設(shè)計(jì)。例如ASP.NET程序,應(yīng)當(dāng)確保絕大部分用于HTTP 請(qǐng)求處理的對(duì)象在0代和1代垃圾回收中被釋放掉
為heap記錄幾個(gè)指針可以確定代齡區(qū)域范圍,創(chuàng)建reachable objects graph時(shí)根據(jù)對(duì)象的地址可以確定對(duì)象位于哪個(gè)代齡區(qū)域,0代GC在創(chuàng)建graph時(shí)如果遇到1代、2代heap對(duì)象,可以直接越過(guò)不用繼續(xù)遍歷下去,較老代齡的對(duì)象如果引用了較新代齡的對(duì)象,可以通過(guò)Win32 API GetWriteWatch訂閱內(nèi)存更新通知,記錄在"card table"中,輔助較低代齡的GC正確構(gòu)造graph
4. LOH
.NET 1.1和2.0中,85000字節(jié)以下的對(duì)象稱(chēng)為小對(duì)象,分配在Gen 0 heap中,85000字節(jié)以上的對(duì)象稱(chēng)為大對(duì)象,分配在Large Object Heap中,這是因?yàn)镚C在heap壓縮時(shí)移動(dòng)大的內(nèi)存塊需要消耗大量CPU時(shí)間,通過(guò)性能調(diào)優(yōu)實(shí)踐確定了85000字節(jié)這樣一個(gè)閥值。
LOH只在2代GC時(shí)進(jìn)行回收,采用Mark-Sweep算法,沒(méi)有壓縮處理,因此LOH中的內(nèi)存分配是不連續(xù)的,使用一個(gè)空閑列表free list記錄LOH中的空閑空間,對(duì)釋放出來(lái)的空間進(jìn)行管理。
上圖中obj1、obj2釋放之后,其空間合并起來(lái)成為free list的一個(gè)節(jié)點(diǎn),隨后被分配給obj4
什么時(shí)候觸發(fā)垃圾回收?
前面已經(jīng)提到,0代和1代垃圾回收主要由閥值控制。初始時(shí)Gen 0 heap大小與CPU緩存的大小相關(guān),運(yùn)行時(shí)CLR根據(jù)內(nèi)存請(qǐng)求狀態(tài)動(dòng)態(tài)調(diào)整Gen 0 heap大小,但Gen 0和Gen 1總大小保持在16M左右
Gen 2 heap和LOH都在full GC時(shí)進(jìn)行回收,full GC主要由2類(lèi)事件觸發(fā):
a). 進(jìn)入Gen 2 heap和LOH的對(duì)象很多,超過(guò)了一定比例。RegisterForFullGCNotification的參數(shù) maxGenerationThreshold、largeObjectHeapThreshold可以分別為Gen 2 heap和LOH設(shè)定這個(gè)值
b). 操作系統(tǒng)內(nèi)存吃緊的時(shí)候。CLR會(huì)接收到操作系統(tǒng)內(nèi)存緊張的通知消息,觸發(fā)full GC
5. Heap細(xì)節(jié)、擴(kuò)容與收縮
Heap的代齡是邏輯上的結(jié)構(gòu),heap實(shí)際內(nèi)存申請(qǐng)和分配以及釋放以segment(段)為單位,workstation GC模式segment大小為16M,server GC模式segment大小為64M。Gen 0和Gen 1 heap總是位于同一個(gè)段中,叫做ephemeral segment(新生段),因此max(Gen 0 heap size+Gen 1 heap size)≈16M || 64M,Gen 2 heap由0個(gè)或多個(gè)segments組成,LOH由1個(gè)或多個(gè)segments組成。
.NET程序啟動(dòng)時(shí)CLR為heap創(chuàng)建2個(gè)segment,一個(gè)作為ephemeral segment,另一個(gè)用于LOH。.NET使用VirtualAlloc申請(qǐng)和分配heap內(nèi)存,在LOH中分配新對(duì)象時(shí)沒(méi)有足夠的空間,或者1代GC 時(shí)進(jìn)入Gen 2的對(duì)象過(guò)多空間不夠,.NET將為L(zhǎng)OH或者小對(duì)象heap分配新的segment。申請(qǐng)新的segment失敗將由EE拋出OutOfMemory異常
Full GC后完全空閑的segments將被釋放掉,內(nèi)存返回給操作系統(tǒng)。
.NET 2.0對(duì)GC的一個(gè)重要改進(jìn)是盡量改善heap碎片處理。heap碎片主要由pinned objects引起,改善措施主要有2個(gè)方面。首先是延遲升級(jí),如果ephemeral segment存在pinned objects,則盡可能的延遲他們升級(jí)到Gen 2的時(shí)間點(diǎn),考慮pinned objects的同時(shí)盡量充分利用當(dāng)前ephemeral segment的空間;其次是重復(fù)利用Gen 2的空間,如果Gen 2中存在pinned objects的segments釋放出了足夠空間,該segments可能重新作為ephemeral segment使用
6. GC方式
有Workstation GC with Concurrent GC off、 Workstation GC with Concurrent GC on、Server GC 3種
Workstation GC with Concurrent GC off: 用于單CPU機(jī)器實(shí)現(xiàn)高吞吐量,采用一系列策略觀(guān)察內(nèi)存分配以及每次GC的狀況,動(dòng)態(tài)調(diào)整GC策略,盡可能使程序隨著運(yùn)行時(shí)狀態(tài)的變化實(shí)現(xiàn)高效的GC操作,但進(jìn)行GC時(shí)會(huì)凍結(jié)所有線(xiàn)程
Workstation GC with Concurrent GC on: 用于響應(yīng)時(shí)間非常重要的交互式程序,例如流媒體的播放等(如果一次full GC導(dǎo)致應(yīng)用程序中斷幾秒、十幾秒時(shí)間,用戶(hù)將無(wú)法忍受)。
這種方式利用多CPU對(duì)full GC進(jìn)行并行處理,不是整個(gè)full GC期間凍結(jié)所有線(xiàn)程,而是將full GC切分成多次很短的時(shí)間對(duì)線(xiàn)程進(jìn)行凍結(jié),在線(xiàn)程凍結(jié)時(shí)間之外,應(yīng)用程序仍然可以正常運(yùn)行,進(jìn)行內(nèi)存分配,這主要通過(guò)將Gen 0 heap size設(shè)置的比non-concurrent GC大很多而實(shí)現(xiàn),使得GC操作時(shí)線(xiàn)程仍然能夠在Gen 0 heap中進(jìn)行內(nèi)存分配,但如果Gen 0 heap用完后GC仍然沒(méi)有結(jié)束,線(xiàn)程仍然會(huì)出現(xiàn)阻塞。這種方式付出的代價(jià)是working set和GC所需時(shí)間比non-concurrent GC要大一些。
Server GC: 用于多CPU機(jī)器的服務(wù)器應(yīng)用程序?qū)崿F(xiàn)高吞吐量和伸縮性,充分利用服務(wù)器的大內(nèi)存。.NET為每個(gè)CPU創(chuàng)建一組heap(包括Gen 0, 1, 2和LOH)和一個(gè)GC線(xiàn)程,每個(gè)CPU可以獨(dú)立的為相應(yīng)的heap執(zhí)行GC操作,而其他CPU則正常執(zhí)行處理。最佳的應(yīng)用場(chǎng)景是多線(xiàn)程之間內(nèi)存結(jié)構(gòu)基本相同,執(zhí)行的工作相同或類(lèi)似
單CPU機(jī)器上只能使用workstation GC,默認(rèn)情況下為Workstation GC with Concurrent GC on方式,單CPU機(jī)器上配置為Server GC無(wú)效,仍然使用workstation GC;多CPU服務(wù)器上的ASP.NET默認(rèn)使用Server GC方式,Server GC時(shí)不能使用concurrent方式。
concurrent GC可以用于單CPU機(jī)器,它與CPU數(shù)量無(wú)關(guān)。
對(duì)于A(yíng)SP.NET程序應(yīng)當(dāng)盡量保證一個(gè)CPU僅對(duì)應(yīng)一個(gè)GC線(xiàn)程,防止同一個(gè)CPU上面多個(gè)GC線(xiàn)程之間的沖突造成性能問(wèn)題。如果使用了Web Garden則應(yīng)當(dāng)使用Workstation GC with Concurrent GC off。Web Garden為了提高吞吐量會(huì)導(dǎo)致多出幾倍的內(nèi)存使用,每個(gè)work process的內(nèi)存有很多重復(fù)部分,Web Garden的最佳應(yīng)用場(chǎng)景是多個(gè)進(jìn)程之間使用一個(gè)共享的resource pool,避免內(nèi)存的重復(fù)并盡可能的提高吞吐量。在這一點(diǎn)上Server GC應(yīng)當(dāng)與Web Garden類(lèi)似,但Web Garden在多個(gè)進(jìn)程中,而Server GC是在同一個(gè)進(jìn)程中通過(guò)多線(xiàn)程實(shí)現(xiàn),目前沒(méi)有發(fā)現(xiàn)Server GC方面深入一些的資料,很多東西只能根據(jù)現(xiàn)有資料做一些猜想為workstation GC禁用concurrent GC:
- <configuration>
- <runtime>
- <gcConcurrent enabled="false"/>
- </runtime>
- </configuration>
啟用Server GC:
- <configuration>
- <runtime>
- <gcServer enabled=“true"/>
- </runtime>
- </configuration>
詳解.NET內(nèi)存管理機(jī)制與垃圾回收就介紹到這里。
本文來(lái)自riccc的博客園文章《.NET內(nèi)存管理、垃圾回收》
【編輯推薦】