内存管理误解






4.93/5 (76投票s)
.NET 内存管理是一个令人印象深刻且全面的系统,但它并非完美无缺,有时甚至有点反直觉。因此,在您真正充分利用 .NET 框架之前,需要消除一些常见的误解。
本文由 Clive Tong(Red Gate 的一名软件工程师,他花费大量时间热衷于函数式语言)撰写,并经 Simple-Talk Publishing 许可重新发布,版权所有 © 2011)。本文是正在进行的“Ricky Leeks”教育活动的一部分,该活动专注于新的 .NET 内存管理知识。
.NET 内存管理是一个复杂的过程,大多数时候它运作良好。然而,它并非完美无缺,我们开发者也不是,所以内存管理问题仍然是熟练开发者应该准备应对的。虽然有可能获得关于 .NET 内存管理的有用信息并编写更好的代码,而无需完全理解框架内部的黑盒,但在您真正开始之前,有几个常见的误解需要消除:
误解 #1:垃圾收集器收集垃圾
运行时系统有一个关于它认为在接下来的执行中将要接触的对象的概念,这些对象被称为“活动”或可达对象。相反,任何“非活动”的对象都可以被视为“死亡”对象,显然我们希望能够重用这些死亡对象所占用的内存资源,以使我们的程序运行更高效。因此,.NET 垃圾收集器(简称 GC)的焦点实际上是“非垃圾”;那些所谓的活动对象,这或许有些反直觉。
大多数人实现的 GC 策略背后一个基本思想是,大多数对象都“英年早逝”。如果您分析大量程序,您会发现,通常,其中很多程序在进行一些计算时会生成临时对象,然后生成一些其他对象来表示该计算的结果(以某种方式)。因此,许多这些年轻对象都是临时的,并且很快就会“死亡”,所以您希望设计您的 GC 来收集“死亡”项,而无需单独处理“所有”它们。理想情况下,您希望只遍历活动对象,对它们进行一些操作以确保它们安全,然后摆脱您现在知道已“死亡”的所有对象,而“无需逐个遍历它们”。
这正是 .NET GC 算法所做的。它旨在收集死亡项而无需单独处理它们,并尽量减少对整个系统的干扰。后一个考虑因素促成了 .NET GC 采用的分代模型,我稍后会再次提及。目前我只会说有三代,标记为 Gen0、Gen1 和 Gen2,新对象分配到 Gen0,我们将重点关注 Gen0,因为我们将看一个简单的 GC 工作示例。
一个简单的变异器
我们将尝试使用一个简单的 C# 程序来阐述我刚才解释的内容;它被称为变异器(Mutator)。这个程序会创建一个集合实例,然后将其赋值给一个局部变量,因为这个集合被赋值给了局部变量,它将是活着的。并且由于这个局部变量在下面您可以看到的 While 循环的整个执行过程中都被使用,它将在程序的其余部分保持活泼。
var collect = new List<B>();
while(true)
{
collect.Add(new A());
new A();
new A();
}
列表 1 – 一个简单的变异器
我们正在分配三个名为 A 的小型类实例。第一个实例将放入集合中;它将保持活动状态,因为它被集合引用,而集合又被局部变量引用。然后分配另外两个实例,但它们不会被任何东西引用,因此它们将是“死”的。
如果我们考虑这种分配模式在研究 Gen0 时会是什么样子,我们会发现它看起来像这样:
在第 0 代中的分配
这里我们假设 Gen0 在我们的函数开始运行时是空的。当然,这永远不是真的;当你启动 CLR 时,基类库会立即加载,并在你的程序运行之前在堆上分配大量对象。为了清晰起见,我们暂时忽略这些,我们也将忽略在第一步中分配给那个局部变量的集合对象。
所以,让我们看看 While 循环,我们知道它每次迭代都会分配 3 个实例——我们将被集合引用的实例涂成黑色,以标记它为一个活动对象,然后另外两个实例,即死亡对象,涂成红色。
显然,在第二次迭代中我们会得到相同的模式;分配一个活动对象,随后是两个死亡对象——然后第三次迭代也会相同。查看图 3,您可以清楚地看到,当我们进入第四次迭代时,我们会发现 Gen0 中没有剩余空间,因此我们需要执行一次收集。
没有空间?复制
在处理第 0 代、第 1 代和第 2 代(即小对象堆)时,.NET CLR 采用了一种复制策略;在这种情况下,它试图将 Gen0 中的活动对象提升到 Gen1 中。其思想是找到 Gen0 中所有的活动对象,并将它们复制到 Gen1 内的某个空闲空间中(为清晰起见,我们也假设 Gen1 是空的)。请记住,这只是收集过程的第一步,当 GC 从 Gen1 到 Gen2 工作时,也以相同的方式应用,并用下面的箭头表示。
此时,GC 需要遍历堆上引用我们实例的其他对象(例如我们的集合对象),并修正这些对象的指针,使其指向被引用对象复制到的新位置。
这是一个有点棘手的步骤,因为在 GC 复制对象时,它还需要确保没有线程实际操作这些内容。如果它们“正在”操作,那么它可能会错过在复制后对对象“旧”版本所做的更新,或者它可能会以某种方式修改对象之间的指针,导致它忘记复制某些对象。
为了管理这个问题,.NET 运行时会将线程带到所谓的“安全点”。本质上,它会停止线程,并给自己一个机会,将这些指针从 Gen0 中的旧对象重定向到 Gen1 中新创建的副本。
当然,最酷的是,一旦 GC 完成了这一点,它现在就可以回收“整个”Gen0,并且无需单独扫描它曾经持有的对象。毕竟,它知道活动对象已经安全地提升并被正确引用,而所有其他对象都已经“死亡”,因此无关紧要。所以,假设大多数对象都“英年早逝”,我们只需处理少量对象就可以回收整个 Gen0。
观察
- .NET 分代 GC 的基本诀窍是允许对象移动(或者更确切地说,被复制)。这是一种很好的方式,可以将它们移开,以便我们可以重用它们的内存,而无需单独处理每个对象。这也意味着执行一次收集所需的时间与 GC 必须移动的活动对象的数量成正比,而不是与内存中它无论如何都会忽略的“死亡”对象的数量成正比。
- 然而,由于这个系统,我们确实需要将所有线程带到安全点,这会产生一定的开销。在安全点,我们可以修正指针以引用每个对象被复制到的位置。这显然对运行时设计产生了影响。例如,您需要访问从 JIT 和程序本身获取的数据,这些数据告诉您在各种对象之间可能找到指针的偏移量,以便您可以 a) 扫描它们以找到活动对象,以及 b) 在稍后时间修正它们。
- 您偶尔会听到一个与这种提升策略及其影响相关的术语,那就是“碰撞分配”,这仅仅意味着我们拥有快速分配事物的便捷能力。如果 Gen0 完全空白开始,那么当我们想要分配第一个对象时,我们所要做的就是将最初指向该代开头的指针增加与该第一个对象大小相应的字节数。这样,我们就知道我们可以立即将下一个对象放置在该新偏移位置,并按“该”对象的大小移动指针,等等。这给了我们之前图中看到的干净、堆叠的布局,其中对象一个接一个地出现。
现在看来,要做到这一切并不像你想象的那么容易,因为可能存在多个线程,如果你想在对象分配和指针递增过程中避免加锁,你需要做一些技巧来确保你在快速路径(你通常走的路径)上不需要任何线程加锁。这在随本次讨论附带的可下载网络研讨会(可从原始 Ricky Leeks 页面获取)中有更详细的介绍,但这里我不再赘述。
另一个技巧
.NET 运行时对小对象(即小于 85k1)还使用了另一个技巧,那就是我们已经遇到的分代结构。它将对象分为 Gen0(存放新对象)、Gen1 和 Gen2(后者是存放非常老的对象的地方,它们在每次垃圾回收幸存后都会升级)。换句话说,它假设对象在垃圾回收中存活的时间越长,它们继续存活的可能性就越大。考虑到我之前说的“大多数对象都英年早逝”,这种结构意味着 GC 可以将注意力集中在只对 Gen0 进行 GC(即只对可用内存的一个子集进行 GC),我们期望在这里获得回收死亡内存的最大回报。
1 大于 85k 的对象是另一个完全不同的问题,我们暂时不担心它们。
误解 #2:大量的 Gen0 分配是不好的
这个误解几乎是我们已经看过的材料的延伸。我们已经看到,执行 Gen0 收集的时间与该代中“活动”数据的数量成正比,尽管将线程带到安全点有一些固定的开销。移动大量对象是一件代价高昂的事情,但仅仅执行 Gen0 收集不一定是一件坏事。想象一个假设的情况,Gen0 满了,但所有占用空间的对象都已“死亡”。在这种情况下,没有活动对象会被移动,因此该收集的实际成本将是最小的。
基本的答案是,进行大量的 Gen0 收集很可能“不是”一件坏事,除非你处于 Gen0 中的所有对象都是活动的情况,在这种情况下,你最终会先将它们分配到 Gen0,然后立即将它们复制到 Gen1(这被称为双重分配)。
误解 #3:性能计数器是准确的
Windows 有一个性能计数器的概念;这只是系统定期更新的一些统计数据,您可以使用工具查看这些值,尝试推断系统内部正在发生什么。.NET 框架提供了许多这样的计数器,您可以使用各种工具以伪实时方式查看它们。
从内存管理的角度来看,使用这些性能计数器可以帮助您了解应用程序是否像“典型”应用程序一样运行,以及对象是否“英年早逝”。如果您对“典型”的含义感到好奇,网上提供的各种 Microsoft 文档共同提供了一个相当不错的衡量标准。例如,通常认为 Gen1 与 Gen2 收集的比率大约应为 10:1。
有一些非常有用的性能计数器,我们稍后会提到,它们与分配速率有关。这些计数器的例子包括“所有堆中的字节数”(Bytes in all heaps)(它告诉我们所有已分配对象的总数量)、“在 GC 中花费的时间”(time spent in GC)以及“每秒分配的字节数”(allocated bytes per sec),所有这些都可以通过免费的 Perfmon 工具绘制成图表。
本质上,您可以获得“大量”数据,这些数据都由系统维护,以便为您提供,正如我所提到的,一种伪实时的感觉,了解您的应用程序是如何运行的。从表面上看,这很好,但我们将其列为误解,因为您的数据收集和显示方式存在一些问题。
周期性测量
首先,重要的是要记住,这些计数器是“定期”更新的,特别是 .NET 内存计数器只在发生收集时才更新。这意味着如果没有发生收集,那么计数器就会停留在当前读数。这“意味着”您在 Perfmon 中看到的平均值并不能真正准确地告诉您应用程序内部正在发生什么,尽管它们确实比没有好。
为了演示其中的一些内容,我编写了一个简单的 C# 程序,它具有我们之前看到的相同基本结构:我们创建一个集合对象,将其赋值给一个局部变量,然后我们分配一个小类实例。这个程序类在 x86 上大约需要 12 个字节。然而,我们限制了分配速率,每毫秒只分配一个这样的对象。自然,随着这种累积,并且考虑到 Gen0 的容量为 1 或 2 MB,需要相当多的秒数才能填满 Gen0 并触发一次收集。
class Program
{
static void Main(string[] args)
{
var accumulator = new List<Program>();
while (true)
{
DateTime start = DateTime.Now;
while ((DateTime.Now - start).TotalSeconds < 15)
{
accumulator.Add(new Program());
Thread.Sleep(1);
}
Console.WriteLine(accumulator.Count);
}
}
}
列表 2 – 一个以受限速率分配对象的简单 C# 程序
如果您在 Perfmon 下查看这样一个运行中的程序,而不是看到一个恒定的“每秒分配字节数”计数器(我们知道我们的程序实际就是这样做的),由于驱动计数器测量的周期性,分配速率看起来就像每次收集发生时都会出现尖峰。
同样重要的是要记住,运行时本身也在衡量正在发生的事情。每次收集发生时,它都会计算出有多少百分比的对象存活下来,以便进行调整,为不同的代选择最佳大小,并努力最大化吞吐量。
所以,如果您绘制一些东西,比如各种堆大小的图表,您会发现得到误导性的数据。例如,您可以在图 8 中看到,系统决定将 Gen2 大幅扩大,然后又选择缩小。简而言之,尽管 Perfmon 给了我们这种伪实时的感觉,但它向我们展示的并不一定完全是应用程序本身的行为。
深入探究
为了真正了解您的应用程序行为,您需要深入研究并从对象或类型层面查看事物,有几种方法可以做到这一点。
第一个方法,如图 9 和图 10 所示,是使用 WinDbg,它是 Windows 调试工具 的一部分,您可以将其附加到正在运行的可执行文件。 .NET 框架本身带有一个名为 SOS 的调试器扩展,您可以将其加载到 WinDbg 中,然后它允许您扫描堆并查找其中包含的对象的详细信息。实质上,加载该 DLL 会使一组了解 .NET 内存布局的额外命令可用于调试器。例如,在图 10 中,我们正在转储类型为程序的*所有*对象,它会告诉我们(例如)在我拍摄此快照时堆中有 3953 个该类型的实例。它还会显示每个实例占用 12 字节的内存。
现在,如果我们考虑一个特定的实例,我们可以使用像 GCRoots 这样的命令来尝试将该对象与实际将其保留在内存中的根关联起来,该路径将向我们展示它是如何保持存活的,这可能是一个非常有用的信息。
有许多工具试图简化处理内存调试中大量信息的工作。在上面的例子中,它首先尝试在屏幕顶部显示各种性能计数器信息(参见上图 11),以引导我们确定何时需要拍摄快照(即堆中所有对象的转储)。然后,ANTS Memory Profiler 提供了工具,允许您比较快照,以找出哪些对象意外存活,以及哪些对象在您不期望的情况下大量分配。它还允许我们执行我们刚才看到的根查找技巧,但以更图形化的方式(参见图 12)。
所以,总结一下性能计数器的讨论,您可以使用 WinDbg,它能提供大量信息但难以导航,或者您可以尝试使用更图形化的工具,它们提供过滤功能和一种图形化探索您拍摄快照时堆内容的方法。无论哪种方式,请始终记住,这些工具所依赖的性能计数器不一定能实时反映您的应用程序内部正在发生的情况。
误解 #4:.NET 不会泄漏内存
从某种意义上说,这种说法在字面上是正确的,但 .NET 中存在具有相同症状的问题。这最终是一个定义问题。
旧定义
当你使用 malloc 和 free 自己管理内存时,内存泄漏就是任何时候你忘记了 free 部分所发生的事情。基本上,你会分配一些内存,用它做一些工作,然后你忘记将其释放回运行时系统。
或者,也许您正在处理一个非常大的数据结构,但您无法确定该结构中的实际根节点是什么,这使得您很难开始释放事物。
或者您调用了一个库例程,它返回了一些对象,但不清楚是您还是库稍后会释放这些对象。
此外,过早释放对象通常是致命的。假设您分配了一个对象,释放了它,然后继续尝试使用它。如果您的内存空间已经被分配给另一个对象,您会发现两个不同类型的对象正在争夺相同的内存,这通常会导致灾难性的错误。
新定义
好消息是,那些日子已经一去不复返了2,.NET 运行时会为您处理对象的释放,并且它也非常谨慎。它会判断在程序运行期间是否需要某个特定对象,并且只有当它能完全保证该对象不再需要时,才会释放它。
当然,这方面的困难在于,很难在头脑中建立一个有效的成本模型,来描述您分配的对象何时会被再次释放,因此理解您自己的代码可能会带来其自身的挑战!此外,虽然这种托管内存是一大福音,但它也存在一些机会和漏洞,允许对象存活的时间比它们应有的更长。
2 除非您激进地进行 Dispose。
什么会延长对象的生命周期?
有几件事可能会导致您的对象存活时间超过必要,从而使您的应用程序占用比您想象的更多的内存:
运行时本身
您使用的构建类型可能会影响对象的持久性。例如,如果您选择进行调试构建而不是发布构建,您可能会发现您的对象保留的时间比实际需要长得多,因为编译器在优化方面不够积极。
附加调试器也会延长对象的生命周期,因为您的应用程序将以一种使局部变量存活到函数结束的方式进行 JIT 编译,从而更容易调试。
我们已经看到,.NET 运行时有许多启发式算法来决定何时收集更高代的对象,结果是 Gen2 对象不会经常被收集。因此,如果您的对象不小心被提升到更高代,可能需要很长时间才能释放该对象并回收其内存。
终结器是另一个罪魁祸首;为了实现一个终结器,系统会等到一个带有终结器的对象本应被收集时,然后通过提升它来保存该对象。这意味着该对象将存活到下一代的收集,如果终结器线程未能及时处理它,可能会存活更长时间。
用户界面及其行为
事件处理程序是一个典型的例子——如果你将一个委托订阅到一个长期存在的项(比如一个顶级 Windows 窗体)上的事件处理程序,你实际上是在向一个对象和该对象上的一个方法添加一个引用(这本质上就是委托)。简而言之,你实际上让你的对象与顶级窗体一样长寿。如果你后来忘记取消订阅该对象,你会发现你引入了内存泄漏(以一个不必要地长寿对象的形式)。
这种事故还有其他例子,但它们并不常见,所以我在这里就不多赘述了。
库
你会发现有些库内部有缓存来提高性能,但它们对这些缓存的生命周期策略可能不是很好。所以你可能会发现你无意中保留了最后的 50 个结果,或者类似的东西。即使你很长时间没有调用该库,缓存仍然会保持活动状态,并且所有这些对象仍然会存在。
编译器
在我所有这些问题中,我最喜欢的是编译器如何将 C# 中更现代的结构转换为在 .NET 框架提供的 CLR 2 基础设施上运行。闭包和 Lambda 表达式是一个非常好的例子。
Lambda 表达式在 CLR 的 IL 层面本身并不表示为对象,而是表示为编译器生成的类,这些类用于维护对局部变量的引用。
class Program
{
private static Func<int> s_LongLived;
static void Main(string[] args)
{
var x = 20;
var y = new int[20200];
Func<int> getSum = () => x + y.Length;
Func<int> getFirst = () => x;
s_LongLived = getFirst;
}
}
列表 3 – 使用 Lambda 表达式说明编译器翻译
在这个简单的例子中,我们有两个局部变量被一个 lambda 表达式引用,该 lambda 表达式通过放置在一个静态字段中而存活很长时间。现在,为了使这些局部变量的生命周期与 lambda 函数的生命周期匹配,C# 编译器实际上通过将局部变量封装到一个类中来生成所有这些,并创建该类的实例,然后编译器将 lambda 函数表示为该类上的委托。
所以在这个例子中,即使局部变量 Y 不需要存活很长时间(因为 lambda 表达式只引用变量 X),我们也会发现,由于 C# 编译器的行为方式,这个大数组将存活很长时间。
如果我们查看 .NET Reflector 以了解该代码是如何生成的,我们会看到编译器生成了这个额外的显示类(参见上图 13),并且局部变量实际上表示为该显示类中的字段。然而,即使系统知道这些字段的值将来无法访问,也没有努力清除这些字段。
误解 #5:所有对象都是平等的
最后,正如我们在误解 #2 中所暗示的,对于非常大的对象,使用我们之前看到的复制-晋升策略是一个糟糕的主意,因为复制这些对象需要很长时间(而且非常昂贵)。因此,.NET 内存管理系统的设计者需要一种更好的方法来处理这些大对象,并采用了名为“标记-清除”的标准技术。GC 不会将活动对象晋升到另一个代,而是将它们留在原地,并记录它们周围的空闲区域,然后在稍后阶段使用该记录来分配对象。关键是,目前没有进行压缩。
收集期间不进行复制
让我们用我之前写的那个简单的变异器,而不是分配小对象实例,我们分配大对象实例(请记住,任何大于 85k 字节的对象通常都会分配在大对象堆中)。按照我们之前看到的相同场景,我们有一系列实例,包括活动和死亡的,在循环中被分配。
当发生垃圾回收时,GC 不会将这些活动对象移动到新的代,而只是记录它们的位置,然后扫描死亡对象,将其地址范围标记为空闲块,以便稍后用于分配请求。
再次强调,关键在于没有复制——一切都留在原地。
一些观察
这有一些优点——首先,没有移动,所以我们不需要做任何修正,也不需要将其他线程带到安全点来调整它们的指针。这也为我们带来了潜在的并行优势。
这种模型的问题在于它引入了潜在的碎片化,我们稍后会看到碎片化到底意味着什么。GC 现在还必须决定在什么时间点对这个大对象堆进行收集,并且决定将这个区域与 Gen2 同义,至少在 GC 目的上是这样。这意味着只要 Gen2 发生收集,这个大对象堆也会发生收集。
“这个”决定的后果是,临时大型对象并不真正适合这种模型。对于小型对象,临时生成它们是没问题的,因为它们会被非常快速且廉价地回收,但对于大型对象来说显然不是这种情况。因此,如果我们生成临时大型对象,可能需要很长时间才能进行 Gen2 收集,并且这些临时对象将在整个期间占用内存。
我们还看到,为了找到空闲内存块,GC 必须遍历堆上的所有对象。从分页的角度来看,这种行为代价要高得多,因为我们实际上接触了更多的活动内存。
碎片化问题
如果您的程序以活动-死亡-活动-死亡的模式分配对象,如下图所示,就可以说明这一点。
收集后,那些“死亡”对象都被标记为空闲空间,等待回收。
当您尝试分配一个比这些空闲块中任何一个都稍大的对象时,问题就出现了……
显然,GC 发现这个新对象无法放入任何这些空闲区域。所以,即使有足够的可用内存来满足该大小对象的请求,GC 也会发现它实际上没有地方放置新对象,并将被迫调整 LOH 的大小以使分配成为可能。
结论
我们研究了您可能遇到的 5 个不同的 .NET 内存管理问题,并试图为您讲述每个问题背后的一些故事和历史。我认为结论是,您的进程堆内部发生了很多事情,理想情况下,您需要能够可视化正在发生的事情,以便理解为什么有些东西存活的时间比您想象的要长。