修复内存问题
如果内存分配对任何程序都至关重要,为什么 DPA 将其用作问题指标? 问题不在于内存分配,内存分配在性能方面非常廉价,而在于垃圾回收 (GC),它可能需要大量的系统资源。 分配的内存越多,将来需要回收的也就越多。 有关 .NET 如何管理内存的快速提醒,请参阅 JetBrains dotMemory 文档。
DPA 检测与 闭包、 分配到小对象堆和 分配到大对象堆相关的内存问题。 这些问题被分组在 内存分配 选项卡中,位于 Dynamic Program Analysis 窗口中。

在本章中,您将找到可能导致此类问题的代码设计示例以及如何修复这些问题的建议。
此外,我们强烈建议您将 Heap Allocations Viewer 插件与 DPA 一起使用。 该插件会突出显示代码中所有分配内存的位置。 它与 DPA 完美匹配——两者结合堪称绝配。 插件显示分配并描述其发生原因,而 DPA 显示某个特定分配是否确实是一个问题。

闭包对象
- 它是什么?
当您将某些上下文传递给 lambda 表达式 或 LINQ 查询 时,会发生闭包。 为了处理闭包,编译器会创建一个特殊的类和一个委托方法:
<>c__DisplayClass...和Func<...>。 请注意,每次调用 LINQ 时,它还会生成一个枚举器。- 如何找到它?
DPA 将
<>c__DisplayClass...的分配标记为 闭包 问题,而Func<...>的分配标记为 小对象堆 问题。默认阈值为 50 MB。
- 如何修复它?
通常,您可以重写代码,使得 上下文不传递给 lambda。 对于 LINQ,唯一的 解决方案是不使用它们。
lambda 表达式中的闭包
Lambda 表达式是 .NET 中一个非常强大的功能,在某些情况下可以显著简化您的代码。 不幸的是,如果使用不当,lambda 会显著影响应用程序性能。
请看以下示例。 这里,我们有一个 StringUtils 类,其中包含一个 筛选 方法,用于对字符串列表应用过滤器:
我们希望使用此方法过滤掉长度超过某个指定值的字符串。 我们将使用 lambda 将此过滤器传递给条件参数。 请注意,编译后的代码将取决于 lambda 是否包含闭包。 闭包 是传递给 lambda 的上下文,例如,在调用 lambda 的方法中声明的局部变量。 在我们的例子中,上下文是所需的字符串长度。
使用过滤器的最明显方法是将字符串长度作为参数传递: length。
如果您使用 JetBrains dotPeek 之类的工具反编译上述代码,您会看到:
编译器创建了
<>c__DisplayClass0_0类,该类将 lambda 存储在b__0方法中。当您运行包含 lambda 的方法时,首先会创建一个新的
<>c__DisplayClass0_0类实例。然后,通过创建一个
Func<>委托来调用 lambda:new Func<string, bool>((object) cDisplayClass00, __methodptr(b__0))).Count)
每次调用 lambda 时,都会分配新的类实例和新的委托实例。 由 lambda 引起的分配是第 2 点和第 3 点的总和。 如果 lambda 位于热点路径(被频繁调用),将会分配大量内存。
DPA 如何显示此内容
让我们调用包含 lambda 的方法 10000 次。 DPA 检测到 <>c__DisplayClass0_0 类的创建(上述 列表中的第 1 点),并将这些分配标记为 闭包 问题。 请注意,在闭包的情况下,DPA 会将方法的第一个开括号标记为问题。

问题如下所示(<>c__DisplayClass0_0 类型的分配):

目前,lambda 委托的分配(上述 列表中的第 2 点)未被标记为 闭包 ,而是标记为 小对象堆 问题:

问题如下所示(Func<...> 类型的分配):

修正方法
使用 lambda 的主要策略是避免闭包。 在这种情况下,特殊的 <>c__DisplayClass0_0 类的实例将被缓存,并且创建的委托将被设为静态。 因此,不会有额外的内存分配。 因此,对于我们的示例,一个解决方案是将参数 length 传递给 筛选 方法,而不是传递给 lambda。 修复后的代码如下所示:
如果我们现在运行项目并检查 DPA 结果,我们会看到没有检测到闭包:既没有创建 <>c__DisplayClass0_0 实例,也没有创建 Func<> 实例。


