通过减少内存使用改善.NET 性能

  • Jonathan Allen
  • 孙浩

2017 年 12 月 13 日

话题:.NET性能调优语言 & 开发

对.NET 的性能调优来说,我们有一个普遍被误解的观念:规避内存分配的重要性。人们认为,由于内存分配是快速的,因此很少会对性能产生影响。

要理解导致这种误解的原因,我们必须回到在 C++ 和 Visual Basic 4 到 6 中所看到的 COM 编程时代。对于 COM,内存是使用引用计数形式的垃圾回收器进行管理的。每当将一个对象分配给一个引用变量时,就会增加一个隐藏的计数器。如果变量被重新分配或从作用域退出,计数器就会被取消。如果计数器达到 0,对象就会被删除,将内存释放到其他地方。

这种内存管理系统是“确定的”。通过仔细分析,你可以确定何时删除一个对象。这意味着你可以自动释放数据库连接等资源。而对于.NET 而言,你需要一个单独的机制 (例如,销毁 / 启用) 以确保非内存资源能够及时地被释放。

引用计数垃圾收集器有三个主要的缺点。首先,它们容易受到“循环引用”的影响。如果两个对象相互引用,即使是间接的,那么引用计数也不可能降为 0,这便会导致内存泄漏的发生。我们必须小心地编写代码,要么避免循环引用,要么提供某种解构方法以便在当对象不再需要时中断循环。

工作在多线程环境中时会遇到另一个主要的缺点。为了避免竞态条件,某种类型的锁机制 (例如: 锁住、增量、旋锁等) 需要确保重新计数仍然是正确的。这些操作出奇的昂贵。

最后,可用内存位置的列表可能会变成碎片化的,在活动对象之间会产生许多小的、不可用的空间。内存分配通常涉及到遍历一个有空闲空间的连续链表,以便寻找到一个足够大的位置来满足需求对象。(内存碎片在.NET 中也存在于“大对象堆”或“LOH”。)

相比之下,像.NET 或 Java 那样将内存分配到一个“标记 - 清扫”形式的垃圾回收器中,便是一个简单的指针增量机制。赋值并不比分配一个整数更昂贵。只有当 GC 实际运行时,才会支付实际成本,而且通常通过使用分代收集器来缓解这种情况。

当.NET 刚出现的时候,许多人抱怨.NET 的垃圾回收器不确定性的表现将会损害性能并且难以解释。当时微软的反驳是,对于大多数用例来说,尽管间歇的 GC 会暂停,但“标记 - 清扫”的垃圾回收器实际上会更快。

不幸的是,随着时间的推移,这条信息变得有些混乱。即使我们接受这样一种理论,即“标记 - 清扫”的垃圾回收器速度比引用计数更快,但这并不意味着它在绝对意义上是必须的。内存分配和相关的内存压力通常是很难检测性能问题的原因。

而且,使用的内存越多,CPU 缓存的效率就越低。虽然主 RAM 很大,以至于在大多数用例中几乎不会使用到基于磁盘的虚拟内存,但是相比之下,CPU 中的缓存是很小的。从 RAM 中填充 CPU 缓存所需的时间可能会占用数十甚至数百个 CPU 周期。

在最近的一篇文章中,Frans Bouma 确定了几种优化内存使用的技术。虽然他着重关注改善 ORM 性能,但这些建议在各种情况下都很有用。他的这些建议包括:

避免参数数组

参数关键字是有用处的,但与普通的函数调用相比要昂贵,因为它需要内存分配。API 应该为常用的参数计算提供无参数重载。

还应该提供一个 IEnumerable<T> 或者 IList<T> 的重载,这样集合在调用函数之前就不需要多此一举的被复制到一个数组中了。

如果定义之后立即添加数据,可先预定义数据结构的大小

List<T> 或其他集合类可以在被填充时多次调整大小。每次调整大小的操作都会分配另一个内部数组,并由前一个数组填充。你可以通过为集合的构造函数提供一个容量参数来避免这种开销。

惰性的初始化成员

如果你知道一个给定对象在大多数情况下是不需要的,那么你应该使用延迟初始化来避免过早地分配内存给它。通常这是手动完成的,因为 Lazy<T> 类本身需要分配内存。

早在 2011 年,我们就曾报道过微软试图通过使用类似技术来减少任务的规模。他们的报告显示,在创建一个 <int32> 任务的时候花费的时间减少了 49% 到 55%,所需空间大小减少了 52%。

查看英文原文Improving .NET Performance by Reducing Memory Usage


感谢冬雨对本文的审校。

给 InfoQ 中文站投稿或者参与内容翻译工作,请邮件至editors@cn.infoq.com。也欢迎大家通过新浪微博(@InfoQ@丁晓昀),微信(微信号:InfoQChina)关注我们。

.NET性能调优语言 & 开发