當前位置: 妍妍網 > 碼農

記憶體最佳化:Boxing

2024-06-07碼農

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是一項基本任務:

    1. 開啟View memory allocations檢視。

    2. 尋找值型別的物件(Group by Types),這些都是boxing的結果。

    3. 確定分配這些物件並生成大部份流量的方法。

    當我們嘗試將值型別賦值給參照型別時,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 比對兩個快照

    1. 開啟View memory allocations檢視

    2. 找到產生大記憶體流量的集合型別

    3. 看看是否與 Dictionary<>.Resize List<>.SetCapacity StringBuilder.ExpandByABlock 等等集合擴容有關

      image-20240605174652571

    如何修復

    如果「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();
    }


    如何發現

    1. 開啟View memory allocations檢視

    2. 找到值型別 System.Collections.Generic.List+Enumerator 並檢查生成的流量。

    3. 尋找生成這些物件的方法。

    4. Heap Allocation Viewer外掛程式也會提示您有關隱藏分配的資訊:

    image-20240605184800584

    如何修復

    避免將集合強制轉換為介面。在上面的範例中,最佳解決方案是建立一個接受 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(stringobject)
    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