是否遇到过 OutOfMemory 异常?





0/5 (0投票)
查找和修复有问题的代码可能是一项耗时且令人沮丧的任务。Florian Standhartinger最近就遇到了这样的经历,他分享了如何使用内存分析工具来解决问题。阅读完整故事。
快速阅读者序言
我知道人们通常没时间阅读完整的文章,所以你只需向下滚动并仅阅读标题和直接放在图片中的红色注释,就能对我说的话有一个非常快速的概述。再加上底部的结论,这应该会给你一个大致但可理解的轮廓,说明我在整篇文章中涵盖了哪些内容。当然,我热烈欢迎你阅读整篇文章——我保证它并不长。
引言
我认为任何在.NET中遇到过OutOfMemory问题的人都会知道,查找和修复有问题的代码是一项耗时且令人沮丧的任务。我最近也经历过这种不愉快的经历,我不得不自学如何使用内存分析工具来解决问题。我想在这里分享我学到的关于解决.NET应用程序中与内存相关的问题的知识,通过描述一个我借助ANTS Memory Profiler解决的真实案例。

问题发生在我们的产品的开发版本中,这是一个在德国广告行业广泛使用的成功的广告管理系统。几周前,在一些内部测试中,我们注意到在我们最新的构建中,导入功能在使用较大的数据源时会导致OutOfMemory Exceptions。最初计划为半天的bug修复,但解决这个问题最终花了大约一周的时间(但至少我们从中了解了很多关于.NET内存使用的情况)。在我们继续之前,作为对.NET内存管理的简要介绍,请记住两个简单的规则:
- 任何被其他对象引用的对象都将保留在内存中,并占用RAM中的一些空间。
- 要摆脱对象并收回占用的RAM,你需要确保没有人再引用你的对象。实现这一点的最常见方法是将持有对象引用的字段或属性设置为“null”。
关于我们在项目中使用的技术
要理解这个例子,可能需要了解一些我们使用的技术,所以我将尽量保持简短和简单。
1 – “CAB”架构指南
整个项目在架构上围绕着Composite Application Block构建,这是Microsoft Patterns and Practices Team推荐的架构框架。这基本上意味着有一个WinForms应用程序(称为“Shell”),它将我们应用程序的所有部分作为插件托管。由于Composite Application Block的模块化结构,我们很容易将逻辑与GUI代码分离,并重用组件。
2 – “XPO”对象关系映射器
为了避免我们编写所有小的SQL语句来存储和检索数据到数据库,我们使用DevExpress的XPO O/R-Mapper。举个XPO数据库映射的快速例子,假设我们需要检索存储在我们数据库中的所有客户;我们只需调用XPO并要求它为我们创建一个客户列表。
var uow = new UnitOfWork(); var customers = new XPCollection<Customer>();
XPO然后会创建一个SQL语句来查询数据库中的Customers表,并将其内部缓存(存储在UnitOfWork对象中)填充Customer对象。然后它将这些对象传递给我们,以便我们可以对它们进行我们想要的任何操作。同样,反之亦也成立。
var uow = new UnitOfWork(); var newCustomer = new Customer(uow) { Name = "Florian" }; uow.CommitChanges();
通过调用Customer数据库映射类的构造函数,可以在数据库中创建新行,并在调用UnitOfWork提交更改后,新创建的客户将被发送到数据库。值得注意的是,XPO从数据库检索的对象,或者存储在内存中以最终提交到数据库的对象,都存储在UnitOfWork中。因此,UnitOfWork在内存使用方面会相当“沉重”,因为它存储了大量数据库对象的引用!
3 – 导入引擎
导致内存问题的导入功能包括一个相对常用的导入引擎和几个不同的导入插件。分工相当直观,导入引擎负责处理诸如匹配现有数据库对象以避免重复,以及管理数据库中的更新或插入等事务。几个导入插件提供从各种源读取数据并将其转换为XPO可以映射到我们数据库的对象。

问题
可以想象,导致OutOfMemory异常的导入功能创建了大量对象,并指示XPO将它们存储到数据库中。这就是为什么我们决定在导入过程中不时地清理UnitOfWork数据容器。我的意思是:比如说,每导入100个对象,我们就丢弃UnitOfWork及其包含的所有数据库对象,然后用一个新的(空的)替换它。