用方法组代替 lambda
早在 2006 年,C# 2.0 引入了“方法组转换”功能,这简化了将方法分配给委托时使用的语法。 在我们的例子中,这意味着我们可以用一个方法替换 lambda,然后将此方法作为参数传递。
这里没有闭包,那么这种方法会产生额外的内存分配吗? 不幸的是,会的。 如果我们查看 DPA 分析结果,会发现每次调用 FilterLongString() 时,该方法都会创建一个新的 Func<string, bool> 实例。 调用 10000 次时,它将生成 10000 个实例。


因此,在使用方法组时,请像使用带闭包的 lambda 一样谨慎。 如果方法组位于热点路径,它们可能会生成大量的内存流量。
LINQ 查询中的闭包
LINQ 查询和 lambda 表达式的概念紧密相关,并且在“底层”具有非常相似的实现。 这意味着我们针对 lambda 讨论的所有问题同样适用于 LINQ。 如果您的 LINQ 查询包含闭包,编译器将不会缓存相应的委托。 例如:
DPA 如何显示此内容
由于 阈值 参数被查询捕获,其委托(Func<String, Boolean> )以及相应的 <>c__DisplayClass0_0 实例将在每次调用方法时创建。 如果我们调用 GetLongNames() 10000 次,DPA 将检测到相应的问题:

LINQ 查询中的枚举器分配
不幸的是,在使用 LINQ 时还有一个需要避免的陷阱。 任何 LINQ 查询(与其他查询一样)都假定对某些数据集合进行迭代,而这反过来又假定创建一个迭代器。 接下来的推理链应该已经很熟悉了:如果这个 LINQ 查询位于热点路径,那么迭代器的持续分配将生成大量的内存流量。 让我们更改前面的示例,使其不包含闭包:
如果我们调用 GetLongNames 10000 次,仍然会有内存分配:

Dynamic Program Analysis 窗口将显示以下类型的问题: 小对象堆。

识别此类问题非常容易:总有一个类的名称中包含 Iterator。
修正方法
不幸的是,这里的唯一答案是不要在热点路径上使用 LINQ 查询。 在大多数情况下,可以用 foreach 替换 LINQ 查询。 在我们的示例中,一个既不生成闭包也不生成枚举器分配的通用修复可能如下所示:
小对象堆
此问题类型的替代名称是“未分类”。 严格来说,它包括所有由应用程序产生且超过 小对象堆 阈值的内存分配。 在未来的版本中,我们将引入更多的内存分配检查。 这意味着来自 小对象堆 的许多问题将获得其特定的问题类型。 目前,唯一的建议是手动检查 小对象堆 类型的问题,看它们是否具有本页描述的模式。
默认阈值为 100 MB。
装箱
装箱是将值类型转换为引用类型。 例如:
问题在于值类型存储在栈上,而引用类型存储在托管堆中。 这会对性能产生两方面的影响:
要将整数值分配给字符串,CLR 必须从栈中取出该值并将其复制到堆中。
存储在托管堆中的对象(在我们的例子中是字符串)会被垃圾回收。
DPA 如何显示此内容
使用 DPA 检测装箱是一个简单的任务。 在问题列表中,检查分配的类型。 如果是值类型,那么它肯定是装箱的结果。 例如,让我们调用上述代码 10000 次。 问题显示分配的类型是 int32:

在编辑器中,Heap Allocations Viewer 也会警告装箱分配:

修正方法
通常,您可以重写代码以避免装箱。 在我们的示例中,简单的修复是调用 ToString() 方法:
用类代替结构体
装箱 的另一个示例与 结构体 类型相关。 如果一个类型表示单个值,可以将其定义为 结构体。 将其定义为 class (这是一个常见错误)会使其成为引用类型。 因此,其实例被放置在托管堆中而不是栈上,结果是不再需要的实例必须被垃圾回收。
例如,我们有一个 帐户 类型,它由一个 enum 字段和一个 整数 属性组成。 尽管我们可以将该类型定义为 结构体 ,但我们错误地使用了 class:
DPA 如何显示此内容
不幸的是,这个问题没有提示。 在 DPA 中,您只会看到代码分配了一些引用类型的实例。 因此,它可能只是一个“正常”的分配,进入了小对象堆或大对象堆。 假设我们创建了 10000 个账户:

