.NET記憶體管理、垃圾回收

☆綠茶☆發表於2017-03-09

1. Stack和Heap
    每個執行緒對應一個stack,執行緒建立的時候CLR為其建立這個stack,stack主要作用是記錄函式的執行情況。值型別變數(函式的引數、區域性變數 等非成員變數)都分配在stack中,引用型別的物件分配在heap中,在stack中儲存heap物件的引用指標。GC只負責heap物件的釋 放,heap記憶體空間管理

Heap記憶體分配
    
    除去pinned object等影響,heap中的記憶體分配很簡單,一個指標記錄heap中分配的起始地址,根據物件大小連續的分配記憶體

Stack結構
    每個函式呼叫時,邏輯上在thread stack中會產生一個幀(stack frame),函式返回時對應的stack frame被釋放掉
    用個簡單的函式檢視執行時CLR對棧的處理情況:

複製程式碼
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編譯後主要彙編程式碼如下(其他的情況下彙編程式碼可能有所差別,但用這個簡單函式大致看下棧的管理已經足夠):

複製程式碼
;====函式Main====
push    4         ;第3個引數到最後一個引數壓棧
push    5    
push    6    
mov    edx,3   ;第1、第2個引數分別放入ecx、edx暫存器
mov    ecx,2 
call    dword ptr ds:[00AD96B8h]  ;呼叫函式Sum,執行call的時候返回地址(即下面這條mov語句的地址)自動壓棧 了
mov    dword ptr [ebp-0Ch],eax   ;將函式返回值設定到區域性變數r中(函式呼叫結束返回值在eax暫存器中)

;====函式Sum====
push    ebp           ;儲存原始ebp暫存器
mov    ebp,esp     ;將當前棧指標儲存在ebp中,後面使用ebp對引數和區域性變數定址
sub    esp,8         ;分配兩個區域性變數
mov    dword ptr [ebp-4],ecx         ;第1個引數放入區域性變數
mov    dword ptr [ebp-8],edx         ;第2個引數放入區域性變數
......     ;CLR的檢查程式碼
mov    eax,dword ptr [ebp-4]          ;a + b + c + d + e
add    eax,dword ptr [ebp-8]          ;第1個引數+第2個引數(2+3)
add    eax,dword ptr [ebp+10h]      ;+第3個引數(4)
add    eax,dword ptr [ebp+0Ch]      ;+第4個引數(5)
add    eax,dword ptr [ebp+8]          ;+第5個引數(6)
mov    esp,ebp    ;恢復棧指標(區域性變數被釋放了)
pop    ebp          ;恢復原始的ebp暫存器值
ret    0Ch   ;函式返回. 1: 返回地址自動出 棧; 2: esp減去0Ch(12個位元組),即從棧中清除呼叫參 數; 3: 返回值在eax暫存器中
複製程式碼

    執行時刻的stack狀態如下(棧基地址為高階地址,棧頂為低端地址):
    
    Stack狀態變化過程:
    a). 呼叫者將第3、第4、第5個引數壓棧,第1、第2個引數分別放入ecx、edx暫存器
    b). call指令呼叫函式Sum,並自動將函式返回地址壓棧,程式碼跳轉到函式Sum開始執行
    c). 函式Sum先將暫存器ebp壓棧儲存,並將esp放入ebp,用於後面對引數和區域性變數定址
    d). 定義區域性變數以及省略掉的是額外程式碼,跟Sum函式業務無關
    e). 執行加法操作,結果儲存在eax暫存器中
    f). 恢復esp暫存器,這樣函式Sum中所有的區域性變數以及其他壓棧操作全部釋放出來
    g). 原始ebp的值出棧,恢復ebp,這樣棧完全恢復到進入Sum函式呼叫時的狀態
    h). ret指令執行函式返回,返回值在eax暫存器中,返回地址為call指令壓棧的地址,返回地址自動出棧。0Ch指示處理器在函式返回時釋放棧中12個字 節,即由被呼叫者清除壓棧的引數。函式返回之後,本次Sum呼叫的棧分配全部釋放
    這種呼叫約定類似__fastcall

    結合引用型別變數、值型別的ref引數,下面程式碼簡化的stack狀態如下:
    程式碼:

複製程式碼
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狀態:
    
    任何時候引用型別都分配在heap中,在stack中只是儲存物件的引用地址。Run函式執行完畢之後,heap中的MyClass1物件c成為可回收的垃圾物件,在GC時進行回收

