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
介面。考慮以下範例:
classEnumerableTest
{
privatevoidFoo(IEnumerable<string> sList)
{
foreach (var s in sList)
{
}
}
publicvoidGoo()
{
var list = new List<string>();
for (int i = 0; i < 1000; i++)
{
Foo(list);
}
}
}
Foo 方法中的列表被轉換為
IEnumerable
介面,這意味著列舉元的進一步裝箱,因為
List<T>.Enumerator
是結構體。
publicstruct Enumerator : IEnumerator<T>, IEnumerator, IDisposable
{
public T Current { get; }
object IEnumerator.Current { get; }
publicvoidDispose();
publicboolMoveNext();
void IEnumerator.Reset();
}
如何發現
開啟View memory allocations檢視
找到值型別
System.Collections.Generic.List+Enumerator
並檢查生成的流量。尋找生成這些物件的方法。
Heap Allocation Viewer外掛程式也會提示您有關隱藏分配的資訊:
如何修復
避免將集合強制轉換為介面。在上面的範例中,最佳解決方案是建立一個接受
List<string>
集合的 Foo 方法多載。
privatevoidFoo(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,加以作者的個人理解做出的二次創作,如有侵權請聯系刪除:[email protected]。
技術交流群:737776595