字串池化,減少1/3記憶體佔用

微軟技術棧發表於2021-10-12

大家好,我是本期的MVP實驗室研究員:俞坤,今天我將結合一些具體樣例來向大家介紹如何通過池化字串以減少.Net記憶體佔用,並且學習如何觀測記憶體等一些周邊知識。準備好了嗎?這就上路。

微軟MVP實驗室研究員

image.png
本文通過一個簡單的業務場景,來描述如何通過字串池化來減少記憶體中的重複字串例項,從而減少記憶體的佔用。

在業務中,我們假設如下:

  • 有一百萬個商品,每個商品都有一個 ProductId 和 Color 列儲存在資料庫中
  • 需要將所有的資料載入到記憶體中,作為快取使用
  • 每個產品都有 Color
  • Color 的範圍是一個有限的範圍,我們假設大約為八十個左右

學習 dotMemory 度量記憶體

既然需要度量記憶體優化的可靠性,那麼一個簡單有效的度量工具自然必不可少。

本篇,我們介紹 Rider + dotMemory 的組合,如何進行簡單的記憶體度量。讀者也可以根據自己的實際,選擇自己青睞的工具。

首先,我們建立一個單元測試專案,並且編寫一個簡單的記憶體字典構建過程:

public const int ProductCount = 1_000_000;

public static readonly List<string> Colors = new[]
    {
        "amber", // 此處實際上有80個左右的字串,省略篇幅
    }.OrderBy(x => x).ToList();

public static Dictionary<int, ProductInfo> CreateDict()
{
    var random = new Random(36524);
    var dict = new Dictionary<int, ProductInfo>(ProductCount);
    for (int i = 0; i < ProductCount; i++)
    {
        dict.Add(i, new ProductInfo
        {
            ProductId = i,
            Color = Colors[random.Next(0, Colors.Count)]
        });
    }

    return dict;
}

從以上程式碼可以看出:

  • 建立了一百萬個商品物件,其中的 Color 通過隨機數進行隨機選取。

提前指定字典的大小的預期值,實際上也是一種優化。

請參閱:
https://docs.microsoft.com/do...

然後,我們引入 dotMemory 單元測試度量必要的 nuget 包,和其他一些無關緊要的包:

<ItemGroup>
    <PackageReference Include="JetBrains.DotMemoryUnit" Version="3.1.20200127.214830" />

    <PackageReference Include="Humanizer" Version="2.11.10" />
</ItemGroup>

接著,我們建立一個簡單的測試來度量以上字典的建立前後,記憶體的變化:

public class NormalDictTest
{
    [Test]
    [DotMemoryUnit(FailIfRunWithoutSupport = false)]
    public void CreateDictTest()
    {
        var beforeStart = dotMemory.Check();
        var dict = HelperTest.CreateDict();
        GC.Collect();
        dotMemory.Check(memory =>
        {
            var snapshotDifference = memory.GetDifference(beforeStart);
            Console.WriteLine(snapshotDifference.GetNewObjects().SizeInBytes.Bytes());
        });
    }
}

從以上程式碼可以看出:

  • 在字典建立之前,我們通過dotMemory.Check()來捕捉當前記憶體的快照,以便後續進行對比
  • 字典建立完畢後,我們比對前後兩次檢查點中新增的物件的大小。

最後,點選如下圖所示的按鈕,執行這個測試:

image.png

run dotMemory
那麼,就會到的如下這樣的結果:

image.png

result
故而,我們可以得出這樣一個簡單的結論。這樣一個字典,大約需要 61MB 的記憶體。

而這是理論上,這個字典佔用了記憶體最小情況。因為,其中每個 Color 使用的都是上面的八十個範圍之一。因此,他們達到了沒有任何重複例項的目的。

這個資料將會作為後續程式碼的一個基準。

嘗試從資料庫載入到記憶體

實際業務肯定是從資料庫之類的持久化儲存載入到記憶體中的。因此,我們度量一下,沒有經過優化情況下,這種載入方式大概需要多大的記憶體開銷。
這裡,我們使用 SQLite 作為演示的儲存資料庫,實際上用什麼都可以,因為我們關心的是最終快取的大小。
我們,引入一些無關緊要的包:

<ItemGroup>
    <PackageReference Include="Dapper" Version="2.0.90" />
    <PackageReference Include="System.Data.SQLite.Core" Version="1.0.115" />
</ItemGroup>

我們編寫一個測試程式碼,將一百萬測試資料寫入到測試庫中:

[Test]
public async Task CreateDb()
{
    var fileName = "data.db";
    if (File.Exists(fileName))
    {
        return;
    }

    var connectionString = GetConnectionString(fileName);
    await using var sqlConnection = new SQLiteConnection(connectionString);
    await sqlConnection.OpenAsync();
    await using var transaction = await sqlConnection.BeginTransactionAsync();
    await sqlConnection.ExecuteAsync(@"
CREATE TABLE Product(
    ProductId int PRIMARY KEY,
    Color TEXT
)", transaction);

    var dict = CreateDict();
    foreach (var (_, p) in dict)
    {
        await sqlConnection.ExecuteAsync(@"
INSERT INTO Product(ProductId,Color)
VALUES(@ProductId,@Color)", p, transaction);
    }

    await transaction.CommitAsync();
}

public static string GetConnectionString(string filename)
{
    var re =
        $"Data Source={filename};Cache Size=5000;Journal Mode=WAL;Pooling=True;Default IsolationLevel=ReadCommitted";
    return re;
}

以上程式碼:

  • 建立一個名為 data.db 的資料
  • 在資料庫中建立一個 Product 表,包含 ProductId 和 Color 兩列
  • 將字典中的所有資料插入到這兩個表中,其實就是前文建立的那個字典

執行這個測試,大概十秒左右,測試資料也就準備好了。後續,我們將重複從這個資料庫讀取資料,作為我們的測試用例。

現在,我們編寫一個從資料庫讀取資料,然後載入到字典的程式碼,並且度量一下記憶體的變化:

[Test]
[DotMemoryUnit(FailIfRunWithoutSupport = false)]
public async Task LoadFromDbAsync()
{
    var beforeStart = dotMemory.Check();
    var dict = new Dictionary<int, ProductInfo>(HelperTest.ProductCount);
    await LoadCoreAsync(dict);
    GC.Collect();
    dotMemory.Check(memory =>
    {
        var snapshotDifference = memory.GetDifference(beforeStart);
        Console.WriteLine(snapshotDifference.GetNewObjects().SizeInBytes.Bytes());
    });
}

public static async Task LoadCoreAsync(Dictionary<int, ProductInfo> dict)
{
    var connectionString = HelperTest.GetConnectionString();
    await using var sqlConnection = new SQLiteConnection(connectionString);
    await sqlConnection.OpenAsync();
    await using var reader = await sqlConnection.ExecuteReaderAsync(
        "SELECT ProductId, Color FROM Product");
    var rowParser = reader.GetRowParser<ProductInfo>();
    while (await reader.ReadAsync())
    {
        var productInfo = rowParser.Invoke(reader);
        dict[productInfo.ProductId] = productInfo;
    }
}

以上程式碼:

  • 我們改變了字典的建立方式,將其中的資料從資料庫中讀取並載入
  • 使用 Dapper 讀取 DataReader 並且全部載入字典

同樣,我們執行 dotMemory 度量變化,可以得到資料為:

95.1 MB
因此,我們得出,採用這種方式,多消耗了 30MB 左右的記憶體。看起來很少,但其實比前面多了 50%。(一千五工資加薪到三千,漲薪 100%的即時感)

當然,你可能會懷疑,多出來的這些開銷實際上是資料庫操作消耗的。但通過下文的優化,我們可以提前知道:

這些多出來的開銷,實際上是因為存在重複的字串消耗。

剔除重複的字串例項

既然我們懷疑多出來的開銷是重複的字串,那麼我們就可以考慮通過將它們轉為同一個物件的方式,減少字典中重複的字串。

所以,我們就有了下面這個版本的測試程式碼:

[Test]
[DotMemoryUnit(FailIfRunWithoutSupport = false)]
public async Task LoadFromDbAsync()
{
    var beforeStart = dotMemory.Check();
    var dict = new Dictionary<int, ProductInfo>(HelperTest.ProductCount);
    await DbReadingTest.LoadCoreAsync(dict);
    foreach (var (_, p) in dict)
    {
        var colorIndex = HelperTest.Colors.BinarySearch(p.Color);
        var color = HelperTest.Colors[colorIndex];
        p.Color = color;
    }
    GC.Collect();
    dotMemory.Check(memory =>
    {
        var snapshotDifference = memory.GetDifference(beforeStart);
        Console.WriteLine(snapshotDifference.GetNewObjects().SizeInBytes.Bytes());
    });
}

以上程式碼:

  • 我們仍然從資料庫載入所有的資料到字典中,載入的程式碼和先前完全一樣,因此沒有展示
  • 載入之後,我們再次遍歷字典。並且從早在第一個版本就存在的 Color List 搜尋到對應的字串例項,並且賦值給字典中的 Color
  • 通過這樣一搜,一讀,一換。我們使得字典中的 Color 全部來自 Color List

於是,我們再次執行 dotMemory 進行度量,結果非常的 Amazing:

61.69 MB
雖說,最終這個數字的開銷對比,第一個版本略有上升,但其實已經到了相差無幾的地步。

我們通過將相同字串轉為相同例項的方式,將字典中的相同 Color 轉為了相同例項。而 30MB 的臨時字串則會由於沒有物件引用它們,因此在最近的一次 GC 中會被立即回收,一切都是這樣的輕鬆愉快。

直接引入 StringPool

前文我們已經找到了開銷的原因,並且通過辦法進行了優化。不過還存在一些問題實際上要考慮:

  • 很多時候 Color List 並不是靜態的列表,她可能早上還很開心,下午就生氣了
  • Color List 不可能無限大,我們需要一個淘汰演算法,淘汰末尾的 10%,把他們輸送給社會

因此,我們可以考慮直接使用 StringPool,別人寫的程式碼很棒,現在是我們的了。

讓我們再引入一些無關緊要的包:

<ItemGroup>
    <PackageReference Include="Microsoft.Toolkit.HighPerformance" Version="7.0.2" />
</ItemGroup>

稍微改了一下,就有了新的版本:

[Test]
[DotMemoryUnit(FailIfRunWithoutSupport = false)]
public async Task LoadFromDbAsync()
{
    var beforeStart = dotMemory.Check();
    var dict = new Dictionary<int, ProductInfo>(HelperTest.ProductCount);
    await DbReadingTest.LoadCoreAsync(dict);
    var stringPool = StringPool.Shared;
    foreach (var (_, p) in dict)
    {
        p.Color = stringPool.GetOrAdd(p.Color);
    }
    GC.Collect();
    dotMemory.Check(memory =>
    {
        var snapshotDifference = memory.GetDifference(beforeStart);
        Console.WriteLine(snapshotDifference.GetNewObjects().SizeInBytes.Bytes());
    });
}

以上程式碼:

  • 使用了 StringPool.Shared 例項儲存字串例項
  • GetOrAdd 實際上就是實現了我們先前的一搜,一讀,一換三步走戰略

當然,結果也是毫無驚喜可言的驚喜:

61.81 MB
一切就是這樣的輕鬆愉快。

image.png

延伸閱讀

StringPool 和 string.Intern() 有什麼異同?

它們都是為了解決重複字串例項過多,導致浪費記憶體的情況。

效果上的區別,主要是生存期的區別。string.Intern 是終生制的,一旦加入只要程式不重啟,就會一直存在。這和 StringPool 很不一樣。

因此,如果你有生存期上的考慮,請斟酌選擇。

string.Intern 可以參閱:

https://docs.microsoft.com/do...

StringPool是怎麼實現的?

我們也不懂,我們也不敢亂說。總的來說是一個帶有使用計數標記的優先佇列。原始碼我們也讀不懂。

前面的區域,就交給你探索吧:

https://github.com/CommunityT...

我該在什麼情況下考慮使用StringPool?

筆者建議,考慮這些字串入池:

  1. 這個字串可能被很多例項引用
  2. 這個字串需要長期駐留,或者持有它的物件,是長期物件
  3. 記憶體優化確實已經成為你要考慮的事情了

當然,其實存在一個最容易判斷的依據。你可以直接把產線上的記憶體 dump 下來,檢視裡面是否存在很多重複的字串,然後優化他們。現在已經是 2021 年了,不會還有人不會 dump 記憶體吧,不會吧,不會吧?(手動狗頭 如果你還不會 dump 記憶體,那麼可以參閱黃老師在微軟 Reactor 上分享的視訊進行學習:

https://www.bilibili.com/vide...
好耶!我可以用 StringPool 來儲存列舉的 DisplayName

確實,也沒有什麼錯。不過,其實還有更好的一些方案:

https://github.com/Spinnernic...

總結

dotMemory 度量還有更多姿勢,你可以多多嘗試。

重複,池化。這是一種非常常見的優化方案。掌握它們,在你需要的時候,這或許就幫到了你。

本篇文章中程式碼例項,可以在以下地址找到,不要忘記為專案 star 喲:

https://github.com/newbe36524...

微軟最有價值專家(MVP)

image.png

微軟最有價值專家是微軟公司授予第三方技術專業人士的一個全球獎項。28年來,世界各地的技術社群領導者,因其線上上和線下的技術社群中分享專業知識和經驗而獲得此獎項。

MVP是經過嚴格挑選的專家團隊,他們代表著技術最精湛且最具智慧的人,是對社群投入極大的熱情並樂於助人的專家。MVP致力於通過演講、論壇問答、建立網站、撰寫部落格、分享視訊、開源專案、組織會議等方式來幫助他人,並最大程度地幫助微軟技術社群使用者使用Microsoft技術。
更多詳情請登入官方網站:
https://mvp.microsoft.com/zh-cn


掃碼關注微軟中國MSDN,獲取更多微軟一手技術資訊和官方學習資料!
image.png

相關文章