2. Mark-Compact 標記壓縮演算法
    簡單把.NET的GC演算法看作Mark-Compact演算法
    階段1: Mark-Sweep 標記清除階段
    先假設heap中所有物件都可以回收,然後找出不能回收的物件,給這些物件打上標記,最後heap中沒有打標記的物件都是可以被回收的
    階段2: Compact 壓縮階段
    物件回收之後heap記憶體空間變得不連續,在heap中移動這些物件,使他們重新從heap基地址開始連續排列,類似於磁碟空間的碎片整理
    
    Heap記憶體經過回收、壓縮之後,可以繼續採用前面的heap記憶體分配方法,即僅用一個指標記錄heap分配的起始地址就可以

    主要處理步驟:將執行緒掛起=>確定roots=>建立reachable objects graph=>物件回收=>heap壓縮=>指標修復
    可以這樣理解roots:heap中物件的引用關係錯綜複雜(交叉引用、迴圈引用),形成複雜的graph,roots是CLR在heap之外可以找到的 各種入口點。GC搜尋roots的地方包括全域性物件、靜態變數、區域性物件、函式呼叫引數、當前CPU暫存器中的物件指標(還有finalization queue)等。主要可以歸為2種型別:已經初始化了的靜態變數、執行緒仍在使用的物件(stack+CPU register)
    Reachable objects:指根據物件引用關係,從roots出發可以到達的物件。例如當前執行函式的區域性變數物件A是一個root object,他的成員變數引用了物件B,則B是一個reachable object。從roots出發可以建立reachable objects graph,剩餘物件即為unreachable,可以被回收
    
    指標修復是因為compact過程移動了heap物件,物件地址發生變化,需要修復所有引用指標,包括stack、CPU register中的指標以及heap中其他物件的引用指標
    Debug和release執行模式之間稍有區別,release模式下後續程式碼沒有引用的物件是unreachable的,而debug模式下需要等到 當前函式執行完畢,這些物件才會成為unreachable,目的是為了除錯時跟蹤區域性物件的內容
    傳給了COM+的託管物件也會成為root,並且具有一個引用計數器以相容COM+的記憶體管理機制,引用計數器為0時這些物件才可能成為被回收物件
    Pinned objects指分配之後不能移動位置的物件,例如傳遞給非託管程式碼的物件(或者使用了fixed關鍵字),GC在指標修復時無法修改非託管程式碼中的引用 指標,因此將這些物件移動將發生異常。pinned objects會導致heap出現碎片,但大部分情況來說傳給非託管程式碼的物件應當在GC時能夠被回收掉

3. Generational 分代演算法
    程式可能使用幾百M、幾G的記憶體,對這樣的記憶體區域進行GC操作成本很高,分代演算法具備一定統計學基礎,對GC的效能改善效果比較明顯
    將物件按照生命週期分成新的、老的,根據統計分佈規律所反映的結果,可以對新、老區域採用不同的回收策略和演算法,加強對新區域的回收處理力度,爭取在較短 時間間隔、較小的記憶體區域內,以較低成本將執行路徑上大量新近拋棄不再使用的區域性物件及時回收掉
    分代演算法的假設前提條件:
    a). 大量新建立的物件生命週期都比較短,而較老的物件生命週期會更長
    b). 對部分記憶體進行回收比基於全部記憶體的回收操作要快
    c). 新建立的物件之間關聯程度通常較強。heap分配的物件是連續的,關聯度較強有利於提高CPU cache的命中率

    .NET將heap分成3個代齡區域: Gen 0、Gen 1、Gen 2
    
    Heap分為3個代齡區域,相應的GC有3種方式: # Gen 0 collections, # Gen 1 collections, # Gen 2 collections。如果Gen 0 heap記憶體達到閥值,則觸發0代GC,0代GC後Gen 0中倖存的物件進入Gen 1。如果Gen 1的記憶體達到閥值,則進行1代GC,1代GC將Gen 0 heap和Gen 1 heap一起進行回收,倖存的物件進入Gen 2。2代GC將Gen 0 heap、Gen 1 heap和Gen 2 heap一起回收
    Gen 0和Gen 1比較小,這兩個代齡加起來總是保持在16M左右;Gen 2的大小由應用程式確定,可能達到幾G,因此0代和1代GC的成本非常低,2代GC稱為full GC,通常成本很高。粗略的計算0代和1代GC應當能在幾毫秒到幾十毫秒之間完成,Gen 2 heap比較大時full GC可能需要花費幾秒時間。大致上來講.NET應用執行期間2代、1代和0代GC的頻率應當大致為1:10:100
    
    圖為一個ASP.NET程式執行的Performance Moniter,Gen 0 heap size(紅色)平均6M,Gen 1(藍色)平均5M,Gen 2(黃色)達到620M,Gen 0+Gen 1平均13.2M,最大19.8M

    直觀上來看,程式的執行由一系列函式呼叫組成,函式執行期間會建立很多區域性物件,函式結束之後也就產生大量待回收的物件。採用分代演算法加強較新代齡的垃圾 回收力度,通常能夠極大的提高垃圾回收效率,否則就是極特殊的程式,或者是不合理的物件關聯設計。例如ASP.NET程式,應當確保絕大部分用於HTTP 請求處理的物件在0代和1代垃圾回收中被釋放掉

    為heap記錄幾個指標可以確定代齡區域範圍,建立reachable objects graph時根據物件的地址可以確定物件位於哪個代齡區域,0代GC在建立graph時如果遇到1代、2代heap物件,可以直接越過不用繼續遍歷下去, 較老代齡的物件如果引用了較新代齡的物件,可以通過Win32 API GetWriteWatch訂閱記憶體更新通知,記錄在"card table"中,輔助較低代齡的GC正確構造graph

