kevin wang's blog

.Net 記憶體管理的一些 study

June 26, 2020

這一季聽了許多同事在記憶體管理的一些討論,
決定花一些時間來讀官方文件以及動手實作看看。

在研究記憶體管理之前要先有相關監測工具,
我使用 .Net 內建的工具來做監測。

dotnet tool install --global dotnet-trace
dotnet tool install --global dotnet-counters

先執行一個簡單的 Hello world

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

並透過以下指令取得 process id:

dotnet-trace ps

然後找到要監測的 pid

接下來執行

dotnet-counters monitor --refresh-interval 1 -p {pid}
[System.Runtime]
    % Time in GC since last GC (%)                         0
    Allocation Rate / 1 sec (B)                        8,168
    CPU Usage (%)                                          0
    Exception Count / 1 sec                                0
    GC Heap Size (MB)                                      0
    Gen 0 GC Count / 60 sec                                0
    Gen 0 Size (B)                                         0
    Gen 1 GC Count / 60 sec                                0
    Gen 1 Size (B)                                         0
    Gen 2 GC Count / 60 sec                                0
    Gen 2 Size (B)                                         0
    LOH Size (B)                                           0
    Monitor Lock Contention Count / 1 sec                  0
    Number of Active Timers                                0
    Number of Assemblies Loaded                            7
    ThreadPool Completed Work Item Count / 1 sec           0
    ThreadPool Queue Length                                0
    ThreadPool Thread Count                                2
    Working Set (MB)                                      20

接下來要多產生一些物件

	class Program
	{
		static void Main(string[] args)
		{
			Console.WriteLine("press any key to start");
			Console.ReadKey();
			for (var i = 1; i < 10 * 60; i++)
			{
				var array = new byte[500000];
				var random = new Random();
				random.NextBytes(array);
				Thread.Sleep(100);
			}
			Console.WriteLine("press any key exit...");
			Console.ReadKey();
		}
	}

執行中

[System.Runtime]
    % Time in GC since last GC (%)                         0
    Allocation Rate / 1 sec (B)                    5,008,128
    CPU Usage (%)                                          0
    Exception Count / 1 sec                                0
    GC Heap Size (MB)                                      2
    Gen 0 GC Count / 60 sec                              120
    Gen 0 Size (B)                                        24
    Gen 1 GC Count / 60 sec                              120
    Gen 1 Size (B)                                     5,952
    Gen 2 GC Count / 60 sec                              120
    Gen 2 Size (B)                                   239,456
    LOH Size (B)                                   4,020,088
    Monitor Lock Contention Count / 1 sec                  0
    Number of Active Timers                                0
    Number of Assemblies Loaded                            8
    ThreadPool Completed Work Item Count / 1 sec           0
    ThreadPool Queue Length                                0
    ThreadPool Thread Count                                2
    Working Set (MB)                                      24

執行結束

[System.Runtime]
    % Time in GC since last GC (%)                         0
    Allocation Rate / 1 sec (B)                        8,168
    CPU Usage (%)                                          0
    Exception Count / 1 sec                                0
    GC Heap Size (MB)                                      2
    Gen 0 GC Count / 60 sec                                0
    Gen 0 Size (B)                                        24
    Gen 1 GC Count / 60 sec                                0
    Gen 1 Size (B)                                   117,360
    Gen 2 GC Count / 60 sec                                0
    Gen 2 Size (B)                                   117,704
    LOH Size (B)                                   3,520,032
    Monitor Lock Contention Count / 1 sec                  0
    Number of Active Timers                                0
    Number of Assemblies Loaded                            8
    ThreadPool Completed Work Item Count / 1 sec           0
    ThreadPool Queue Length                                0
    ThreadPool Thread Count                                0
    Working Set (MB)                                      24

以上資訊有幾個名詞

其中

