## 一:背景 ### 1. 講故事 首先聲明的是這個 `黑洞` 是我定義的術語,它是用來表示 `記憶體吞噬` 的一種現象,何為 `記憶體吞噬`,我們來看一張圖。 ![](https://img2023.cnblogs.com/blog/214741/202307/214741-202307241003 ...
一:背景
1. 講故事
首先聲明的是這個 黑洞
是我定義的術語,它是用來表示 記憶體吞噬
的一種現象,何為 記憶體吞噬
,我們來看一張圖。
從上面的 卦象圖
來看,GCHeap 的 Allocated=852M
和 Committed=16.6G
,它們的差值就是 分配緩衝區=16G
,緩衝區的好處就是用空間換時間,弊端就是會實實在在的侵占記憶體,擠壓其他程式的生存空間。
二:黑洞現象
1. 為什麼會有黑洞現象
萬事皆有因果,今生的果是前世種的因,換句話說是程式曾經有大量及頻繁的創建臨時對象,讓GC不自主的痙攣,小攣傷神,大攣傷身,所以GC為了避免大攣的發生,就大量的囤積本應該釋放掉的記憶體,目的就是防止未來某個時刻再次有大記憶體分配的發生。
2. 重現今生的果
我相信因果關係大家都弄清楚了,但口說無憑,還得用代碼證明一下不是?為了模擬GC痙攣,上一段測試代碼。
public class Program
{
public static void Main(string[] args)
{
var builder = WebApplication.CreateBuilder(args);
// Add services to the container.
builder.Services.AddAuthorization();
var app = builder.Build();
// Configure the HTTP request pipeline.
app.UseAuthorization();
app.MapGet("/mytest", (HttpContext httpContext) =>
{
return MyTest();
});
app.MapGet("/gc", (HttpContext httpContext) =>
{
GC.Collect();
return 1;
});
app.Run();
}
public static string MyTest()
{
List<string> list = new List<string>();
for (int i = 0; i < 100000000; i++)
{
list.Add(i.ToString());
}
return "ok";
}
}
代碼非常簡單,每請求一次 /mytest
都會分配一個 1億
大小 List<string>
數組,而這個 List<string>
又是一個臨時對象,後續會被 GC 回收,接下來我們多請求幾次來調戲一下 GC
,看他如何痙攣,截圖如下:
從卦中看,我當前請求了 6 次,記憶體峰值達到了 12G,因為是臨時對象,稍稍有一點回落,但此時已經撐成一個大胖子了,接下來我們用 WinDbg 附加一下,觀察下 Allocated 和 Committed 閾值。
0:033> !eeheap -gc
========================================
Number of GC Heaps: 12
----------------------------------------
...
Heap 11 (0000023513f26c10)
generation 0 starts at 23351c3aab8
generation 1 starts at 233484c38e0
generation 2 starts at 233484c1000
ephemeral segment allocation context: none
Small object heap
segment begin allocated committed allocated size committed size
0233484c0000 0233484c1000 02335c794ad0 023379ad2000 0x142d3ad0 (338508496) 0x31612000 (828448768)
Large object heap starts at 234384c1000
segment begin allocated committed allocated size committed size
0234384c0000 0234384c1000 0234384c1018 0234384e2000 0x18 (24) 0x22000 (139264)
Pinned object heap starts at 234f84c1000
segment begin allocated committed allocated size committed size
0234f84c0000 0234f84c1000 0234f84c1018 0234f84c2000 0x18 (24) 0x2000 (8192)
------------------------------
GC Allocated Heap Size: Size: 0x14f241378 (5622731640) bytes.
GC Committed Heap Size: Size: 0x2b125c000 (11561975808) bytes.
從卦中看當前已經有 6G
的緩衝區了,為了讓緩衝區更誇張,我們故意手工觸發一次 GC 即請求 /gc
,觸發了GC之後,記憶體從 10G 回落到了 7G 就不再降了,截圖如下:
從卦中看,這兩個指標就更誇張了,GC 堆只有 1.1M
的對象,但預留了 7.1G
的記憶體。
這個GC表現不管在 道德
還是 倫理
上都說不通的。
3. 找到前世的因
要想找到前世的因,手段有很多,比如用 WinDbg 觀察前世的托管堆,從殘留的 Committed - Allocated
上就能找到因,也可以使用 PerfView 實時觀察,這裡我們採用後者來洞察,使用預設的 Command 參數。
PerfView.exe "/DataFile:PerfViewData.etl" /BufferSizeMB:256 /StackCompression /CircularMB:500 /ClrEvents:GC,Binder,Security,AppDomainResourceManagement,Contention,Exception,Threading,JITSymbols,Type,GCHeapSurvivalAndMovement,GCHeapAndTypeNames,Stack,ThreadTransfer,Codesymbols,Compilation /NoGui /NoNGenRundown /Merge:True /Zip:True collect
採集一段時間後停止採集,接下來雙擊 GC Heap Net Mem (Coarse Sampling) Stacks
選項再選擇 WebApplication1
進程,通過 MaxMetric
指標看到曾經峰值達到了 10.9G
,截圖如下:
毫無疑問的說,記憶體峰值的時候必有妖怪,可以將峰值
填入到 End
文本框中,然後雙擊記憶體占比最高的 System.String[]
,觀察下它是誰分配的,截圖如下:
從截圖中可以清晰的看到,原來是 Program.MyTest()
造的孽,至此真相大白。
4. 尋求化解之道
化解之道有很多:
- 修改 GC 模式
簡而言之就是將 Server GC
改成 Workstation GC
,參考代碼如下:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<ServerGarbageCollection>false</ServerGarbageCollection>
</PropertyGroup>
</Project>
- 修改 Heap 個數
預設情況一個 cpucore 有一個 heap,我們可以儘量的減少 heap.count 的個數,比如將 12 個改成 2 個。參考代碼如下:
{
"runtimeOptions": {
"configProperties": {
"System.GC.HeapCount": 2
}
}
}
- 大事化小
導致今世的果
是因為在記憶體中短時的出現大對象,可以將大對象拆分成多批次的小對象處理,這樣可以達到後浪推前浪的的記憶體復用,從源頭上繞過這個問題。
三:總結
記憶體黑洞
雖不算 CLR 的一個bug,但絕對是 CLR 可優化的一個空間,分析這類問題是需要經驗性的,分享出來供後來者少踩坑吧,畢竟在我的分析旅程中至少遇到了3次