4. LOH
    .NET 1.1和2.0中,85000位元組以下的物件稱為小物件,分配在Gen 0 heap中,85000位元組以上的物件稱為大物件,分配在Large Object Heap中,這是因為GC在heap壓縮時移動大的記憶體塊需要消耗大量CPU時間,通過效能調優實踐確定了85000位元組這樣一個閥值
    LOH只在2代GC時進行回收,採用Mark-Sweep演算法,沒有壓縮處理,因此LOH中的記憶體分配是不連續的,使用一個空閒列表free list記錄LOH中的空閒空間,對釋放出來的空間進行管理
    
    上圖中obj1、obj2釋放之後,其空間合併起來成為free list的一個節點,隨後被分配給obj4

    什麼時候觸發垃圾回收?
    前面已經提到,0代和1代垃圾回收主要由閥值控制。初始時Gen 0 heap大小與CPU快取的大小相關,執行時CLR根據記憶體請求狀態動態調整Gen 0 heap大小,但Gen 0和Gen 1總大小保持在16M左右
Gen 2 heap和LOH都在full GC時進行回收,full GC主要由2類事件觸發:
    a). 進入Gen 2 heap和LOH的物件很多,超過了一定比例。RegisterForFullGCNotification的引數 maxGenerationThreshold、largeObjectHeapThreshold可以分別為Gen 2 heap和LOH設定這個值
    b). 作業系統記憶體吃緊的時候。CLR會接收到作業系統記憶體緊張的通知訊息,觸發full GC

5. Heap細節、擴容與收縮
    Heap的代齡是邏輯上的結構,heap實際記憶體申請和分配以及釋放以segment(段)為單位,workstation GC模式segment大小為16M,server GC模式segment大小為64M。Gen 0和Gen 1 heap總是位於同一個段中,叫做ephemeral segment(新生段),因此max(Gen 0 heap size+Gen 1 heap size)≈16M || 64M,Gen 2 heap由0個或多個segments組成,LOH由1個或多個segments組成
    .NET程式啟動時CLR為heap建立2個segment,一個作為ephemeral segment,另一個用於LOH。.NET使用VirtualAlloc申請和分配heap記憶體,在LOH中分配新物件時沒有足夠的空間,或者1代GC 時進入Gen 2的物件過多空間不夠,.NET將為LOH或者小物件heap分配新的segment。申請新的segment失敗將由EE丟擲OutOfMemory異 常
    Full GC後完全空閒的segments將被釋放掉,記憶體返回給作業系統

    .NET 2.0對GC的一個重要改進是儘量改善heap碎片處理。heap碎片主要由pinned objects引起,改善措施主要有2個方面。首先是延遲升級,如果ephemeral segment存在pinned objects,則儘可能的延遲他們升級到Gen 2的時間點,考慮pinned objects的同時儘量充分利用當前ephemeral segment的空間;其次是重複利用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機器實現高吞吐量,採用一系列策略觀察記憶體分配以及每次GC的狀況,動態調整GC策略,儘可能使程式隨著執行時狀態的變化實現高效的GC操 作,但進行GC時會凍結所有執行緒
    Workstation GC with Concurrent GC on: 用於響應時間非常重要的互動式程式,例如流媒體的播放等(如果一次full GC導致應用程式中斷幾秒、十幾秒時間,使用者將無法忍受)。這種方式利用多CPU對full GC進行並行處理,不是整個full GC期間凍結所有執行緒,而是將full GC切分成多次很短的時間對執行緒進行凍結,線上程凍結時間之外,應用程式仍然可以正常執行,進行記憶體分配,這主要通過將Gen 0 heap size設定的比non-concurrent GC大很多而實現,使得GC操作時執行緒仍然能夠在Gen 0 heap中進行記憶體分配,但如果Gen 0 heap用完後GC仍然沒有結束,執行緒仍然會出現阻塞。這種方式付出的代價是working set和GC所需時間比non-concurrent GC要大一些
    Server GC: 用於多CPU機器的伺服器應用程式實現高吞吐量和伸縮性,充分利用伺服器的大記憶體。.NET為每個CPU建立一組heap(包括Gen 0, 1, 2和LOH)和一個GC執行緒,每個CPU可以獨立的為相應的heap執行GC操作,而其他CPU則正常執行處理。最佳的應用場景是多執行緒之間記憶體結構基本 相同,執行的工作相同或類似

    單CPU機器上只能使用workstation GC,預設情況下為Workstation GC with Concurrent GC on方式,單CPU機器上配置為Server GC無效,仍然使用workstation GC;多CPU伺服器上的ASP.NET預設使用Server GC方式,Server GC時不能使用concurrent方式
    concurrent GC可以用於單CPU機器,它與CPU數量無關
    對於ASP.NET程式應當儘量保證一個CPU僅對應一個GC執行緒,防止同一個CPU上面多個GC執行緒之間的衝突造成效能問題。如果使用了Web Garden則應當使用Workstation GC with Concurrent GC off。Web Garden為了提高吞吐量會導致多出幾倍的記憶體使用,每個work process的記憶體有很多重複部分,Web Garden的最佳應用場景是多個程式之間使用一個共享的resource pool,避免記憶體的重複並儘可能的提高吞吐量。在這一點上Server GC應當與Web Garden類似,但Web Garden在多個程式中,而Server GC是在同一個程式中通過多執行緒實現,目前沒有發現Server GC方面深入一些的資料,很多東西只能根據現有資料做一些猜想
    為workstation GC禁用concurrent GC:

<configuration>
    <runtime>
        <gcConcurrent enabled="false"/>
    </runtime>
</configuration> 

    啟用Server GC:

<configuration>
    <runtime>
        <gcServer enabled=“true"/>
    </runtime>
</configuration>


7. Finalization
    具有finalize method的物件在垃圾回收時,.NET先呼叫finalize method,然後再進行回收,具體處理如下:
    a). 在heap建立具有finalize method的物件時,物件指標會放入finalization queue;
    b). 垃圾回收時,具有finalize method的物件如果成為unreachable,則將其指標從finalization queue中移除,放入freachable queue,在本次垃圾回收處理中並不對這些物件進行回收;其它沒有finalize method的unreachable物件正常回收。freachable queue中的物件是reachable的(它引用到的其他物件也都是reachable的)
    c). 垃圾回收結束後,如果freachable queue非空,則一個專門的執行時執行緒finalizer thread被喚醒,它逐個呼叫freachable queue中物件的finalize method,然後將其指標從freachable queue中移除
    d). 經過步驟c的處理之後,第二次垃圾回收時這些物件就成為unreachable,被正常回收
    因為finalize method被設計用於非託管資源的釋放,對這些資源的釋放可能需要較長的時間,為了優化垃圾回收處理的效能,因此將呼叫finalize method專門交給一個獨立的執行緒finalizer thread非同步進行處理,這樣也造成finalize method的物件需要經過2次垃圾回收處理

參考:
Garbage Collection - Past, Present and Future, Patrick Dussud, 中文翻譯: .NET垃圾收集器的過去現在和未來(一)(二)
C# Heap(ing) Vs Stack(ing) in .NET Part IPart IIPart IIIPart IV Matthew Cochran
Garbage Collection: Automatic Memory Management in the Microsoft .NET Framework Jeffrey Richter
Garbage Collection Part 2: Automatic Memory Management in the Microsoft .NET Framework Jeffrey Richter
CLR Inside Out: Large Object Heap Uncovered Maoni Stephens
Heap: Pleasures and Pains Murali R. Krishnan
The Dangers of the Large Object Heap Andrew Hunter
Garbage Collection Notifications
Garbage Collector Basics and Performance Hints Rico Mariani
CLR Inside Out: Investigating Memory Issues Claudio Caldato and Maoni Stephens
Understanding Garbage Collection in .NET Andrew Hunter
Using GC Efficiently Part 1Part 2Part 3Part 4 Maoni Stephens
Notes on the CLR Garbage Collector Vineet Gupta
The Mystery of Concurrent GC Mark Smith
Garbage Collection Curriculum Ferreira Paulo, Veiga Luís
Java theory and practice: A brief history of garbage collection Brian Goetz

 

http://www.cnblogs.com/riccc/archive/2009/09/01/dotnet-memory-management-and-garbage-collection.html

相關文章