Allocation Rate / 1 sec (B) 約為 5,000,000 (程式碼是每100毫秒 new byte[500000]

大型物件堆積(LOH)占用非常多的原因是因為當物件要求超過 85,000 B的時候,
runtime 會一律將物件放在 LOH 上,
而小於 85,000 B 的物件會放在(小型物件堆積) SOH 上。

物件在記憶體中會放在以下幾個層代

  • 層代 0(Gen 0)
  • 層代 1(Gen 1)
  • 層代 2(Gen 2)

占用記憶體小的物件會在 Gen 0 結束, 留存比較久的物件會提升到 Gen 1 或 Gen 2, 提升的條件為 GC 發生後沒被回收的物件就會被提升, 而大型物件會一律在 Gen 2 中。

GC通常發生在

  1. Gen 0 達到回收閥值
  2. LOH 達到回收閥值
  3. 系統記憶體不足(由作業系統通知)
  4. 呼叫 GC.Collect() 後強制執行
    (不是每種 runtime 都是這樣實作的,如Java的實作是只通知 GC ,系統不一定會馬上執行,也可能忽略 GC 請求)

GC有兩種方式

  1. 工作站垃圾收集(Workstation garbage collection) 在只有一個處理器的的機器上會強制使用這種方式, GC執行緒會與其他執行緒搶CPU時間
  2. 伺服器垃圾收集(Server garbage collection) 每個處理器上都會有一條專門GC執行緒,
    由於有多個 GC 執行緒,
    所以 GC 速度會比工作站垃圾收集快, 但是如果有多個程式在同一台機器上使用伺服器垃圾收集時會互搶CPU時間,
    所以會建議不要同時開伺服器垃圾回收,
    甚至在上百個程式在同一台機器上運行時,
    建議切換為工作站垃圾收集避免大量的 context switch。

前景GC與背景GC

GC 也分為

  1. 背景垃圾收集(background GC)

    在背景垃圾收集發生時所有託管的執行緒會盡可能的繼續執行

  2. 前景垃圾收集(foreground GC)

    在前景垃圾收集發生時所有託管的執行緒都必須暫停執行

不同Gen有不同的回收期間

Gen 1 GC 發生時,會同時對 Gen 0 與 Gen 1 做記憶體回收,
Gen 2 GC 發生時,則會同時對 Gen 0、Gen 1 與 Gen 2 做記憶體回收, 所以Gen 2 GC又稱為完整GC。

以下是一塊記憶體配置的示例

SOH Allocations and GC

  1. GC前

    ----------------------------------------------------
    |Obj1(使用中) |Obj2(未使用)|Obj3(使用中)|Obj4(未使用)|
    ----------------------------------------------------
    |            |            |           |
    Gen 0        Gen 0        Gen 0       Gen 0
  2. 開始GC

    ----------------------------------------------------
    |Obj1(使用中) |            |Obj3(使用中)|           |
    ----------------------------------------------------
  3. 壓縮

    ----------------------------------------------------
    |Obj1(使用中) |Obj3(使用中)|                        |
    ----------------------------------------------------
    |
    Gen 1

    此時Obj1 Obj3提升到Gen 1

LOH Allocations and GC

  1. GC前

    ----------------------------------------------------
    |Obj1(使用中) |Obj2(未使用)|Obj3(使用中)|Obj4(未使用)|
    ----------------------------------------------------
    |            |            |           |
    Gen 2        Gen 2        Gen 2       Gen 2
  2. GC

    ----------------------------------------------------
    |Obj1(使用中) |            |Obj3(使用中)|           |
    ----------------------------------------------------
    |                         |
    Gen 2                     Gen 2

LOH 的記憶體區塊通常不會壓縮,
因為大型物件的搬移成本較高,
在較新的 runtime 可以透過設定,
GCSettings.LargeObjectHeapCompactionMode
決定是否要做 LOH 壓縮(*7)
(文件有提到目前不會自動壓縮 LOH 但未來 .Net 不排除會自動壓縮 LOH )

記憶體使用不當對效能的影響

  1. 頻繁 GC 導致執行緒被暫停吞吐量降低
  2. 記憶體碎片過多導致頻繁壓縮甚至因無法配置記憶體導致記憶體不足

記憶體使用上的一些優化技巧

  1. 在預先知道集合大小時直接配置剛好的大小避免自動配置導致配置多餘的記憶體。
  2. 在集合(如列表)可重用的情形下直接透過 static 宣告直接重用。
    (通常在不平行執行的 Job 等只有一條執行緒存取的情境較常見)
  3. 若是資料物件傳遞的情境可以考慮定出介面並實作,
    使物件傳遞時不須因為要轉換成小物件而需再建立一個小物件並重新賦值。

		public interface IAccountInfo
		{
			string AccountId { get; set; }
			string Password { get; set; }
			string Name { get; set; }
			int Gender { get; set; }
			string FirstName { get; set; }
			string LastName { get; set; }
		}

		public class AccountInfo : IAccountInfo, ISmallAccountInfo
		{
			public string AccountId { get; set; }
			public string Password { get; set; }
			public string Name { get; set; }
			public int Gender { get; set; }
			public string FirstName { get; set; }
			public string LastName { get; set; }
			public string FullName { get => FirstName + LastName; }
		}

		public interface ISmallAccountInfo
		{
			string AccountId { get; }
			string Password { get; }
			string FullName { get; }
		}

		public class SmallAccountInfo : ISmallAccountInfo
		{
			public string AccountId { get; }
			public string Password { get; }
			public string FullName { get; }
		}

在取得 AccountInfo 物件後,
若需要呼叫一個方法而此方法需要 SmallAccountInfo 資料物件,
就可以考慮透過這樣的技巧省去新配置物件以及賦值。

  1. 部分無狀態物件可考慮用 Singleton,
    但建議透過DI容器來使用 Singleton。
  2. 實作 Object Pool 將部分常常重複建立的物件都透過Pool來取得避免不斷重配置記憶體後再被回收。(手機應用開發上很常見)
  3. 使用 .Net 提供的 Span<T> 和 Memory<T> API操作記憶體

ref:

  1. MSDN dotnet-追蹤效能分析公用程式
  2. MSDN (dotnet) 的計數器調查效能計數器
  3. MSDN ASP.NET Core 中的記憶體管理和垃圾收集 (GC)
  4. MSDN 在 .NET Core 中偵測記憶體流失
  5. MSDN 背景垃圾收集
  6. MSDN GC.Collect 方法
  7. MSDN GCSettings.LargeObjectHeapCompactionMode 屬性
  8. MSDN 工作站和伺服器記憶體回收