.NET程序的對象是由CLR控制并分配在托管堆中,如果是你,會如何設計一個內存分配策略呢?
按需分配,要多少分配多少,移動alloc_ptr指針即可,沒有任何浪費。缺點是每次都要向OS申請內存,效率低
預留緩沖區,降低了向OS申請內存的頻次。但在多線程情況下,alloc_ptr鎖競爭會非常激烈,同樣會降低效率
利用TLS,來避免鎖競爭,但Thread1與Thread2之間存在Free塊,內存空間浪費多。
將Free塊利用起來,實現最大化能效
.NET程序就是采用了第四種,來實現空間與時間的最大化。
因此有些alloc_context在段尾,有些在兩個已分配對象之間的空余空間
眼見為實
Free塊
在上面,我們已經見到了Free塊,簡單來說,Free就是segment中留下來的空洞。也就是內存碎片。
Free塊產生的原因主要有三種
- GC計劃階段,用作特殊目的
- GC標記階段,將垃圾對象標記為Free
- 多個Free相鄰時,GC將多個合并為一個大的Free塊
內存碎片的危害:造成內存空間的浪費,降低內存分配效率。
眼見為實
點擊查看代碼
internal class Program
{
public static byte[] bytes1, bytes2, bytes3, bytes4, bytes5, bytes6;
static void Main(string[] args)
{
Alloc();
Console.WriteLine("分配完畢");
Debugger.Break();
GC.Collect();
Console.WriteLine("GC完成");
Debugger.Break();
}
public static void Alloc()
{
bytes1 = new byte[85000];
bytes2 = new byte[85000];
bytes3 = new byte[85000];
bytes4 = new byte[85000];
bytes5 = new byte[85000];
bytes6 = new byte[85000];
bytes2 = null;
bytes3 = null;
bytes5 = null;
}
}
LOH特有的特殊標記
垃圾對象被標記為Free,相鄰的Free對象合并。
Free塊管理邏輯
CLR對Free塊采用數組+鏈表進行管理,根據size確定對應的bucket,再使用雙向鏈表維系大小相近的Free塊。
這樣按照大小維度劃分,極大提高了查找的性能,拿空間換時間。
眼見為實
不是所有Free都會被納入管理,只有 free > 2 * min_obj_size 的對象才能進入bucket集合中,可以思考一下為什么會這么做。
Free塊內存結構
與其它普通對象類似,也有對象頭與方法表,再附加額外信息。內存結構如下。
眼見為實
點擊查看代碼
internal class Program
{
public static byte[] bytes1, bytes2, bytes3, bytes4, bytes5, bytes6;
static void Main(string[] args)
{
Alloc();
Console.WriteLine("分配完畢");
Debugger.Break();
GC.Collect();
Console.WriteLine("GC完成");
Debugger.Break();
}
public static void Alloc()
{
bytes1 = new byte[85000];
bytes2 = new byte[85000];
bytes3 = new byte[85000];
bytes4 = new byte[85000];
bytes5 = new byte[85000];
bytes6 = new byte[85000];
bytes2 = null;
bytes4 = null;
bytes6 = null;
}
}
細心的朋友會發現兩個問題
- do命令顯示的size明明為0x14c60,為什么dp命令顯示為0x14c48?
只是計算取值的差異,它們之間差值為24,分別為objectheader/mt/size - 為什么Next Free有值,Prev Free沒有值?
在SOH上2代Free記錄了Next/Prev Free的雙向鏈表。 其他代只記錄了Next Free的單向鏈表
對象分配過程
點擊查看代碼
internal class Program
{
static void Main(string[] args)
{
Debugger.Break();
var person = new Person();
Debugger.Break();
}
}
public class Person
{
private long age = 10;
private int age2 = 10;
private int age3 = 10;
}
new Person() 的匯編如下
00007ffb`93b8195e 48b9d893c393fb7f0000 mov rcx,7FFB93C393D8h (MT: Example_12_1_4.Person)
00007ffb`93b81968 e8d3ceb65f call coreclr!JIT_TrialAllocSFastMP_InlineGetThread (00007ffb`f36ee840)
00007ffb`93b8196d 488945f0 mov qword ptr [rbp-10h],rax
00007ffb`93b81971 488b4df0 mov rcx,qword ptr [rbp-10h]
00007ffb`93b81975 ff15b5df0a00 call qword ptr [00007ffb`93c2f930 (Example_12_1_4.Person..ctor())
00007ffb`93b8197b 488b45f0 mov rax,qword ptr [rbp-10h]
00007ffb`93b8197f 488945f8 mov qword ptr [rbp-8],rax
可以看到,New對象的創建分為兩步:
- 先調用JIT_TrialAllocSFastMP_InlineGetThread進行內存分配
LEAF_ENTRY JIT_TrialAllocSFastMP_InlineGetThread, _TEXT
mov edx, [rcx + OFFSET__MethodTable__m_BaseSize]
INLINE_GETTHREAD r11
mov r10, [r11 + OFFSET__Thread__m_alloc_context__alloc_limit]
mov rax, [r11 + OFFSET__Thread__m_alloc_context__alloc_ptr]
add rdx, rax
cmp rdx, r10
ja AllocFailed
mov [r11 + OFFSET__Thread__m_alloc_context__alloc_ptr], rdx
mov [rax], rcx
ret
AllocFailed:
jmp JIT_NEW
LEAF_END JIT_TrialAllocSFastMP_InlineGetThread, _TEXT
- 再調用構造函數進行值分配
在Example_12_1_4.Person..ctor()處設置斷點
快速路徑與慢速路徑
在上面提到過的JIT_TrialAllocSFastMP_InlineGetThread方法中,可以看到當Alloc_limit不足,不能完成內存分配時,會執行JIT_NEW方法。
JIT_NEW內部有大量判斷,來盡最大可能保證分配成功。因此執行速度比較慢,所以稱為慢速路徑,與之對應的JIT_TrialAllocSFastMP_InlineGetThread方法,判斷極其簡單且高效,所以被稱之為快速路徑
JIT在編譯期間會根據不同的對象來使用不同的策略,比如帶析構函數的類默認是慢速分配。
慢速分配流程圖如下:
可以看到,快速分配僅僅用8行匯編就完成了分配過程,而慢速分配則復雜得多。
眼見為實
點擊查看代碼
internal class Program
{
static void Main(string[] args)
{
byte[] b = new byte[86000];
var p = new Person();
var pf = new PersonFinalize();
Debugger.Break();
}
}
public class Person
{
}
public class PersonFinalize
{
~PersonFinalize()
{
}
}
避免堆分配
到目前位置,我們討論都是在托管堆上的分配,也了解到.NET如何盡可能使堆分配更高效。
眾所周知,在棧上進行分配與釋放的速度明顯要快得多,完全避免了堆過程。因此在特定條件下,棧分配是一個非常有空且高效的操作
如果我們希望非常高效地處理數據同時又不想再堆上分配大型數據表,可以顯示使用棧分配方式
棧分配方式主要有兩種:
- stackalloc
unsafe void Test()
{
int* array = stackalloc int[20];
array[0] = 10;
array[19] = 12;
Debugger.Break();
}
- Span
public void SpanTest()
{
Span<int> array = stackalloc int[20];
array[0] = 10;
array[19] = 12;
Debugger.Break();
}
顯式使用棧分配能帶來兩個好處
- 分配在棧上,永遠不會進入慢速分支,也完全不受GC管轄,但要注意StaticOverflow
- 由于生命周期跟隨棧指針,對象的地址也被變相的固定住(不會移動),所以可以安全的將內存地址傳遞給非托管代碼,且不會產生額外的固定(pinning)
固定(pinning)對象是影響GC速度的大殺手
總結
在性能要求非常高的情況下,盡量避免堆分配。如果條件允許,在棧上分配是更好的選擇,如果條件不允許(StaticOverflow),使用對象池機制實現對象復用也是一種好的解決辦法
基于這個思路,我們會發現日常編碼中,有很多值得優化的地方
- 使用結構傳遞小型數據
- 使用ValueTuple替代Tuple
- 使用stackalloc分配小型數組,或者使用ArrayPool實現對象復用
- 針對經常被緩存的Task,使用ValueTask
- 閉包帶來的值類型提升
- 使用ValueTuple替代匿名對象
- ........................