dotMemory
如今,許多開發人員都熟悉效能分析的工作流程:在分析器下執行應用程式,測量方法的執行時間,識別佔用時間較多的方法,並致力於最佳化它們。然而,這種情況並沒有涵蓋到一個重要的效能指標:應用程式多次GC所分配的時間。當然,你可以評估GC所需的總時間,但是它從哪裡來,如何減少呢? “普通”效能分析不會給你任何線索。
垃圾收集總是由高記憶體流量引起的:分配的記憶體越多,需要收集的記憶體就越多。眾所周知,記憶體流量最佳化應該在記憶體分析器的幫助下完成。它允許你確定物件是如何分配和收集的,以及這些分配背後保留了哪些方法。理論上看起來很簡單,對吧?然而,在實踐中,許多開發人員最終都會這樣說:“好吧,我的應用程式中的一些流量是由一些系統類生成的,這些系統類的名稱是我一生中第一次看到的。我想這可能是因為一些糟糕的程式碼設計。現在我該怎麼做?”
這就是這篇文章的主題。實際上,這將是一系列文章,我將在其中分享我的記憶體流量分析經驗:我認為什麼是“糟糕的程式碼設計”,如何在記憶體中找到其蹤跡,當然還有我認為的最佳實踐。
簡單的例子:如果您在堆中看到值型別的物件,那麼裝箱肯定是罪魁禍首。裝箱總是意味著額外的記憶體分配,因此移除它很可能會讓您的應用程式變得更好。
該系列的第一篇文章將重點關注裝箱。如果檢測到“bad memory pattern”,該去哪裡查詢以及如何採取行動?
本系列中描述的最佳實踐使我們能夠將 .NET 產品中某些演算法的效能提高 20%-50%。
您需要什麼工具
在我們進一步討論之前,先看看我們需要的工具。我們在 JetBrains 使用的工具列表非常簡短:
- dotMemory 記憶體分析器。無論您試圖查詢什麼問題,分析演算法始終相同:
- 在啟用記憶體流量收集的情況下開始分析您的應用程式。
- 在您感興趣的方法或功能完成工作後收集記憶體快照。
- 開啟快照並選擇記憶體流量檢視。
- Heap Allocations Viewer外掛。該外掛會突出顯示程式碼中分配記憶體的所有位置。這不是必須的,但它使編碼更加方便,並且在某種意義上“迫使”您避免過度分配。
Boxing
裝箱是將值型別轉換為引用型別。 例如:
int i = 5;
object o = i; // 發生裝箱
為什麼這是個問題?值型別儲存在棧中,而引用型別儲存在託管堆中。因此,要將整數值分配給物件,CLR 必須從棧中取出該值並將其複製到堆中。當然,這種移動會影響應用程式的效能。
一個物件的至少佔用3個指標單元:物件頭(object header)、方法表指標(method table ref)、預留單元(首欄位地址/陣列長度)
在x64系統3個指標單元意味24位元組的開銷,而一個int型別本身只佔用4位元組,其次,棧記憶體的由執行執行緒方法棧管理,方法內宣告的local變數、字面量更是能夠在IL編譯期就預算出棧容量,效率遠高於執行時堆記憶體GC體系
如何發現
使用 dotMemory,找到boxing是一項基本任務:
- 開啟View memory allocations檢視。
- 查詢值型別的物件(Group by Types),這些都是boxing的結果。
- 確定分配這些物件並生成大部分流量的方法。
當我們嘗試將值型別賦值給引用型別時,Heap Allocation Viewer外掛也會提示閉包分配的事實:
Boxing allocation: conversion from value type 'int' to reference type 'object'
從效能角度來看,您更感興趣的是這種閉包發生的頻率。例如,如果帶有裝箱分配的程式碼只被呼叫一次,那麼最佳化它不會有太大幫助。考慮到這一點,dotMemory 在檢測閉包是否引起真正問題方面要可靠得多。
如何修復
在解決裝箱問題之前,請確保它確實會產生大量流量。如果是這樣,你的任務就很明確:重寫程式碼以消除裝箱。當你引入某些值型別時,請確保不會在程式碼中的任何位置將值型別轉換為引用型別。例如,一個常見的錯誤是將值型別的變數傳遞給使用字串的方法(例如 String.Format
):
int i = 5;
string.Format("i = {0}", i); // 引發box
一個簡單的修復方法是呼叫恰當的值型別 ToString() 方法:
int i = 5;
string.Format("i = {0}", i.ToString());
Resize Collections
動態大小的集合(例如 Dictionary
, List
, HashSet
, 和 StringBuilder
)具有以下特性: 當集合大小超過當前邊界時,.NET 會調整集合的大小並在記憶體中重新定義整個集合。顯然,如果這種情況頻繁發生,應用程式的效能將會受到影響。
如何發現
使用 dotMemory 比對兩個快照
-
開啟View memory allocations檢視
-
找到產生大記憶體流量的集合型別
-
看看是否與
Dictionary<>.Resize
、List<>.SetCapacity
、StringBuilder.ExpandByABlock
等等集合擴容有關
如何修復
如果“resize”方法造成的流量很大,唯一的解決方案是減少需要調整大小的情況數量。嘗試預測所需的大小並用該大小初始化集合。
var list = new List<string>(1000); // 初始容量1000
此外請記住,任何大於或等於 85,000 位元組的分配都會在大物件堆 (LOH) 上進行。在 LOH 中分配記憶體會帶來一些效能損失:由於 LOH 未壓縮,因此在分配時需要 CLR 和空閒列表之間進行一些額外的互動。然而,在某些情況下,在 LOH 中分配物件是有意義的,例如,在必須承受應用程式的整個生命週期的大型集合(例如快取)的情況下。
Enumerating Collections
使用動態集合時,請注意列舉它們的方式。這裡典型的主要頭痛是使用 foreach
列舉一個集合,只知道它實現了 IEnumerable
介面。考慮以下示例:
class EnumerableTest
{
private void Foo(IEnumerable<string> sList)
{
foreach (var s in sList)
{
}
}
public void Goo()
{
var list = new List<string>();
for (int i = 0; i < 1000; i++)
{
Foo(list);
}
}
}
Foo 方法中的列表被轉換為 IEnumerable
介面,這意味著列舉器的進一步裝箱,因為List<T>.Enumerator
是結構體。
public struct Enumerator : IEnumerator<T>, IEnumerator, IDisposable
{
public T Current { get; }
object IEnumerator.Current { get; }
public void Dispose();
public bool MoveNext();
void IEnumerator.Reset();
}
如何發現
- 開啟View memory allocations檢視
- 找到值型別
System.Collections.Generic.List+Enumerator
並檢查生成的流量。 - 查詢生成這些物件的方法。
- Heap Allocation Viewer外掛也會提示您有關隱藏分配的資訊:
如何修復
避免將集合強制轉換為介面。在上面的示例中,最佳解決方案是建立一個接受 List<string>
集合的 Foo 方法過載。
private void Foo(List<string> sList)
{
foreach (var s in sList)
{
}
}
如果我們在修復後分析程式碼,會發現 Foo 方法不再建立列舉器。
don’t prematurely optimize
易讀性應該在多數時候成為我們編碼的第一原則,而非的效能優先或記憶體優先。本文討論的一切都是微觀最佳化,定期進行記憶體分析是良好的習慣
例如,交換a和b,從第一直覺上我們會編寫出以下程式碼:
int a = 5;
int b = 10;
var temp = a;
a = b;
b = temp;
// 在c# 7+我們甚至可以用元組,進一步增強可閱讀性
(a, b) = (b, a);
但是下面這種寫法透過按位運算,可以不必申請額外空間來儲存temp
a = a ^ b;
b = a ^ b;
a = a ^ b;
但這並不是我們鼓勵的:過早的在編碼初期進行最佳化,喪失可讀性。在99%的情況下,我們的程式碼應該只依賴語義,剩下的,交給探查器!
上文Boxing提到的string.Format
案例,只能代表今天,而不是明天。也許下一個將在IL編譯時甚至JIT中去解決值型別裝箱問題,Enumerating Collections也是同一個道理。
int i = 5;
string.Format("i = {0}", i); // 引發box
DefaultInterpolatedStringHandler
.net6引入的ref結構DefaultInterpolatedStringHandler
,就是一個很好的案例
$"..."
這種字串插值(String Interpolation)語法是在 C# 6.0 中引入的。
var i = 5;
var str = $"i = {i}"; // box
在.net6之前,上面的寫法會發生裝箱,生成的IL如下:
IL_001a: ldarg.0 // this
IL_001b: ldstr "i = {0}"
IL_0020: ldarg.0 // this
IL_0021: ldfld int32 Fake.EventBus.RabbitMQ.RabbitMqEventBus/'<ProcessingEventAsync>d__19'::'<i>5__1'
IL_0026: box [netstandard]System.Int32
IL_002b: call string [netstandard]System.String::Format(string, object)
IL_0030: stfld string Fake.EventBus.RabbitMQ.RabbitMqEventBus/'<ProcessingEventAsync>d__19'::'<str>5__2'
而從.net6開始,生成的IL發生了變化,由原來呼叫的System.String::Format(string, object)
,變成了DefaultInterpolatedStringHandler
,裝箱也不見了,內部細節感興趣的自己去閱讀原始碼,內部用到了高效能的Span,unsafe和ArrayPool
IL_0014: ldloca.s V_3
IL_0016: ldc.i4.4
IL_0017: ldc.i4.1
IL_0018: call instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::.ctor(int32, int32)
IL_001d: ldloca.s V_3
IL_001f: ldstr "i = "
IL_0024: call instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::AppendLiteral(string)
IL_0029: nop
IL_002a: ldloca.s V_3
IL_002c: ldloc.0 // i
IL_002d: call instance void [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::AppendFormatted<int32>(!!0/*int32*/)
IL_0032: nop
IL_0033: ldloca.s V_3
IL_0035: call instance string [System.Runtime]System.Runtime.CompilerServices.DefaultInterpolatedStringHandler::ToStringAndClear()
IL_003a: stloc.1 // str
不要過早最佳化
不要過早最佳化!!!
不要過早最佳化!!!
不要過早最佳化!!!
Link
本系列參考jetbrains官方團隊的部落格:https://blog.jetbrains.com/dotnet,加以作者的個人理解做出的二次創作,如有侵權請聯絡刪除:2357729423@qq.com。