不幸的是,我们不得不承认,即使在导入了只有几百个对象后,可用的RAM仍然被填满,我们仍然收到OutOfMemory Exception。
缩小问题范围
以下是解决问题所需的步骤:
为了排除任何可能分散我注意力于真正问题的因素,我尝试简化要分析的应用程序,并且只运行与当前问题直接相关的代码。因此,我将导入功能从我们的主应用程序中提取出来,并在一个控制台应用程序中调用相同的代码。为了进一步减少可能隐藏问题的代码行数,我用一个简单的虚拟插件替换了实际的导入插件,该插件实例化随机生成的对象。

运行这个简单的程序并发现它仍然导致内存不足的异常,这确保了问题一定在导入引擎的某个地方。顺便说一句,由于分析总是会明显减慢应用程序的速度,将分析的代码减少到最少也使等待结果变得更加可以忍受。
我们在哪里可以对内存使用有清晰的期望,并获取内存快照?
在这种情况下,导入引擎中有一个中央循环,它在处理导入时迭代数据源中的所有对象。在这个循环中,我们不时地替换UnitOfWork以保持内存占用量,我发现替换之后的行是使用分析器拍摄内存快照的好位置。这是一个我可以对内存占用情况有清晰期望的地方,并且不同循环迭代之间的内存占用差异可能是导致内存泄漏的线索。
因此,我插入了一个消息框,以便在方便的时候提醒我进行分析快照,代码看起来大致如此(简化后):
int i = 0; foreach(var objectToImport in m_importPlugin.GetRecords()) { //... do stuff like matching with existing records to avoid duplicate records and //copy records into the database DoMatching(); CopyToDatabase(objectToImport); //replace unitofwork objects to get rid of the objects we don’t want to be referenced //any more if (i++ == 100) { ReplaceUnitOfWorks(); //*********** EVERY TIME WE HIT THE FOLLOWING LINE WE SHOULD HAVE THE SAME //MEMORY OCCUPATION MessageBox.Show("Now take memory profiler snapshot"); } }
运行分析器并拍摄快照
我使用了ANTS Memory Profiler来查找内存泄漏,主要是因为目前它似乎是唯一能够显示将对象保留在内存中的引用作为可视化树的内存分析器。与大多数其他知名分析器相比,我还发现它是唯一一个能够让我以可容忍的时间和可接受的额外内存消耗来分析一个相当大的程序。ANTS Memory Profiler带有方便的Visual Studio集成,因此您可以直接从IDE启动它。
在运行这段精简的代码时,我等到我的警报弹出,拍摄内存快照,清除警报,然后等待下一个,这样我就有了两个内存快照可以进行比较。
通过检查内存使用增长最多的数据类型来比较快照
正如标题所示,我开始搜索问题,查看内存使用增长最快的数据类型。

一个名为RBTree<K>+Node<int>[]的神秘类似乎在我的上次快照后内存使用增长最多,我想更多地了解这些占用我宝贵内存的类型的实例,所以我点击了Instance List按钮。

考虑到我触发第一个和第二个快照的代码位置,我非常确定我不希望在这两个快照之间有任何东西在内存中大量增长。
我假设即使有很多RBTree<K>-Node<int>[]对象暂时存活在我的RAM中是可以的,但它们至少应该最终消失,因为我的主循环在“要导入的对象”可枚举列表的末尾处迭代。如果它们直到循环结束都没有消失,并且一直在增长,那么这意味着一个OutOfMemory异常迟早会发生在一个非常长的循环中。
因此,我对在我第一次快照时就已经存在,并且在第二个快照时仍然在内存中的对象特别感兴趣。为了知道是什么一直在保留这些棘手的对象,我检查了其中一个对象在Object Retention Graph中的状态。

对象保留图(图9)显示了谁引用了一个对象,从而将其“保留”了下来;最底部是你的对象,你可以看到谁引用它堆叠在它上面。
在我们的例子中,我们可以从图中看到,有一个类型为Dictionary<Session, ProviderType>的私有静态字段(注意:Session是UnitOfWork的基类)。该字典一直将一个UnitOfWork对象保留在内存中(记住:我们试图不时地通过用新的替换旧的UnitOfWork对象来摆脱它们),然后该UnitOfWork又通过几个引用将RBTree<K>+Node<int>[]对象保留在内存中。
查找保留对象在内存中的不当引用

我实际上在很久以前就引入了那个Dictionary字段,用于一些缓存原因,以进行性能优化。显然,我在替换UnitOfWork时忘记清空Dictionary,因此造成了一个内存泄漏,它一直保留着我所有的UnitOfWork对象,而我本以为它们会被替换掉。为了测试我的理论,我切换到ANTS分析器中的Class List视图,并按UnitOfWork类型进行筛选。

正如预期的那样,存活的UnitOfWork对象数量远比应该的多。
修复代码
我需要做的就是向ReplaceUnitOfWorks()方法中引入一些代码来重置正在保留我的UnitOfWork对象的Dictionary。
public void ReplaceUnitOfWorks() { //HERE IS THE FIX: ExtKey.ClearFrozenContextDictionary(); //HERE IS THE CODE THAT HAS ALREADY BEEN THERE: this.UnitOfWork = new UnitOfWork(); } ... //Somewhere else: public class ExtKey { public static void ClearFrozenContextDictionary() { m_frozenProviderContexts = new Dictionary<Session, ProviderType>(); } }
再次运行分析器以确保问题已解决
在构建修复后的代码并重复分析过程后,我发现第二个内存快照中存活的UnitOfWork对象的数量减少到只有一个实例。此外,内存使用随时间的变化(可以在ANTS Memory Profiler的顶部图表中看到)与我早期分析会话中的图表相比,看起来更加稳定。

尤里卡!内存问题解决了!
结论
如果你的.NET应用程序有数千行代码,如果你没有使用合适的工具,查找内存泄漏可能是一项非常困难的任务。我认为,你很可能会最终修复并非真正有问题的方法,并引入任何人都不需要的临时解决方案。
提示和良好实践
以下是我在处理这个内存问题时收集到的一些提示和最佳实践。它们都是我个人的观点,所以如果你不同意,请随时发表评论。
- 让“编写健壮、可读且优雅的代码”成为编码过程中的重要指令。当然比关于微小性能和内存优化的假设更重要。性能或内存优化的代码往往可读性较差,在大多数情况下,代码的不可读性比你可能遇到的任何性能或内存问题都要糟糕。如果你的优雅代码在客户的要求范围内(速度和内存消耗方面)表现良好,那么就没有必要对其进行更改。如果它的表现不好,那么就开始你的分析器吧。
- 在进行分析时,始终让分析器引导你找到最大的问题,并首先解决它。如果只剩下一些小问题,而客户可以接受,那么可能没有必要投入更多工作。
- 尝试通过仅分析引起问题的确切模块来缩小问题范围。如果可能,关闭任何不相关的部分,例如用户界面、日志记录机制等。
- 不要猜测问题所在,并基于这些假设修改代码;让分析器告诉你哪里有问题。通常你的猜测(至少是我的)可能会让你走错方向,并花费大量时间而没有任何收益。
- 尝试在代码中识别拍摄内存快照的好位置。如果你的OutOfMemory异常是在一个长时间运行的循环中抛出的,那么该循环底部的几行通常是拍摄快照的好地方(因为在循环的迭代过程中,内存消耗需要或多或少保持一致)。
- 当你识别出代码中一个引用对象仅用于可选(或更确切地说,不重要)存储的地方时,请使用WeakReference类。例如,如果你只想在对象存活期间保留一个缓存,但不想让缓存保留这些对象。
- 使用静态字段和属性时要小心。从静态字段引用的所有对象在应用程序的整个生命周期(更准确地说,是你的AppDomain的生命周期)内都会保留在内存中,或者直到引用被移除(例如,设置为null)。
- 注意.NET事件。事件实际上只是委托的语法糖,而委托则是一种语法糖,用于组合对实例的引用和该实例上的回调方法。
因此,订阅事件的每个对象都将被发布该事件的对象保留在内存中,直到发布事件的对象不再被任何地方引用,或者事件被注销。 - 在分析应用程序之前,尽量关闭尽可能多的多线程内容,并使调用顺序化。多线程可能表现出看似不确定的行为,并且每次分析应用程序时都会产生不同的结果。
- 如果你正在使用CAB或任何其他依赖注入容器(IOC)框架,请确保在对象不再需要时将其从容器中注销。在CAB中,这意味着调用WorkItem.Items.RemoveObject(obj)、WorkItem.WorkItems.Remove(...)等。
- 请注意,调用对象的Dispose()方法并不一定会将对象从内存中清除。如果仍然存在对已处置对象的引用,它仍将占用内存。
- 尽可能避免在.NET环境中自行进行过多的内存管理。调用Garbage Collection(垃圾回收)通常是个坏主意,它倾向于使你的内存占用时间比自动GC机制更长。此外,终结器/析构函数的用法在大多数情况下也会使你的对象在内存中停留更长时间。终结器应仅在您使用需要显式释放的非托管内容时使用。
我希望通过描述这个.NET应用程序内存分析的特殊案例,能帮助到一些人。请随时对这个话题发表评论、批评和想法。