­

淺入 .NET Core 中的記憶體和GC知識


參考資料:

【1】//docs.microsoft.com/zh-cn/dotnet/standard/managed-code

【2】://docs.microsoft.com/zh-cn/dotnet/standard/clr

託管程式碼

在 .NET 中, CLR(Common Language Runtime) 負責提取託管程式碼並編譯成機器語言,然後執行它。在此過程中,CLR 提供自動記憶體管理、安全邊界、類型安全等服務,保證了程式碼安全。

託管程式碼指在其執行過程中由 CLR(Common Language Runtime) 管理的程式碼,託管程式碼是可在 .NET 上運行得一種高級語言(C#、F#等),編寫的託管程式碼被編譯後會被生成 中間語言(IL)。

CLR 有 .NET Core/.NET5+、Mono、.NET Framework 等實現,託管程式碼生成的文件(IL程式碼)不能被作業系統直接運行,需要 CLR 的實現(如 .NET5) 託管運行,託管過程中對其再次編譯生成二進位程式碼(JIT編譯)。

中間語言(IL)有時也稱為公共中間語言 (CIL) 或 Microsoft 中間語言 (MSIL)。

自動記憶體管理

自動記憶體管理是 CLR 的功能之一,它可以為應用程式管理記憶體的分配和釋放,託管程式碼被執行時,由 CLR 進行記憶體管理,保證了記憶體安全。

垃圾回收

GC

GC(garbage collector)中文譯為垃圾回收器,.NET 中的 GC 指的是 CLR 中的自動記憶體管理器,GC 負責管理 .NET 程式的記憶體分配和釋放

GC 的優點如下:

  • 自動管理記憶體,不必手動分配和釋放;

  • 高效管理託管堆上的對象;

  • 智慧回收對象,清除記憶體;

  • 記憶體安全:避免野指針、懸空指針等情況造成嚴重錯誤;

記憶體

物理記憶體

物理記憶體是物理記憶體條上的記憶體空間,是物理機器真實的容量大小。

虛擬記憶體

虛擬記憶體(Virtual Memory)是電腦作業系統進行記憶體管理的一種技術,它可以將多個硬體、非連續地址的碎片空間組合起來,形成進程上可識別的連續記憶體空間。

虛擬記憶體由作業系統進行支援,如 Windows 上的虛擬記憶體,Linux 上的交互空間,虛擬記憶體需要作業系統映射到真實的記憶體地址空間才能使用。虛擬記憶體調度方式有分頁式、段式、段頁式3種,讀者感興趣可自行查閱資料。

現代作業系統都採用了虛擬記憶體管理技術,通過對物理存儲設備的抽象,作業系統調度外存當作記憶體使用,提供了比物理記憶體更大的記憶體範圍。

這些存儲設備組成的記憶體稱為虛擬地址空間,而用戶(開發者)接觸到的地址是虛地址,並不是真實的物理地址。虛擬空間大大拓展了記憶體,使得系統可以同時運行多道程式而不「吃力」。

虛擬地址空間分為兩部分:用戶空間、內核空間,每個程式運行時的會消耗兩種空間。在 Linux 中比例是 3:1,在 Windows 中是 2:2。

.NET 記憶體組成

.NET 中,記憶體分為非託管記憶體、託管記憶體。

.NET Core/.NET5+ 有一個稱為 dotnet 的驅動程式,此驅動程式用於執行命令或運行 .NET 程式。當我們使用 dotnet 命令運行一個 .dll 文件時,作業系統會啟動 dotnet 驅動程式,此時會分配作業系統記憶體資源、dotnet 驅動程式記憶體資源,這一部分即非託管資源,其中 dotnet 部分的記憶體包含了 CLR 等部件的記憶體。即使你並沒有使用到 C/C++ 等非託管程式碼或者使用非託管資源,也會使用到非託管記憶體。

接下來 CLR 將初始化新進程,CLR 將為其分配託管記憶體(託管堆),這段託管記憶體是一個連續的地址空間區域。.NET 安全程式碼只能使用託管記憶體,不能直接使用物理記憶體,垃圾收集器會為安全程式碼在託管堆上分配和釋放虛擬記憶體。

顯然, dotnet 的工作原理十分複雜,筆者沒有能力講清楚,感興趣的讀者可以自行查閱資料。

CLR 中的記憶體

微軟 .NET CLR 文檔中寫道:By default, on 32-bit computers, each process has a 2-GB user-mode virtual address space.

即在 32 位系統中,.NET 進程會使用 2GB 的用戶模式虛擬記憶體,其虛擬地址空間的表示範圍是 0x00000000 到 0x7fff;而 64 位系統中,地址範圍是 0x000’00000000 到0x7FFF’FFFFFFFF,約等於 16TB。

從以上資訊,我們知道 .NET 程式會消耗比較多的虛擬記憶體,如果在 64 位作業系統上運行 .NET 程式,其用戶模式虛擬地址空間可能遠遠大於 2GB。

編寫一個 “c1” 程式,其程式碼如下:

        static void Main(string[] args)
        {
            Console.WriteLine("Hello World!");
            Console.Read();
        }

在 Linux 中使用 dotnet xx.dll 命令運行程式,然後查看其佔用的資源:

 VIRT    RES    SHR S  %CPU  %MEM     TIME+ COMMAND
  3.1g   0.0g   0.0g S   0.3   0.3   0:00.83 dotnet

使用 dotnet-counters 查看 dotnet 進程:

    GC Heap Size (MB)                                              0
    Gen 0 GC Count (Count / 1 sec)                                 0
    Gen 0 Size (B)                                                 0
    Gen 1 GC Count (Count / 1 sec)                                 0
    Gen 1 Size (B)                                                 0
    Gen 2 GC Count (Count / 1 sec)                                 0
    Gen 2 Size (B)                                                 0
    LOH Size (B)                                                   0

註:使用 dotnet run 運行 .NET 項目,會出現 dotnet、c1 兩個進程,可以看到會產生 dotnet 和 c1 兩個進程,dotnet 是驅動程式,dotnet 啟動後,CLR 會將. dll 程式集編譯,並初始化啟動一個進程。

CLR 中的虛擬地址空間需要位於一個地址塊中,因為在請求虛擬記憶體分配時,虛擬記憶體管理器必須找到滿足需求的單個可用塊,例如就算存在大於 2GB 的虛擬地址空間,但如果不是連續的,則會分配失敗。如果沒有足夠的可供保留的虛擬地址空間或可供提交的物理空間,則可能會用盡記憶體。

CLR 虛擬記憶體狀態

CLR 中的虛擬記憶體可以有三種狀態:

State Description
Free 可用 The block of memory has no references to it and is available for allocation. 記憶體塊沒有對它的引用,可以進行分配
Reserved保留 The block of memory is available for your use and cannot be used for any other allocation request. 該記憶體塊可供您使用,不能用於任何其他分配請求 However, you cannot store data to this memory block until it is committed. 但是,在提交數據之前,不能將數據存儲到此記憶體塊中
Committed已提交 The block of memory is assigned to physical storage. 記憶體塊已指派給物理存儲

記憶體分配

CLR 在初始化新進程時,會為進程保留一個連續的地址空間區域,這個地址空間被稱為託管堆。託管堆中維護著一個指針,最初此指針指向託管堆的基址,這個指針是向後移動的。當需要分配記憶體時,CLR 便會分配位於此指針後的記憶體區域,同時指針指向此對象地址空間之後的位置。

記憶體分配

由於 CLR 通過向指針添加值來為對象分配記憶體,所以它的分配速度幾乎跟從堆棧中分配記憶體速度一樣快;而且連續分配的新對象連續存儲在託管堆中,程式可以快速地訪問這些對象。

當 GC 回收記憶體時,一些對象釋放後記憶體會被回收,這樣託管堆地記憶體處於碎片化,之後整個記憶體段會被壓縮,重新組成連連續的記憶體段,指針會被重置到對象的末尾。

當然,大對象堆(LOH)回收並不會壓縮記憶體段,這一點我們後面再討論。

記憶體釋放

垃圾回收的條件

根據微軟官方文檔,整理的垃圾回收條件如下:

  • 系統物理記憶體不足;
  • 託管堆分配的記憶體已超出可接受閾值;(當然,這個閾值會被動態調整)
  • 手動調用 GC 類的 API(例如 GC.Collect);

託管堆

本機堆(Native Heap)

前面提到過,.NET 的記憶體有非託管記憶體和託管記憶體。CLR 運行的進程,存在本機堆和託管堆兩種記憶體堆,本機記憶體堆通過 Windows API 的 VirtualAlloc 函數分配,提供給 作業系統和 CLR 使用,用於非託管程式碼所需的記憶體。

託管堆(Managed Heap)

關於託管堆,前面已經寫了,這裡不再贅述。

託管堆代數

託管堆中的記憶體被分為三代,分別使用0、1、2 標識,GC 分配的記憶體首先在 0 代託管堆中,當進行垃圾回收時,如果對象沒有被釋放,則將其升級並存儲到 1 代託管堆中。1 代託管堆進行記憶體回收時,不被釋放的對象也會被升級到 2 代記憶體中,然後 1 代記憶體堆進行空間壓縮。

託管堆的管理是 GC 負責的,而 GC 進行記憶體分配和釋放,使用了 GC 演算法。

GC 演算法基於以下理論:

  • ① 壓縮託管堆的一部分記憶體要比壓縮整個託管堆速度快;
  • ② 較新的對象生命周期較短,較舊的對象生命周期較長;
  • ③ 較新的對象趨向於相互關聯,並且大約在同一時間被應用程式訪問;

我們必須深刻理解這些理論,才能深入理解託管堆的設計。

關於 0 到 2 代堆,其基本說明如下:

  • 0 代:0 代中的對象擁有短暫的生命周期,垃圾回收最常發生在此代中;
  • 1 代:作為生命周期較短和生命周期較長對象的緩衝區。
  • 2 代:存儲生命周期長的對象;0、1 代沒被回收而升級的對象會升級到 2 代中,靜態數據等則會一開始就分配到 2代。

在 .NET 5 之前,.NET 有 SOH(小對象堆)、LOH(大對象堆);在 .NET 5 中,出現了 POH ;

小對象堆的記憶體段有 0、1、2 代堆;

微信圖片_20210110194803

今天就水到這裡為止。