字符串池化,減少重復實例,內存降低,一切就是這樣的輕松愉快。
開篇摘要
本文通過一個簡單的業務場景,來描述如何通過字符串池化來減少內存中的重復字符串實例,從而減少內存的占用。
在業務中,我們假設如下:
- 有一百萬個商品,每個商品都有一個 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/dotnet/api/system.collections.generic.dictionary-2.-ctor?view=net-5.0&WT.mc_id=DX-MVP-5003606#System_Collections_Generic_Dictionary_2__ctor_System_Int32_
然后,我們引入 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()來捕捉當前內存的快照,以便后續進行對比 - 字典創建完畢后,我們比對前后兩次檢查點中新增的對象的大小。
最后,點擊如下圖所示的按鈕,運行這個測試:
run dotMemory
那么,就會到的如下這樣的結果:
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
一切就是這樣的輕松愉快。
diff
延伸閱讀
StringPool 和 string.Intern() 有什么異同?
它們都是為了解決重復字符串實例過多,導致浪費內存的情況。
效果上的區別,主要是生存期的區別。 string.Intern 是終生制的,一旦加入只要程序不重啟,就會一直存在。這和 StringPool 很不一樣。
因此,如果你有生存期上的考慮,請斟酌選擇。
string.Intern 可以參閱 https://docs.microsoft.com/dotnet/api/system.string.intern?view=net-5.0&WT.mc_id=DX-MVP-5003606
StringPool 是怎么實現的?
咱也不懂,咱也不敢亂說。總的來說是一個帶有使用計數標記的優先隊列。源代碼咱也讀不懂。
前面的區域,就交給你探索吧:
我該在什么情況下考慮使用 StringPool?
筆者建議,考慮這些字符串入池:
- 這個字符串可能被很多實例引用
- 這個字符串需要長期駐留,或者持有它的對象,是長期對象
- 內存優化確實已經成為你要考慮的事情了
當然,其實存在一個最容易判斷的依據。你可以直接把產線上的內存 dump 下來,查看里面是否存在很多重復的字符串,然后優化他們。 現在已經是 2021 年了,不會還有人不會 dump 內存吧,不會吧,不會吧?(手動狗頭 如果你還不會 dump 內存,那么可以參閱黃老師在微軟 Reactor 上分享的視頻進行學習: https://www.bilibili.com/video/BV1jZ4y1P7EY
好耶!我可以用 StringPool 來存儲枚舉的 DisplayName
確實,也沒有什么錯。不過,其實還有更好的一些方案:
https://github.com/Spinnernicholas/EnumFastToStringDotNet
本篇小結
dotMemory 度量還有更多姿勢,你可以多多嘗試。
重復,池化。這是一種非常常見的優化方案。掌握它們,在你需要的時候,這或許就幫到了你。
本篇文章中代碼實例,可以在以下地址找到,不要忘記為項目 star 喲:
https://github.com/newbe36524/Newbe.Demo/tree/main/src/BlogDemos/Newbe.StringPools

字符串池化,減少重復實例,內存降低,一切就是這樣的輕松愉快。