在编辑器中,这看起来如下:

修正方法
修复方法很明显:将类型定义为 结构体 而不是 class。 在我们的示例中,它将是:
调整集合大小
动态大小的集合(如 词典、 列表、 HashSet 和 StringBuilder )具有以下特点:当集合大小超过当前边界时,CLR 会调整集合大小并在内存中重新定义整个集合。 显然,如果这种情况频繁发生,您的应用程序性能将受到影响。
例如,我们有一些代码向列表中添加一个元素:
DPA 如何显示此内容
动态集合的内部可以在托管堆中看到,作为值类型的数组(例如, Int32[] 在 词典 的情况下)或 字符串[] 类型的数组(在 列表 的情况下)。 因此,在问题列表中找到此类分配是主要提示。

在编辑器中,主要提示是此数组是间接分配的:不是通过 new 关键字,而是通过某些其他方法:

请注意, var list = new List<int>(); 行没有进行分配,因为它定义了一个没有元素的空列表。
修正方法
如果“调整大小”方法引起的流量显著,唯一的解决方案是减少需要调整大小的情况数量。 尝试预测所需的大小,并用该大小或更大的值初始化集合。 另一种策略是分配足够的内存量,并使用 Truncate() 进一步截断。
在我们的示例中,修复可能如下所示:
枚举集合
在处理动态集合时,请注意枚举它们的方式。 这里的典型主要问题是仅知道集合实现了 IEnumerable 接口时,使用 foreach 枚举集合。 请查看以下示例:
在 EnumerateCollection() 方法中,列表被强制转换为 IEnumerable 接口,这意味着枚举器会进一步发生 装箱。
DPA 如何显示此内容
您应该检查 Enumerator<> 类型的分配问题。

在编辑器中,您可以在枚举集合的代码位置看到这些分配。

请注意,这同样适用于数组。 唯一的区别是,您应该检查 System.SZGenericArrayEnumerator<> 类型的分配问题:

修正方法
避免将集合强制转换为接口。 在我们上面的示例中,最佳解决方案是创建一个接受 List<string> 集合的 EnumerateCollection() 方法重载。
更改字符串内容
字符串 是一种不可变类型,这意味着字符串对象的内容无法更改。 当您修改字符串内容时,会创建一个新的 字符串 对象。 这一事实是字符串导致性能问题的主要原因。 您更改字符串内容的次数越多,分配的内存就越多。 这反过来会触发垃圾回收,从而影响应用程序性能。 直接的解决方法是优化代码以尽量减少新 字符串 对象的创建。
请看一个反转字符串函数的示例:
DPA 如何显示此内容
DPA 对此问题没有额外提示:您只会看到分配到小对象堆或大对象堆。

修正方法
在大多数情况下,修复方法是使用 StringBuilder 类,或者将字符串作为字符数组处理并使用特定的数组方法。 在我们的示例中,代码可能如下所示:
大对象堆
.NET 中应用的一种性能技巧源于垃圾回收器不仅需要移除未使用的对象,还需要压缩托管堆。 压缩是通过简单的复制完成的,这会带来额外的性能开销。 研究表明,如果复制的对象大于 85 KB,这些开销会超过堆压缩的好处。 因此,所有此类对象都被放置在托管堆的一个单独段中,称为大对象堆 (LOH)。 LOH 中存活的对象不会被压缩(尽管您可以在完整垃圾回收期间强制垃圾回收器压缩 LOH)。 这有两个缺点:
您的应用程序随着时间的推移会消耗越来越多的内存:LOH 会变得碎片化。
性能开销:与 LOH 的交互比与小对象堆更复杂。
DPA 如何显示此内容
DPA 仅跟踪所有分配到 LOH 的情况:它不会区分这些分配的原因。 与分配到小对象堆一样,分配到 LOH 本身并不意味着什么:这些分配可能是当前用例所需的。
默认阈值为 50 MB。
分配到 LOH 的情况被标记为 大对象堆 问题:

修正方法
确保分配到 LOH 是不可避免的或确实需要的。 在某些情况下,将对象分配到 LOH 是有意义的,例如,在必须持续整个应用程序生命周期的大型集合(例如缓存)的情况下。