參考資料:
【1】https://docs.microsoft.com/zh-cn/dotnet/standard/managed-code
【2】:https://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 代堆;
今天就水到這裡為止。