共计 7058 个字符,预计需要花费 18 分钟才能阅读完成。
大家好,我是本期的 MVP 实验室研究员:俞坤,明天我将联合一些具体样例来向大家介绍如何通过池化字符串以缩小.Net 内存占用,并且学习如何观测内存等一些周边常识。筹备好了吗?这就上路。
微软 MVP 实验室研究员
本文通过一个简略的业务场景,来形容如何通过字符串池化来缩小内存中的反复字符串实例,从而缩小内存的占用。
在业务中,咱们假如如下:
- 有一百万个商品,每个商品都有一个 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() 来捕获以后内存的快照,以便后续进行比照
- 字典创立结束后,咱们比对前后两次查看点中新增的对象的大小。
最初,点击如下图所示的按钮,运行这个测试:
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
所有就是这样的轻松愉快。
延长浏览
StringPool 和 string.Intern() 有什么异同?
它们都是为了解决反复字符串实例过多,导致节约内存的状况。
成果上的区别,次要是生存期的区别。string.Intern 是终生制的,一旦退出只有程序不重启,就会始终存在。这和 StringPool 很不一样。
因而,如果你有生存期上的思考,请斟酌抉择。
string.Intern 能够参阅:
https://docs.microsoft.com/do…
StringPool 是怎么实现的?
咱也不懂,咱也不敢乱说。总的来说是一个带有应用计数标记的优先队列。源代码咱也读不懂。
后面的区域,就交给你摸索吧:
https://github.com/CommunityT…
我该在什么状况下思考应用 StringPool?
笔者倡议,思考这些字符串入池:
- 这个字符串可能被很多实例援用
- 这个字符串须要长期驻留,或者持有它的对象,是长期对象
- 内存优化的确曾经成为你要思考的事件了
当然,其实存在一个最容易判断的根据。你能够间接把产线上的内存 dump 下来,查看外面是否存在很多反复的字符串,而后优化他们。当初曾经是 2021 年了,不会还有人不会 dump 内存吧,不会吧,不会吧?(手动狗头 如果你还不会 dump 内存,那么能够参阅黄老师在微软 Reactor 上分享的视频进行学习:
https://www.bilibili.com/vide…
好耶!我能够用 StringPool 来存储枚举的 DisplayName
的确,也没有什么错。不过,其实还有更好的一些计划:
https://github.com/Spinnernic…
总结
dotMemory 度量还有更多姿态,你能够多多尝试。
反复,池化。这是一种十分常见的优化计划。把握它们,在你须要的时候,这或者就帮到了你。
本篇文章中代码实例,能够在以下地址找到,不要遗记为我的项目 star 哟:
https://github.com/newbe36524…
微软最有价值专家(MVP)
微软最有价值专家是微软公司授予第三方技术专业人士的一个寰球奖项。28 年来,世界各地的技术社区领导者,因其在线上和线下的技术社区中分享专业知识和教训而取得此奖项。
MVP 是通过严格筛选的专家团队,他们代表着技术最精湛且最具智慧的人,是对社区投入极大的激情并乐于助人的专家。MVP 致力于通过演讲、论坛问答、创立网站、撰写博客、分享视频、开源我的项目、组织会议等形式来帮忙别人,并最大水平地帮忙微软技术社区用户应用 Microsoft 技术。
更多详情请登录官方网站:
https://mvp.microsoft.com/zh-cn
扫码关注微软中国 MSDN,获取更多微软一手技术信息和官网学习材料!