堆还是不堆;这是大对象的问题?






4.99/5 (85投票s)
在本文中,我们探讨了一个理论;在处理 .NET 中的非常大的对象时,为了避免大对象堆(LOH)碎片,不使用堆结构会更好吗?
好的,您和您的团队刚刚构建了一个大型、可扩展、坚如磐石的 ASP.NET 网站门户,并取得了巨大成功;客户注册速度飞快,门户网站以惊人的速度增长,管理层非常满意。IIS 和 ASP.NET 在企业领域以其可扩展性而闻名,这一点得到了证明。一切都比预期要好,很快您就进入了企业级规模。然后——砰——不知从何而来——就像爆胎一样——您的服务器硬盘开始失控地疯狂转动。您的 Web 服务器上的所有内存都已被完全耗尽,可怕的永远分页到系统页面文件的情况已经开始发生,将您的服务器推向了毁灭的边缘。您的服务器硬盘发出隆隆声,就像一支正在执行空袭任务的 B-52 轰炸机机队逼近一样。您的工作进程(w3wp.exe)开始循环,企图从这种糟糕的状况中恢复,这会导致客户暂时服务中断,并使您的网站门户显得结构不稳定和不专业。您以同样惊人的速度从最好跌落到最糟。
我无意描绘如此令人沮丧的景象,但很多时候,这就是企业规模现实世界的写照。如果这个故事听起来惊人地熟悉,并且您的 IIS Web 服务器出现了许多相同的令人喜爱的特征,那么罪魁祸首很可能是大对象堆(LOH)碎片。
示例项目概述
示例项目代码旨在通过简单地将分配对象移出 LOH 来对抗 LOH 碎片,如图 1 所示。它并非旨在完全取代 LOH,也不是旨在将所有类型的对象都移出 LOH。目前,它取代了 `MemoryStream`、`string.Split` 以及整型和浮点值类型的数组,例如 `byte[]`、`int[]`、`long[]`、`double[]`。不过,值得注意的是,相同的技术也可用于将许多其他分配场景移出 LOH。几乎任何可直接映射(blittable)或具有显式布局的对象在大多数情况下都是理想的候选者。示例项目演示了这项新技术,并可用作一个简单的模板,以供将来扩展到其他分配场景。
示例项目包含两个项目:Tester.exe 和 NoLOH.dll。Tester.exe,如图2 所示,包含几个用于比较服务器上 LOH 碎片情况的测试。这些测试旨在模拟一个非常繁忙的企业服务器,该服务器正经历着会引起 LOH 大规模碎片化的精确场景,并允许您轻松地使用这项新技术来比较同一场景,使碎片化几乎消失。所有实现这项新技术的类都位于 NoLOH.dll 中。NoLOH.dll 非常易于使用——只需将其引用到您的项目中。然后,用这些类代替等效的内置 .NET 类型。就这样!
目前,我们在 NoLOH.dll 中实现了三个主要场景:`ArrayNoLOH<T>`、`string.SplitNoLOH()` 和 `MemoryStreamNoLOH`。
`ArrayNoLOH<T>` 可用于代替整型值类型数组,如下所示:
ArrayNoLOH<byte> bytes = new ArrayNoLOH<byte>(86000); //not on LOH vs. byte[] bytes = new byte[86000]; //on LOH
`string.SplitNoLOH()` 可用于代替 `string.Split()`,只需在后面追加“NoLOH”即可:
string text = "a large string"; foreach(string part in text.SplitNoLOH(‘^’)) //not on LOH vs. foreach(string part in text.Split(‘^’)) //on LOH using(StringArrayNoLOH parts = text.SplitNoLOH(‘^’)) //not on LOH vs. string[] parts = text.Split(‘^’); //on LOH
`string.SplitLazyLoad()` 可用于代替 `string.Split()`,只需在后面追加“LazyLoad”即可:
foreach(string part in text.SplitLazyLoad(‘^’)) //not on LOH vs. foreach(string part in text.Split(‘^’)) //on LOH
`MemoryStreamNoLOH` 可用于代替 `MemoryStream` 进行二进制序列化,如下所示:
public ArrayNoLOH<byte> Serialize(object listOfItems) { using(MemoryStreamNoLOH ms = new MemoryStreamNoLOH()) { new BinaryFormatter().Serialize(ms, listOfItems); //not on LOH ArrayNoLOH<byte> bytes = ms.ToArray(); //not on LOH return bytes; } } public object Deserialize(ArrayNoLOH<byte> bytes) { using(UnmanagedMemoryStreamEx ms = new UnmanagedMemoryStreamEx(bytes)) { return new BinaryFormatter().Deserialize(ms); } } vs. public byte[] Serialize(object listOfItems) { using(MemoryStream ms = new MemoryStream()) { new BinaryFormatter().Serialize(ms, listOfItems); //on LOH byte[] bytes = ms.ToArray(); //on LOH return bytes; } } public object Deserialize(byte[] bytes) { using(MemoryStream ms = new MemoryStream(bytes)) { return new BinaryFormatter().Deserialize(ms); } }
第一个,`ArrayNoLOH<T>`,由非托管内存(非 LOH)支持,该内存是在其构造函数中分配并在其 Dispose 中释放的——本质上是将其一个大对象放在小型对象堆(SOH)上。它实现了 `IList<T>` 接口,因此可以在代码中按索引访问它,类似于常规值类型数组。它使用起来非常简单——只需将其代替内置的值类型数组(即,将“int[]
”更改为“ArrayNoLOH<int>
”等)。您可以通过遵循 ArrayNoLOH<T>
类注释部分中列出的说明来添加对其他值类型的支持。我们希望我们已使其相对容易扩展,并将其留作读者的练习。
目前,`ArrayNoLOH<T>` *仅*支持值类型,不支持引用类型。引用类型要复杂得多,并且超出了本文的范围。获取引用类型的字节大小要复杂得多,这使得实现起来更加困难。理论上,如果有人足够大胆和疯狂,并且拥有计算大引用类型对象确切字节大小的专业知识,那么就有可能用这种新技术完全取代 LOH。GC 可以从 SOH—LOH 变为 SOH—VirtualAlloc,更接近于 HeapCreate 创建的堆与小于 512KB 的 HeapAlloc(使用堆)和大于 512KB 的 HeapAlloc(直接使用 VirtualAlloc)的操作方式,如图 8 所示。换句话说,GC 分配小于约 83KB 的对象将使用 SOH,大于约 83KB 的对象将使用直接面向操作系统的 VirtualAlloc(即,小于约 83KB 的基于堆,大于约 83KB 的非基于堆)。我们不是贪婪的惩罚者,也不想失去我们仅存的理智,所以我们将把这个可怕的实验留给那些真正的编码忍者读者。
您可能认为不必像您想象的那么大的整型值类型数组就会放到 LOH 上。一个 byte[] 在 85,000 字节时会放到 LOH 上。但是,一个 int[] 只需要大约 21,250 的长度,因为每个 int 是 4 字节。一个 long[] 只需要大约 10,625 的长度,因为每个 long 是 8 字节。哇!
此外,我们为与内置值类型数组的转换实现了显式运算符(例如,`ArrayNoLOH<byte>` 到 `byte[]`,`byte[]` 到 `ArrayNoLOH<byte>` 等),但不建议使用它们。`byte[]` 最终会放到 LOH 上,从而使整个目的失效。我们之所以包含它们,只是为了方便与您代码中已有的现有函数交互,并且只是作为一个如何操作的示例。
第二个,`SplitNoLOH()` 和 `SplitLazyLoad()`,作为扩展方法实现,位于 System 命名空间中,应该会在字符串数据类型的 Intellisense 中自动显示。它们都实现了与内置字符串拆分基本相同的功能,但没有使用 LOH,因此 LOH 不会碎片化。它们使用起来非常简单——只需在现有 `string.Split` 函数调用末尾追加“NoLOH”或“LazyLoad”(例如,将“string.Split
”更改为“string.SplitNoLOH
”。)就这样!
`SplitLazyLoad()` 不返回可在代码中按索引访问的 `string[]`。相反,它只是枚举,将字符串拆分成子字符串,这些子字符串只能在该 foreach 循环中使用。从某种意义上说,它被视为许多小的字符串对象,而不是一个大的字符串数组对象。它速度极快,内存占用极少。如果您的现有代码不需要返回 `string[]`,或者您可以重构代码以使用此方法,那么它绝对是您的最佳选择。
另一方面,`SplitNoLOH()` 返回 `StringArrayNoLOH`(代替 `string[]`),可以在代码中按索引访问。`StringArrayNoLOH` 由非托管内存(非 LOH)支持,该内存是在其构造函数中分配并在其 Dispose 中释放的——本质上是将其一个大对象放在 SOH 上。它在非托管内存中存储每个子字符串的分隔符位置和长度,作为一对 int。它实现了 `IList<T>` 接口,因此可以在代码中按索引访问它,类似于常规的 `string[]`。索引器使用这些分隔符位置和长度从*原始*字符串返回实际的子字符串。
一个字符串只需要超过大约 42,500 个字符就会放到 LOH 上,因为它内部使用 `char[]` 来实现,并且 .NET char 对于 Unicode 是 2 字节。但是,字符串拆分的内部实现使用一个长度等于字符串长度的 `int[]` 来跟踪拆分分隔符的位置,请参阅 string.cs 中 `SplitInternal` 函数的第 984 行(`int[] sepList = new int[Length];`)(https://referencesource.microsoft.com/#mscorlib/system/string.cs)。当被拆分的字符串长度仅为大约 21,250 个字符时,这会导致字符串拆分将一个 `int[]` 放到 LOH 上,因为一个 int 是 4 字节。不像您想的那么大!
第三个,`MemoryStreamNoLOH`,由非托管内存(非 LOH)支持,该内存是在其构造函数中分配并在其 Dispose 中释放的。它有一个指向内存块段的指针的内部列表。当向流写入数据时,流会自动扩展,新分配的段会自动分配并添加到列表中。内存段不需要是连续的,并且可以指定它们的大小(如果需要调整)。您可以在几乎任何允许使用流接口的地方使用它;序列化、套接字等。非常酷!
序列化一系列客户对象时,您会惊讶于需要多少计数才能强制内存流进入 LOH。在我们的测试中,只需序列化大约 1100 个客户对象,使用一个相对较小的基本客户对象,就足以强制内存流进入 LOH。
即使您完全不同意我们的理论,并认为我们的新技术是绝对的垃圾,我们仍然认为,拥有一个实现流接口(继承自基类 Stream)且带有可扩展的自定义后备存储的非常简单的示例可能会对您有所帮助。我们认为它可以用作一个起点模板,用于扩展您自己的新想法,替换您自己的自定义后备存储,甚至用于自定义您可能包含需要流对象的代码的其他项目。如果它能激发一些新想法,那么它的努力就是值得的!
测试结果
Tester.exe,如图2 所示,包含重现 LOH 碎片问题的测试用例——每个选项卡一个测试用例。每个选项卡都允许您轻松比较 .NET GC 与我们的新技术。每个测试由多个线程创建*短暂的短命*对象,以及一个同时创建*永久的长命*对象的线程组成。有两个“开始”按钮——一个用于开始创建短命对象,一个用于开始创建长命对象。选择“LOH”选项,同时按下两个“开始”按钮,以使用 .NET GC 运行测试。将生成一个“TestResults_LOH_...”文件。选择“No LOH”选项,同时按下两个“开始”按钮,以使用我们的新技术运行完全相同的测试。将生成一个“TestResults_NoLOH_...”文件。然后,您可以简单地比较这两个测试结果文件。
我们在 Windows Server 2012 R2 64 位系统上进行了测试,该系统拥有 12GB 内存、50GB 页面文件和 8 个逻辑处理器(4 核)。.NET GC 配置为“服务器 GC”模式(例如,8 个堆)和“后台 GC(并发)”模式。
我们的许多测试在运行仅仅二三十分钟后就开始出现严重的服务器问题——物理内存完全耗尽,硬盘疯狂翻页,在某些情况下,我们的视频监视器甚至失效。这可不好!
图 3 非常清楚地说明了 LOH 中的碎片对服务器的工作集(即物理 RAM)和 LOH 大小的巨大影响。令人震惊!红线是 .NET GC 使用 `byte[]` 的测试结果图。蓝线是我们的新技术使用 `ArrayNoLOH<byte>` 的测试结果图。工作集比较图上每两个点之间的差距几乎完全是由于 LOH 中的碎片造成的。简直难以置信!
测试包括:
- 80 个线程创建短暂的短命对象(随机大小,在 100,000-101,000 字节之间)
- 同时,1 个线程创建永久的长命对象(随机大小,在 100,000-101,000 字节之间)
请注意,在工作集比较图中,表示我们新技术的蓝线与列表中存储的实际总内存非常接近。另一方面,表示 .NET GC 的红线是实际存储在列表中的内存量的 2 倍甚至 3 倍。哇,这似乎不可能!以这种比例浪费服务器的内存资源可能会在眨眼之间严重削弱服务器。
此外,请注意,在 LOH 大小比较图中,一旦 LOH 大小超过了服务器的物理 RAM 并开始翻页到磁盘;服务器变得如此迟缓,以至于在实际应用中几乎无法使用。程序打开缓慢,屏幕长时间冻结,鼠标几乎无法移动等。而且,在翻页状态下打开任何其他程序似乎只会加剧碎片化问题。另一方面,我们的新技术(用蓝线表示)似乎能正常运行,即使在 40GB 内存时也是如此。可以随意打开程序,并且实际可用,屏幕不会长时间冻结,鼠标正常工作等。似乎我们的新技术在 40GB 时与 15GB、20GB 等时的运行效果相同。尽管我们没有测试超过 40GB 的情况,但似乎我们的新技术可能能够成功利用页面文件直到其上限,即使在高负载下也是如此。相反,似乎当 LOH 使用页面文件时,在这种高负载下,服务器几乎无法使用。
我们在测试中观察到,当没有短命的短暂对象引起 LOH 碎片时,LOH 使用页面文件似乎运行良好,基本上与我们的新技术性能相似。然而,当系统承受短命的短暂对象负载时,引入了碎片化,LOH 算法似乎极大地加剧了翻页,导致服务器几乎无法使用。我们的新技术即使在这种大规模的短命对象负载下也能正常使用页面文件。由于我们的新技术不会产生 LOH 碎片,LOH 算法根本不适用。
测试似乎表明,只有在没有短命的短暂对象引起 LOH 碎片时,LOH 才能正常运行。当没有碎片时,LOH 似乎能与我们的新技术相媲美。真正重要的是要认识到,我们的新技术*即使有这些短命的短暂对象*也能正常运行。我们认为从测试中可以相当安全地得出结论,LOH 碎片对 .NET 中的垃圾回收有严重影响。
工作集比较图还突出了另一个非常有趣的点——我们的新技术(本质上是 `VirtualAlloc`)由于碎片化非常少,因此看起来更接近线性。我们相信这种线性可能正是操作系统设计人员选择绕过堆并直接使用 `VirtualAlloc()` 而不是通过 OS 的原因之一,当 `HeapAlloc()` 大于 1MB(32 位为 512KB)时,如图9 所示。我们不能 necessarily 证明这一点。然而,这确实让人遐想。
由于 `HeapAlloc()` 使用来自 `HeapCreate()` 或 `GetProcessHeap()` 的堆句柄,并且许多(如果不是大多数)堆是使用 `HeapCreate()` 创建的,我们认为可以公平地说,`VirtualAlloc()` 的这种策略相当广泛地被采用。此外,如图8 所示,由于 `GlobalAlloc()`、`LocalAlloc()`、`malloc()` 和 C++ new 都基本使用 `HeapAlloc()`,它们也基本使用 `VirtualAlloc()` 的这种相同策略。
这确实让人停下来思考,为什么我们不为每个大于约 83KB(85,000 字节)的分配使用相同的策略,而不是使用 LOH。如果它对 512KB 以上(64 位为 1024KB)的系统来说足够好,为什么它对我们 83KB 以上的系统就不够好呢?在旧的 32 位时代,我们不得不担心虚拟地址碎片化。然而,在新一代的 64 位时代,庞大的虚拟地址空间几乎消除了这种担忧。尽管如此,这确实很有趣。
在下面的图 4 中,我们仅仅测量了使用一个线程将 10GB 的长命对象添加到缓存列表所需的时间,以比较使用各种大小对象的原始分配速度。图中红线所示的测试使用了 `byte[]`,而图中蓝线所示的测试使用了 `ArrayNoLOH<byte>`。正如您所见,它们看起来大致相当。
以下是我们完成的一些测试的实际测试结果,以便您也能看到我们的新技术对 LOH 碎片产生了多么显著的影响,几乎完全消除了问题。当我们观察到这些结果时,我们感到非常震惊!
1) `ArrayNoLOH<byte>` 测试
我们选择将额外的重点放在此测试上,即字节数组,因为它是在低级别最常见且最简单的示例,清晰而精确地显示了内存碎片问题,希望使其更容易观察和欣赏。其他测试也表现出相同的内存碎片问题;然而,它们不那么明显,也不容易看到,因为它们基本上只是内部使用数组的应用结果。我们仍然认为包含它们非常重要,因为它们可能会导致 LOH 发生大量碎片化,而开发者却不知情。
此测试包括:
- 80 个线程快速创建和销毁大型*短暂的短命*对象(随机大小,在 100,000-101,000 字节之间)
- 同时,1 个线程快速创建大型*永久的长命*对象(随机大小,在 100,000-101,000 字节之间)并将其存储在列表中
- 测试完成于列表总大小达到 3GB 时
此测试试图在一定程度上模拟一个繁忙的生产服务器。使用我们的新技术运行完全相同的测试,问题几乎消失了。它带来的区别确实非常显著!碎片化问题不仅几乎消失了,服务器本身也运行得更流畅,GC 没有持续搅动。我们感到惊讶!
下面的图 5 显示了 Tester.exe 为 LOH(GC)测试和 No LOH(新技术)测试生成的测试结果文件,其中包含 `byte[]` 和 `ArrayNoLOH<byte>` 的测试结果。请注意,在 LOH(`byte[]`)结果中,工作集和大对象堆大小大约是列表中*实际缓存字节*的 2.7 倍,这几乎完全是由于 LOH 堆中的碎片(空闲空间)(参见表 1)。正如您所见,存储列表中约 3GB 内存大约需要 8GB 的工作集(即物理 RAM)。近三倍——约 64% 的碎片!另一方面,在 No LOH(`ArrayNoLOH<byte>`)结果中,工作集仅略高于列表中缓存的实际内存,因为 LOH 堆中几乎没有碎片(空闲空间)(参见表 1)。哇!这对服务器来说可能非常严重,因为工作集和大对象堆大小都代表物理 RAM。
下面的表 1 显示了 LOH 堆中的碎片(空闲空间),这是由于使用 byte[] 而非使用我们的新技术 `ArrayNoLOH<byte>` 引起的。它包含 WinDbg.exe 中 !heapstat 命令的输出,通过简单地附加到正在运行的 Tester.exe 进程的发布版本并发出命令“.loadby sos clr”然后是“!heapstat”来生成。请注意 `byte[]` 引起的 LOH 中大量的碎片(空闲空间),而 `ArrayNoLOH<byte>` 几乎不会引起碎片(空闲空间)。
表 1 WinDbg.exe 中 !heapstat 命令的输出
Byte[] | ArrayNoLOH<byte> | |||||||
堆 0(空闲 KB \ 总计 KB) | 575,085 | \ | 742,549 | (77% 碎片) | .3 | \ | 382 | (0% 碎片) |
堆 1(空闲 KB \ 总计 KB) | 522,671 | \ | 684,154 | (76% 碎片) | .1 | \ | 256 | (0% 碎片) |
堆 2(空闲 KB \ 总计 KB) | 671,429 | \ | 1,082,184 | (62% 碎片) | 0 | \ | 0 | (0% 碎片) |
堆 3(空闲 KB \ 总计 KB) | 573,426 | \ | 657,166 | (87% 碎片) | 0 | \ | 0 | (0% 碎片) |
堆 4(空闲 KB \ 总计 KB) | 705,100 | \ | 989,703 | (71% 碎片) | .1 | \ | 1,016 | (0% 碎片) |
堆 5(空闲 KB \ 总计 KB) | 723,812 | \ | 1,350,811 | (53% 碎片) | 0 | \ | 0 | (0% 碎片) |
堆 6(空闲 KB \ 总计 KB) | 798,757 | \ | 1,802,680 | (44% 碎片) | 0 | \ | 0 | (0% 碎片) |
堆 7(空闲 KB \ 总计 KB) | 729,127 | \ | 1,138,503 | (64% 碎片) | 0 | \ | 0 | (0% 碎片) |
2) `string.SplitNoLOH` 测试
此测试包括:
- 80 个线程拆分一个包含 42,500 个字符的大字符串,这会导致大量*短暂的短命*对象被快速创建和销毁
- 同时,1 个线程快速创建大型*永久的长命*对象(随机大小,在 100,000-101,000 字节之间)并将其存储在列表中
- 测试完成于列表总大小达到 2GB 时
同样,此测试试图在一定程度上模拟一个繁忙的生产服务器。短暂的短命对象是由 `int[]`(这是字符串的长度)引起的,该数组用于在 Split 函数的内部实现代码中跟踪拆分分隔符的位置。使用我们的新技术(SplitNoLOH)运行完全相同的测试,问题几乎消失了,正如您在图 6 和表 2 中所见。差别很大!
图 6 是此测试的图形化结果;PerfMon.exe 和任务管理器性能选项卡中的内存图形。请注意,对于 `string.Split`(左侧),大对象堆大小(红线)和工作集(蓝线)最终大约是列表中“实际缓存字节”(绿线)的 3.7 倍,而对于 `string.SplitNoLOH`(右侧),工作集(蓝线)几乎与列表中“实际缓存字节”(绿线)匹配。此外,请注意任务管理器中的内存图形,与 `string.SplitNoLOH` 相比,`string.Split` 消耗了更多的实际内存(RAM)。令人震惊!几乎难以置信!
需要极其重要的是要注意,即使是由于大规模服务器拆分字符串,很容易无意中将大量短命的短暂对象放入 LOH,而自己却不知情。正如您在表 2 中所见,这可能会对服务器资源产生相当大的影响。一个极其繁忙的生产服务器在正常标准操作中可能需要拆分大量的字符串,这可能在您不知情的情况下对服务器资源产生巨大影响。
表 2 显示了此测试的结果,用于 `string.Split()` *与* `string.SplitNoLOH()`
.Split() | .SplitNoLOH() | |||||||
列表中“实际缓存字节”(KB) | 2,097,164 | 2,097,161 | ||||||
大对象堆大小(KB) | 7,721,790 | (实际的 3.7 倍) | 722 | |||||
工作集(KB)(RAM) | 7,847,308 | (实际的 3.7 倍) | 2,298,332 | |||||
堆 0(空闲 KB \ 总计 KB) | 1,096,227 | \ | 2,988,031 | (36% 碎片) | .2 | \ | 196 | (0% 碎片) |
堆 1(空闲 KB \ 总计 KB) | 617,839 | \ | 674,750 | (91% 碎片) | .1 | \ | 83 | (0% 碎片) |
堆 2(空闲 KB \ 总计 KB) | 646,795 | \ | 660,019 | (97% 碎片) | 200 | \ | 570 | (35% 碎片) |
堆 3(空闲 KB \ 总计 KB) | 643,059 | \ | 659,226 | (97% 碎片) | 0 | \ | 0 | (0% 碎片) |
堆 4(空闲 KB \ 总计 KB) | 672,808 | \ | 691,919 | (97% 碎片) | 0 | \ | 0 | (0% 碎片) |
堆 5(空闲 KB \ 总计 KB) | 708,372 | \ | 735,176 | (96% 碎片) | 0 | \ | 0 | (0% 碎片) |
堆 6(空闲 KB \ 总计 KB) | 567,386 | \ | 593,267 | (95% 碎片) | 0 | \ | 0 | (0% 碎片) |
堆 7(空闲 KB \ 总计 KB) | 669,752 | \ | 719,399 | (93% 碎片) | 0 | \ | 0 | (0% 碎片) |
3) `MemoryStreamNoLOH` 测试
此测试包括:
- 10 个线程快速将 1400 个 Customer 对象的列表序列化到内存流中并返回字节数组,这会导致大量*短暂的短命*对象被快速创建和销毁
- 同时,1 个线程快速创建大型*永久的长命*对象(随机大小,在 100,000-101,000 字节之间)并将其存储在列表中
- 测试完成于列表总大小达到 5GB 时
同样,此测试试图在一定程度上模拟一个繁忙的生产服务器。短暂的短命对象是由二进制序列化器使用的内存流以及返回的数组引起的。使用我们的新技术(`MemoryStreamNoLOH`)运行完全相同的测试,问题几乎消失了,正如您在表 3 中所见。它带来的区别令人惊叹!
需要极其重要的是要注意,即使是由于大规模服务器序列化对象,也很容易无意中将大量短命的短暂对象放入 LOH,而自己却不知情。正如您在表 3 中所见,像这样简单的事情可能会对服务器资源产生相当大的影响。我们确实想向您展示这些测试结果,因为繁忙的服务器在其日常运营中可能会执行大量的序列化操作,而这些操作可能在短时间内导致服务器瘫痪。
表 3 显示了此测试的结果,用于 `MemoryStream` *与* `MemoryStreamNoLOH`
MemoryStream | MemoryStreamNoLOH | |||||||
列表中“实际缓存字节”(KB) | 5,242,970 | 5,242,970 | ||||||
大对象堆大小(KB) | 9,531,594 | (实际的 1.8 倍) | 7,504 | |||||
工作集(KB)(RAM) | 9,751,416 | (实际的 1.9 倍) | 5,599,128 | |||||
堆 0(空闲 KB \ 总计 KB) | 518,734 | \ | 1,358,347 | (38% 碎片) | 1,802 | \ | 2,071 | (87% 碎片) |
堆 1(空闲 KB \ 总计 KB) | 511,303 | \ | 1,418,457 | (36% 碎片) | 2,403 | \ | 3,115 | (77% 碎片) |
堆 2(空闲 KB \ 总计 KB) | 499,866 | \ | 1,906,318 | (26% 碎片) | 72 | \ | 314 | (23% 碎片) |
堆 3(空闲 KB \ 总计 KB) | 563,613 | \ | 793,743 | (71% 碎片) | .1 | \ | 200 | (0% 碎片) |
堆 4(空闲 KB \ 总计 KB) | 547,767 | \ | 858,495 | (63% 碎片) | .1 | \ | 200 | (0% 碎片) |
堆 5(空闲 KB \ 总计 KB) | 590,589 | \ | 989,587 | (59% 碎片) | 0 | \ | 0 | (0% 碎片) |
堆 6(空闲 KB \ 总计 KB) | 516,068 | \ | 1,062,992 | (48% 碎片) | 1,602 | \ | 2,002 | (80% 碎片) |
堆 7(空闲 KB \ 总计 KB) | 536,236 | \ | 1,143,649 | (46% 碎片) | 0 | \ | 0 | (0% 碎片) |
下面的表 4 显示了配置为工作站 GC 模式(即,只有一个堆)的 64 位 Windows Server 2012 R2 上的 LOH 起始地址的 !dumpheap 命令的输出。正如您所见,它确认在调用 Serialize
函数后将一个 `byte[]` 添加到 LOH,在调用 ToArray
函数后添加第二个 `byte[]`(两者均以红色框突出显示)。然后,表下半部分简单地确认使用我们的新技术没有添加 `byte[]`。
我们的 `MemoryStreamNoLOH` 类仅将常规 `MemoryStream` 和 ToArray
函数生成的这两个 `byte[]` 移出 LOH。在调用 Serialize
函数后创建的附加 `Int64[]` 和 `Object[]` 来自 `Serialize` 函数内部,超出我们的控制范围,并且可能是导致表 3 中 `MemoryStreamNoLOH` 列中堆出现微小干扰的原因。
可以使用“!dumpheap”轻松执行此操作,只需将 WinDbg.exe 附加到正在运行的进程并发出命令“.loadby sos clr”然后是“!eeheap –gc”然后是“!dumpheap 0x????????????????”(用 LOH 起始地址替换?)。就这样!
表 4 LOH 上 !dumpheap 命令的输出
using(MemoryStream ms = new MemoryStream()) { new BinaryFormatter().Serialize(ms, list); return ms.ToArray(); }
using(MemoryStreamNoLOH ms = new MemoryStreamNoLOH()) { new BinaryFormatter().Serialize(ms, list); return ms.ToArray(); }
4) `ArrayNoLOH<byte>` 测试(在 32 位服务器上)
我们还包含了在 32 位服务器上的此测试,纯粹是为了信息目的。我们仍然希望您看到 32 位服务器上也存在相同的 LOH 碎片问题,尽管我们的技术并非为 32 位服务器设计的,因为它们的虚拟地址空间有限。
正如您在以上所有四个测试中所见,LOH 可能对服务器资源造成极其低效的使用。通过仅仅完全避免 LOH,使用我们的新技术直接与操作系统交互(使用 VirtualAlloc 而非使用堆),您的服务器可以实现更好的资源利用率。
即使您的服务器没有遇到这种大规模的 LOH 碎片问题;我们发现我们的新技术实际上让服务器在我们的测试中运行得更好。整个服务器更加稳定。内存使用量大大减少,并且增长更接近线性。由于翻页,硬盘没有像赛车仪表盘那样红线。这相当令人震惊和有些可怕!
那么……堆还是不堆*真的*是大对象的问题吗??????
像 C# 这样的托管语言的主要吸引力之一就是其托管内存和 GC,它使我们免于处理指针和非托管内存,从而消除了忘记释放指针所带来的潜在内存泄漏。您可能会担心在 C# 中使用指向非托管内存的指针是危险的,这与 C# 作者的初衷相悖。然而,在托管对象的 Dispose 中释放指向非托管内存的指针可以保证不会发生内存泄漏。这就是为什么我们将我们的新技术描述为更像是将一个大对象放在 SOH 上的方法,如图1 所示。
我们真心希望您能够以最小的改动和几乎不修改现有逻辑的方式,在您的现有代码库中精确而优雅地利用这项新技术。如果您的服务器因拆分大型字符串而遇到 LOH 问题,我们希望您只需将“NoLOH”追加到现有的 `string.Split` 中,瞧,您的问题就消失了。或者,只需追加“LazyLoad”,然后就可以解决了!无需重新设计,无需更改逻辑——问题就消失了。序列化导致 LOH 问题的对象集合?没问题,只需将 `MemoryStream` 更改为 `MemoryStreamNoLOH`——问题解决了。或者,是大量的 `int[]` 对象在破坏您的服务器 LOH?没问题,只需将 `int[]` 替换为 `ArrayNoLOH<int>`。问题解决了!
此外,我们希望为您提供一个良好的起点,以便您将来可以自行试验,创建您自己的新理论和技术。在我们多年的研究中,我们一直发现很难清晰地了解操作系统内存系统与其相关的堆和 .NET GC。因此,我们创建了这篇文章,作为极其简化的基本概述供参考。
概念
对于标准规模 IIS Web 服务器运行中遇到的普通使用场景,您可能永远不会遇到任何显著的 LOH 碎片。然而,当 IIS Web 服务器在企业规模下被推到极限时,通常会意外地出现异常的内存使用模式,从而导致 LOH 碎片。作为 .NET 程序员,我们通常不太考虑碎片化,甚至没有意识到碎片化可能存在重大风险。我们倾向于相信我们不知何故神奇地免受所有这些令人不快的细节的影响。
总的来说,碎片化是由于在堆中混合了短暂的短命对象和永久的长命对象造成的。在碎片化方面,真正严重影响碎片化的是对象的*持续时间*,而不是对象的大小。如果我们始终为短暂的短命对象拥有一个完全独立的堆,为永久的长命对象拥有一个完全独立的堆,那么本质上就不会发生碎片化。短命对象的内存将被不断回收,并可供下一个短命对象使用,而不会将内存困在两个长命对象之间。长命对象将被简单地附加到其堆的末尾,按顺序排列,因为它们永远不需要被回收。长命对象不会发生碎片化,因为不会有短暂的短命对象在长命对象之间产生内存间隙。
.NET 垃圾回收器(GC)更侧重于对象*大小*,而不是对象*持续时间*,它分为小型对象堆(SOH)和大对象堆(LOH)。SOH 不需要担心碎片化,因为它采用压缩设计,而 LOH 本质上允许混合短暂的短命对象和永久的长命对象,这可能导致严重的碎片化。很可能,在碎片化方面,采用*短命*对象堆(SOH)和*长命*对象堆(LOH)的 GC 而不是当前的*小型*对象堆(SOH)和*大型*对象堆(LOH)会更好。在不知情的情况下,您可能会导致 LOH 严重碎片化。只需持续拆分大型字符串很长时间,或者像将对象序列化到内存流中,同时将大型对象添加到将存在于程序生命周期中的永久缓存列表中,这通常会导致显著的 LOH 碎片化,而您甚至没有意识到。可怕!
在本文中,我们提出了一种理论,该理论将探讨在处理*.NET 中*特别是*大对象时,使用堆或不使用堆是否更好。对于处理大小对象(如 CRT Heap)的常规标准堆,答案非常简单——您需要一个堆。您不能浪费整个内存页来存储仅占几字节的小对象。然而,对于占用许多内存页的大对象,答案变得有些模糊。我们开发了一种简单技术(如图1 所示),该技术不使用 LOH 来处理大对象,而是直接与操作系统交互。所包含的示例项目具有实现此新技术的代码。
进程从操作系统获取内存基本上有两种方式:基于堆(HeapCreate\HeapAlloc)或非基于堆(VirtualAlloc),如图8 所示。基于堆的分配通常应用于较小的尺寸,以避免巨大的浪费;而基于非堆的分配通常只保留给较大的尺寸。
VirtualAlloc 的最小分配粒度是 64KB 的虚拟地址块和 4KB 的物理内存页。这正是计算机科学发展出堆的概念的原因;处理小于一页的所有内容,这样就不会为占几字节的完整内存页浪费资源。我们的技术*仅*针对大约 83KB 以上的大对象设计——大于一页的大小。因此,我们使用非基于堆的分配,直接通过 VirtualAlloc 与操作系统交互。我们不是唯一的。有趣的是,当 HeapAlloc 大于 512KB 时,操作系统本身会使用非基于堆的分配,通过 VirtualAlloc 直接与操作系统交互,如图9 所示。
因此,当回答大对象的问题时,可以提出一个有说服力的论点——不使用堆。对于大对象不使用堆,这在某种程度上违背了传统观念。尽管如此,我们请求您容忍我们并保持开放的心态,因为示例代码可能有助于您那正在经历毁灭性 LOH 碎片问题的服务器,或者可能激发新的想法,从而为那些经历严重 LOH 碎片化的企业级服务器带来非传统的解决方案。即使您完全不同意,从完全不同的角度探索这个主题也是一次非常有趣的旅程。
堆、内存分配和虚拟内存是一个极其复杂的主题,无法在一篇文章中充分涵盖。我们不试图对此主题进行详尽的介绍,而是进行极其简化的概述,希望使其易于可视化和理解。您可以参考 Microsoft 网站上的文档以获取所有详细信息。
为什么我们要关心 LOH 碎片?
对于在 32 位操作系统上运行的 32 位进程来说,LOH 碎片可能很快导致“内存不足”异常,从而完全终止进程。对于在 64 位操作系统上运行的 64 位进程来说,LOH 碎片可能很快导致极大的内存消耗,严重损害进程性能并导致应用程序崩溃。过度的内存消耗可能导致大量分页到系统页面文件,使您的硬盘承受永无止境的虚假工作负载。有了 64 位,对导致进程终止的可怕“内存不足”异常的恐惧几乎消失了;然而,这种巨大的内存消耗的影响可能导致即使是最健壮的服务器也停滞不前。无论是 IIS 由于“内存不足”异常而不断循环应用程序池进程(w3wp.exe),还是服务器停滞不前,最终结果基本上相同;您的应用程序显得结构不稳定和不专业。
我们的新技术通过完全避免 LOH 来避免 LOH 碎片,如图1 所示。我们的技术绝不是 LOH 的永久替代品,它仅用于处理 .NET 中大对象的替代方法。它仅应在服务器出现由这种非典型行为引起 LOH 碎片的罕见症状时作为最后手段使用。
大多数专家推荐某种形式的缓冲区池技术(无论是您自己的自定义缓冲区池实现还是现有的 .NET 缓冲区池实现),反复重用池中的相同缓冲区,以避免 LOH 碎片化。该技术效果很好,但是它将责任推给了开发人员,让他们以精确的风格编写程序,以便他们的程序利用 LOH 而不引起碎片化,而不是简单地让 GC 为我们处理一切。我们希望提供一种替代技术,它极其简单,直接与操作系统交互(使用 VirtualAlloc),以避免创建和维护自己的企业级、生产质量的缓冲区池设计的复杂性。
创建生产质量的缓冲区池设计并非易事——它可能会极大地增加代码的复杂性。此外,对于稍后加入的开发人员来说,维护您的代码可能会有些负担,因为他们必须学习使用自定义缓冲区池样式,并且必须记住严格遵守该自定义编码样式以避免 LOH 碎片化。此外,吸引我们使用 .NET Framework 而不是 C++ 的主要好处之一是不再需要手动管理内存。因此,可以说程序员需要手动构建缓冲区池以及管理它们的底层架构,或者严格遵守现有 .NET 缓冲区池实现的专门自定义规则,仅仅是为了成功利用 LOH,这似乎与当初吸引我们转向 .NET Framework 的初衷相悖。如果我们又必须手动管理自己的内存,那么使用 C++ 不是更好吗,在那里我们可以完全控制内存?我们不是在提倡回到 C++,我们只是说,将它推给开发人员负责手动管理内存作为碎片化的解决方案,这有点弱。所有事物都一样,将缓冲区池作为 LOH 碎片化的解决方案,在一个以保护开发人员免受内存管理之苦为主要卖点的语言中,似乎与 C++ 相比,对于使用 .NET Framework 来说是一个相当薄弱的论点,因为 C++ 对内存拥有完全的控制权。
32 位 vs. 64 位
我们的新技术专门为在 64 位操作系统上运行的 64 位进程设计。在 64 位操作系统(Windows Server 2012 R2)上运行的 64 位进程拥有令人印象深刻的 128TB 用户模式代码虚拟地址空间(用户模式 128TB,系统模式 128TB),如图10 所示。而默认情况下在 32 位操作系统上运行的进程拥有仅 2GB 的用户模式代码虚拟地址空间(用户模式 2GB,内核模式 2GB)。庞大的 128TB 用户模式代码虚拟地址空间几乎消除了虚拟地址碎片化可能带来的不良后果,而使用我们的技术有时可能会出现这种情况。简单来说,我们基本上是用虚拟地址碎片换取 LOH 中的内存碎片。LOH 中的内存碎片非常重要,而另一方面,虚拟地址碎片不太重要,因为实际的物理内存会释放回操作系统,并且地址空间如此之大,以至于需要很长时间才能用完地址。
尽管我们的技术在运行 32 位操作系统的服务器上没有技术限制,但在大多数情况下,升级到 64 位操作系统并采用我们的技术,比在用户模式代码(2GB 会使虚拟地址碎片化成为一个紧迫的问题)的极其有限的虚拟地址空间的限制下工作,效果要好得多。
在大多数情况下,仅升级到 64 位操作系统(不使用我们的技术)就可以极大地改善遇到 LOH 碎片的服务器。表面上看,仅此升级似乎就完全解决了问题——“内存不足”异常消失了,IIS 应用程序池(w3wp.exe)停止循环。然而,通过更仔细的分析,我们发现进程可能实际上遭受了过度的内存消耗,在某些情况下,这可能是代码显式指示消耗的实际内存的两倍甚至三倍;这完全是由于 LOH 碎片化。如果我们只谈论几 GB,例如,就不会有问题。然而,对于 50GB、100GB 甚至 200GB,现在可能会有一些重大的影响。如果您的进程只使用了 50GB,但操作系统认为它使用了 150GB——这可不好!
我们的技术几乎消除了 LOH 碎片化;使进程的内存消耗更接近于代码显式指示的实际内存。例如,假设一个进程的代码显式指示缓存的对象总量为 50GB 的实际内存。使用我们的技术,进程可能运行在 51GB。然而,没有我们的技术,进程可能运行在 100GB 或更多。进程实际上只使用了 50GB,但由于 LOH 碎片化,操作系统认为它使用了超过 100GB。即使在具有大量物理内存和大型页面文件的极其健壮的服务器上,这也可能成为一个严重甚至致命的问题,因为缓存会继续增加。
技术工作原理
将所有内容都保留在 SOH 上,而不是 LOH。如果 LOH 上从未分配过任何对象,内存就不会发生碎片化,并且无需压缩 LOH。问题解决了——生活很美好。然而,当您是处理大型数组、解析和拆分大型字符串、大型 XML、大型 JSON 和大量大型 HTML 文件的企业规模的 Web 门户时,将所有内容都保留在 SOH 上说起来容易做起来难。您可能惊讶地发现,数组对象不需要太大就会放到 LOH 上。一个 `byte[]` 必须大于 85,000 字节。但是,一个 `int[]` 只需要大约 21,250 个元素以上,一个 `long[]` 只需要大约 10,625 个元素以上。您说,那又怎样?这和我有什么关系?我的 Web 门户代码中没有使用很多数组。再次,您可能会惊讶地发现,拆分一个大型字符串会导致一个额外的 `int[]` 放入 LOH,一旦字符串长度超过大约 21,250 个字符。所以,在不知情的情况下,您确实可能在您的 Web 门户代码中使用了许多数组。因此,生成大量大型短暂对象在 LOH 上,这是碎片化的一道关键配料。一旦您零星地混合一点永久缓存对象,您就拥有了完美的灾难配方。哎呀!
我们使用 C# 中的普通托管类,但不使用托管内存来支持它。相反,在构造函数中,我们使用 `VirtualAlloc()` 函数分配非托管内存。然后,在 Dispose 中,我们使用 `VirtualFree()` 函数将非托管内存释放回操作系统。由于我们使用 `VirtualAlloc()` 函数在非托管世界中分配非托管内存,.NET 垃圾回收器(GC)不知道该内存。它认为 C# 类只是 SOH 上的一个小对象,会被定期垃圾回收和压缩。当 C# 类被 Dispose 时,它会立即将非托管内存释放回操作系统。随后的压缩消除了 SOH 中的任何碎片。图 1 说明了该技术。
对于大型数组,您可以使用此技术。在普通托管 C# 类中分配非托管内存(以使其不在 LOH 上)。然后,在普通托管 C# 类的 Dispose 中释放非托管内存(SOH 会压缩,所以 SOH 中没有碎片)。您也可以将此技术用作一种通用的方法,将大型分配移出 LOH(使用非托管内存),纯粹是为了避免 LOH 碎片化。
让 GC 控制之外的非托管内存可能会引起一些担忧。然而,有人可能会争辩说,当今大多数 Web 服务器都拥有足够的内存来处理这种级别的内存使用波动。由于释放非托管内存的 C# 类在被 Dispose 时只是一个普通的 C# 对象,它会被定期垃圾回收并在 SOH 上进行压缩,因此非托管内存不应该持续累积直到耗尽。可能会出现内存使用峰值,但不应该持续累积直到服务器故障。此外,为了进一步缓解担忧,总可以选择通过 `GC.AddMemoryPressure` 和 `GC.RemoveMemoryPressure` 将非托管内存的知识提供给 GC。
一个可怕的想法
由于其设计,当应用程序混合了大量大型短暂对象和大量大型永久对象时,LOH 最终会随着时间的推移而失败。LOH 最终会因为碎片化和无压缩而耗尽连续内存,并在下一次需要该连续内存的分配时失败。
问题不是*是否*会失败,而是*何时*会失败。
这可能意味着目前有大量运行在企业规模的 Web 门户网站,它们广泛使用大型数组和拆分大型字符串,而这些网站注定会随着时间的推移而失败。这仅仅是时间问题。可怕!这个问题可能比我们想象的要普遍得多。完全有可能我们没有听到很多关于这个问题的信息,仅仅是因为 Web 服务器在不断循环工作进程,或者 LOH 是通过代码定期手动压缩的。这两种方法都掩盖了问题的真正性质。它们似乎是合理的解决方案,然而两者都可能导致应用程序的显著延迟和冻结,使其显得不专业。
此外,IIS 和 ASP.NET 内置的强大恢复机制使得解决此问题相当隐蔽。当工作进程(w3wp.exe)的内存消耗达到不可接受的水平或遇到“内存不足”异常时,工作进程会终止,服务器会启动一个新的工作进程来代替它。此功能使得故障排除相当困难。大多数开发人员甚至不知道如何开始排除这种类型的故障。
我们怀疑可能有大量企业生产 Web 门户网站可能在不知情的情况下遇到了这个问题。或者,他们知道它正在发生,但不确定该怎么做。出于绝望,许多人可能只是让工作进程持续循环,或者甚至将 Web 服务器的配置更改为更早地循环工作进程。这并没有解决问题,它只是掩盖了问题。您是否注意到在导航公共企业 Web 门户时,有时会出现停顿和冻结?可能是其中一些停顿完全是由于这些工作进程在循环。这可不好!
总结
在本文中,我们探讨了一个理论;为了避免 LOH 碎片,在处理 .NET 中的非常大的对象时,不使用堆结构会更好吗?在当今巨大的 64 位地址空间中,用地址碎片换取内存碎片是否更好?用 LOH 碎片换取 VAS 碎片?堆还是不堆,这确实是那个价值百万美元的问题……
在第 1 部分中,我们概述了我们新技术(基于我们新理论)的代码和测试结果,本质上为 LOH 碎片问题提供了一个可能的解决方案。
在第 2 部分中,我们将更深入地研究这种新理论背后的计算机科学,解释问题本身以及为什么首先需要解决方案。希望您能加入我们。这将是激动人心的;如果您喜欢底层技术细节并且是精神折磨的爱好者……
第 2 部分 – 深入探讨
在第 1 部分中,我们概述了我们新技术(基于探索在处理 .NET 中的非常大的对象时,不使用堆是否更好)的代码和测试结果。我们选择首先呈现一个可能的解决方案,以引起您的好奇心,并希望您对这个概念感到兴奋。
在第 2 部分中,我们将更深入地研究这种新理论背后的科学,解释底层细节问题本身以及为什么首先需要解决方案。那么,让我们直接开始……
垃圾回收器(GC)
.NET GC 是一项了不起的工程壮举!它能够出色地处理如此多样化的内存需求,而且从不失败,跨越了整个频谱。此外,随着后台工作站垃圾回收(使用专用的后台垃圾回收线程)和后台服务器垃圾回收(使用多个后台垃圾回收线程,通常每处理器一个,始于 .NET Framework 4.5)的出现,以及从 .NET Framework 4.5.1 开始支持代码手动压缩 LOH;GC 已成为一个真正令人惊叹的功臣。
概念上,GC 的托管堆分为两部分;小型对象堆(SOH)和大对象堆(LOH)。SOH 用于小于 85,000 字节的对象,并会定期在垃圾回收期间进行压缩。当 SOH 中的一个对象被销毁,在 SOH 内存中留下一个空位时,SOH 会复制并移动剩余的活动对象(即压缩)以消除间隙,如图11 所示。
另一方面,LOH 用于 85,000 字节或更大的对象,并且根据设计从不进行压缩。在 LOH 中销毁的任何对象都会永久地在 LOH 内存中留下一个空位(导致碎片化),除非在创建后续对象时可以重新利用这个空位,如图12 所示。
GC 主要是一个分代垃圾回收器。SOH 由三代组成:Gen0、Gen1 和 Gen2。Gen0 用于短命对象,Gen2 用于长命对象,Gen1 在短命对象和长命对象之间充当中介。
当 Gen0 变满时,会在 Gen0 上进行垃圾回收。Gen0 中幸存的活动对象通过压缩对象(移除死对象留下的内存间隙)并调整 Gen1 边界来提升到 Gen1。当 Gen1 变满时,会在 Gen1 和较年轻的 Gen0 上进行垃圾回收。Gen1 中幸存的活动对象通过压缩对象并调整 Gen2 和 Gen1 边界来提升到 Gen2,Gen0 提升到 Gen1。当 Gen2 变满时,会在 Gen2、较年轻的代(Gen1 和 Gen0)以及 LOH 上进行垃圾回收。这也被称为完全回收。Gen2 中幸存的活动对象留在 Gen2,Gen1 提升到 Gen2,Gen0 提升到 Gen1,通过压缩对象并调整 Gen2 和 Gen1 边界。图 11 演示了此过程的示例。
LOH*不*进行压缩。GC 设计者为 LOH 做出此架构决策,是因为在压缩非常大的对象时,内存复制和移动会涉及固有的开销。LOH 不进行压缩,而是维护一个列表,其中包含销毁对象后内存中留下的空闲间隙,这些间隙可用于满足未来的分配请求。它使用一种高度优化的算法,在分配新对象时积极地尝试重用内存中的这些空白区域。一个极其简化的图示,描绘了此功能,如图13 所示。
内存中销毁对象后留下的空白区域的重用概念,而不是通过压缩对象来移除这些空白区域,这可能导致 LOH 在出现异常内存分配模式的情况下过度碎片化。基本上,除非所有对象的大小完全相同,以便每个空白区域都能完美重用,否则 LOH 中不可避免地会出现碎片化。通过压缩并因此移除 SOH 中的空白区域,即使对象大小不同,也不会造成碎片化。
小型对象堆(SOH)
GC 的设计者在架构 SOH 时取得了巨大的成功!其压缩设计使其能够处理大量的分配和去分配,所有这些都发生在不同的时间、不同的规模,同时几乎不产生碎片化并保持对象局部性。它能够以几乎不显眼的影响服务于惊人数量的分配,尽管需要额外的内存复制开销来进行压缩。这简直是令人惊叹的!
尽管压缩本质上消除了碎片化,但仍然值得注意的是 SOH 并非对碎片化免疫。将小型对象(小于 85,000 字节)固定在内存中会干扰 SOH 的压缩过程。SOH 不允许移动这些固定的对象——它们被固定在原地,无法移动。SOH 仍然可以自由地移动和压缩固定对象之后的对象,只要它们本身没有被固定。被固定很短时间后很快被销毁的对象很少会引起严重的碎片化。然而,被固定极长时间的对象有时会导致 SOH 大量碎片化。
在架构设计中使用操作系统系统功能(如托管代码)时要极其小心,并给予充分的考虑。这些系统 API 调用处理的是非托管内存,在使用它们时可能需要固定托管内存。如果处理不当,可能会不可避免地导致严重的碎片化。
大对象堆(LOH)
另一方面,GC 的设计者为 LOH 选择了一种完全不同的架构。LOH 更接近于自计算机科学诞生以来一直存在的一些更传统的堆设计,本质上试图重用死对象留下的空闲内存区域,而不是 SOH 的压缩模式。我们说它更接近传统的堆设计,但有一个关键区别,许多传统堆设计一旦分配超过一定大小就会直接与操作系统交互(使用 VirtualAlloc)——LOH 不会。正是这种重用空闲内存区域而不是压缩的设计,使得 LOH 更容易发生碎片化。如果您有很多大型短暂对象,大小不断变化,与大量大型永久缓存对象混合在一起,那么您就准备好了灾难,这有时会导致 LOH 大量碎片化。
设计者决定从不压缩 LOH。从不压缩 LOH 可能会导致您的进程消耗大量内存,这仅仅是碎片化的不幸副作用。尽管这相当不寻常,但在非常大规模的企业环境中,它确实会不时发生。这有点像彩票中奖,只是奖品是服务器耗尽所有内存,翻页到磁盘并缓慢爬行。许多时候,进程的内存可能远高于进程中所有对象实际消耗的内存,这仅仅是因为从不压缩 LOH 造成的碎片化。如果您不压缩——您就会碎片化——无论您如何有效地重用空内存块,因为并非所有分配的大小都完全相同。
LOH 碎片化
GC 的任何一半都不会免疫碎片化。本质上,SOH 中的碎片可能是由固定内存引起的,而 LOH 中的碎片可能是由不压缩引起的。混合大量的长命短暂对象和大量的长命缓存对象,同时不使用压缩,这通常会导致 LOH 大量碎片化。
尽管企业规模的 Web 服务器上可能导致性能不佳甚至崩溃的条件有很多——但很多时候这直接归因于 LOH 碎片化。按设计,LOH 上的对象收集的频率低得多,并且在收集时从不压缩,原因仅仅是成本太高。在许多情况下,这最终会导致 LOH 碎片化。每个新的大对象分配都试图预留连续内存。然而,由于碎片化,这些新的分配迫使进程的内存消耗急剧增加。这种过度的内存消耗最终会耗尽服务器的物理内存,导致过度分页到磁盘。在那时,服务器开始表现不佳并最终崩溃只是时间问题。
最近,.NET Framework 中添加了几个新功能来帮助处理这些问题。首先,您可以从代码中进行 LOH 手动压缩,如下所示
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce; GC.Collect();
然而,这需要一次完整的*阻塞*垃圾回收才能启动压缩,这会导致您的应用程序和最终用户在大部分垃圾回收和压缩期间出现停顿。其次,为了减少 GC 对应用程序造成的停顿,您的服务器可以配置为使用后台服务器垃圾回收,如下所示
<configuration> <runtime> <gcConcurrent enabled="true"/> ("true"=Background GC, "false"=Foreground GC) <gcServer enabled="true"/> ("true"=Server GC, "false"=Workstation GC) </runtime> </configuration> Note: Background GC: Does GC on background thread\threads (one thread per CPU). : Helps latency (good for UI). Foreground GC: Helps throughput (good for services with no UI). Server GC: Should be faster when >2 CPU. Workstation GC: Should be faster when 1 CPU.
然而,这仍然没有解决 LOH 的碎片化问题。它仅仅解决了停顿的症状。
表面上看,能够从代码手动压缩 LOH,以及减少此压缩期间对应用程序的停顿影响,似乎解决了问题。然而,实际上,我们只是用一个问题换取了另一个不同的问题。如果您的 Web 门户在 LOH 被代码手动压缩的许多秒内冻结,这与 Web 门户在工作进程循环时冻结有什么不同?无论是自动按计划循环工作进程,还是手动从代码压缩 LOH,结果都是相同的——您的 Web 门户出现短暂的停顿和冻结,这在最终用户看来是不专业的。
设计者选择从不压缩 LOH,并且至今仍未压缩 LOH,即使在引入允许我们手动从代码压缩 LOH 的新功能之后。他们让开发人员自行承担风险来压缩 LOH,让他们自己承担性能成本和可能对其应用程序造成的负面影响。这确实让人停下来思考,什么时候,甚至是否,开发人员手动*从代码中*压缩 LOH 是个好主意。
一种直观地看到碎片化的简单方法是将其与硬盘和磁盘碎片化进行类比。删除文件会在磁盘上留下空白区域,导致磁盘碎片化。随着驱动器变得越来越碎片化,您的磁盘性能会因为这种碎片化而下降。当您手动执行磁盘碎片整理命令时,剩余的文件会被压缩(复制和移动)以变得更连续,从而消除磁盘上的空白区域。
同样,当您从代码手动压缩 LOH 时,活动对象会被压缩以变得更连续,从而消除 LOH 内存中的空白区域。但是,唯一的缺点是压缩需要一次完全*阻塞*的垃圾回收。如果 LOH 未严重碎片化,并且压缩所需的内存复制量很少,您可能只会得到一个轻微的停顿,而最终用户甚至无法注意到。但如果 LOH 严重碎片化,并且压缩需要大量的内存复制,您的最终用户可能会遇到显著的停顿,这些停顿不仅明显,而且不可接受。当您仔细思考时,如果这实际上是最好的做法,而且没有重大的缺点,那么设计者为什么不选择定期自动压缩 LOH 以避免碎片化呢?而不是让我们开发人员手动从代码中进行?
我们相信 Microsoft 选择这样设计 .NET GC,采用两种不同的架构(SOH 和 LOH),以便它基本上能够处理大多数一般情况。我们的技术只是处理 LOH 中一个特定情况的替代方法。在某种意义上,我们是用巨大的外部碎片化(由于 LOH 在某些情况下无法返回给操作系统的整个虚拟地址空间段,如图14 所示)来换取虚拟地址空间的少量内部碎片化,在最后的 64KB 块内。
SOH 碎片化
SOH 并非完全免疫碎片化。长时间固定内存有时会导致 SOH 过度碎片化。Microsoft 专家建议尽可能缩短固定对象的时间并快速销毁它们,以此作为避免 SOH 碎片化的主要策略。
诸如异步 I/O 或重叠 I/O 之类的系统功能通常要求您提供一个缓冲区,系统将使用该缓冲区来处理 I/O 请求。需要固定,以便在系统处理 I/O 请求时,托管内存缓冲区不会被移动。如果托管缓冲区未被固定,垃圾回收器可能会在内存中移动托管缓冲区,导致当系统在异步 I/O 操作完成后尝试使用托管缓冲区时,托管缓冲区不再位于原始位置。这不是一件好事!
长时间挂起的异步 I/O 或重叠 I/O 可能导致托管缓冲区被固定很长时间。在一个非常繁忙的大规模服务器上,这可能会在 SOH 中造成混乱。可怕!
要查看 SOH 可能发生情况的一个非常有趣的示例,请查看 Microsoft 源代码存储库跟踪的以下问题项的对话 (https://github.com/dotnet/coreclr/issues/1236)。它对 SOH 中的碎片化进行了很好的讨论,甚至包含了一张内存分析工具的输出屏幕截图,说明了由于固定内存时间过长而在 SOH 中可能出现的问题。
为了解决 SOH 中的固定(pinning)问题,我们包含了一个额外的实用程序库 NoPin.dll。NoPin.dll 采用基于堆的分配(HeapAlloc),如图 8 所示。NoPin.dll 从非托管堆分配非托管内存中的缓冲区,这基本上消除了固定(pinning)的需要。垃圾回收器 (GC) 无法移动非托管内存,因此无需进行固定。您可以利用 NoPin.dll 来处理我们之前提到的、对于 NoLOH.dll 中的新技术来说过小的缓冲区分配。基本上,NoPin.dll 使用基于堆的分配 (HeapAlloc),而 NoLOH.dll 使用非基于堆的分配 (VirtualAlloc)。图 8 包含一个说明基于堆和非基于堆分配的图示。
现在可以将托管缓冲区被固定的位置替换掉,以完全消除固定。与其这样做(这会使用 fixed 语句固定托管缓冲区 bytes):
fixed(byte* p = bytes) { r=Win32Native.ReadFile(h,p,count,IntPtr.Zero,overlapped); }
您现在可以这样做(这会使用不需要固定的非托管缓冲区):
using(NoPin.Buffer p = new NoPin.Buffer(bytes.GetLength(0))) { r=Win32Native.ReadFile(h,(byte*)p.Ptr,count,IntPtr.Zero,overlapped); bytes=(byte[])p; //optional }
或者,与其这样做(这会使用 GCHandle.Alloc
和 GCHandleType.Pinned
来固定托管缓冲区 bytes):
GCHandle h = default(GCHandle); try { h = GCHandle.Alloc(bytes,GCHandleType.Pinned); byte* p = (byte*)Marshal.UnsafeAddrOfPinnedArrayElement(bytes,0); r=Win32Native.ReadFile(h,p,count,IntPtr.Zero,overlapped); } finally { if(h.IsAllocated) h.Free(); }
您可以这样做(这会使用不需要固定的非托管缓冲区):
using(NoPin.Buffer p = new NoPin.Buffer(bytes.GetLength(0))) { r=Win32Native.ReadFile(h,(byte*)p.Ptr,count,IntPtr.Zero,overlapped); bytes=(byte[])p; //optional }
什么是堆(Heap)?
简单来说,操作系统 (OS) 的虚拟内存管理器 (VMM) 是一个分页内存系统。本质上,这意味着 VMM 将内存视为 4KB 的页面;虚拟地址空间 (VAS) 以称为页(pages)的 4KB 页面表示,物理内存 (RAM) 以称为页帧(page frames)的 4KB 页面表示,如图 15 所示。因此,内存的最小粒度是 4KB 页面。然而,值得注意的是,对于支持大页面的系统,页面大小可以可选地配置得大得多。内存地址实际上就是由页面编号和页面内偏移量(或物理页面帧编号和物理页面帧内偏移量)组成的数字。VMM 将虚拟地址视为 64KB 的块。从虚拟地址空间分配虚拟地址的最小粒度是 64KB 的块(16 个 4KB 页面),同样如图图 15 所示。
因此,当有人使用 VirtualAlloc 向操作系统的 VMM 请求一字节内存时,他们实际上会获得虚拟地址空间中的一个 64KB 虚拟地址块,因为最小分配粒度是 64KB。然后,当该字节首次被访问并映射到物理内存时,它实际上会使用一个 4KB 的物理内存页面,因为 VMM 将物理内存视为 4KB 的页面。为仅一字节浪费整个 64KB 虚拟地址空间块和 4KB 物理内存页面将是极其低效的。因此,计算机科学领域为此带来了解决方案,引入了堆的概念,以高效地处理小于最小分配粒度(64KB)、小于页面大小(4KB)或不精确地在页面边界上的分配请求。实际上,堆允许分配小于页面的请求,这可以有效地消除浪费。通过堆,我们现在可以获得我们想要的那个字节,而无需浪费内存。如果我们总是以恰好是 64KB 的倍数来请求内存,一切都会很好——不会有浪费。虚拟地址空间不会被浪费,物理内存也不会被浪费。那将是完美的!由于这实际上并不可行,所以堆将一直存在。
堆是一种机制,它通过从操作系统的 VMM 获取完整的内存页面,然后将单个字节内存交给应用程序,从而将内存分配请求从页面粒度转换为字节粒度。本质上,它将最小粒度从页面更改为几个字节。这使得您的应用程序能够进行小于一个页面或不精确地在页面边界上的内存分配请求,而不会浪费内存。思考堆的一个非常简单的方法是,操作系统的 VMM 以完整的内存页面进行通信,而应用程序以单个字节的内存进行通信——堆的作用就像是两者之间的翻译器,在某种意义上将页面转换为字节。堆充当操作系统 VMM 和请求内存的应用程序之间的中间人。您可以将其视为零售店。零售店从批发商那里批量购买内存(来自操作系统的 VMM 的内存页面),然后作为中间人将这些内存的单个字节出售给零售消费者(应用程序的单个字节)。这就像从零售杂货店购买一盒草莓(HeapAlloc),而不是从食品批发配送仓库购买一整卡车的草莓(VirtualAlloc)。这就像单独购买(零售),而不是批量购买(批发)。如果您只打算吃几颗草莓,从一家零售杂货店购买一整卡车的草莓肯定会浪费很多草莓。
如果您的分配请求是小于页面大小或不精确地在页面边界上的小型分配请求,在大多数情况下,通过堆作为中间人会更有效。然而,对于较大的分配请求,例如 1MB,您可以直接通过 VirtualAlloc 与操作系统的 VMM 进行交互。事实上,即使是使用 HeapCreate 创建的堆,当分配请求超过 1MB(64 位)时,也会选择直接使用 VirtualAlloc 与操作系统的 VMM 进行交互,如图 9 所示。
我们为什么需要堆?很简单,堆可以使小型分配请求更有效。堆能够为您提供所请求的一个字节,同时在内部管理内存页面。很棒!这似乎解决了问题,然而有时会以碎片化的代价。碎片化并非秘密。这是堆中一个相当著名的问题。实际上,从一开始,程序员就必须采取各种缓解策略来分配内存,以应对堆碎片化。
问题不是堆是否会碎片化——而是它会碎片化多少。
Windows 操作系统中的低碎片堆 (LFH) 是一个很好的例子,它能够将碎片化降至最低,在许多情况下几乎消除了堆碎片化。然而,仅凭堆的本质,很可能仍会存在某种形式的碎片化。任何时候,当你获取一个大块的东西并尝试分发其小块而不进行压缩时,你都会在一定程度上注定会出现碎片化,这取决于你用来实现这一目标的算法或结构。许多堆采用一种相当知名的设计,即尝试用与分配请求大小匹配的空白区域来填补死对象留下的空白,而不是像 SOH 那样进行压缩。这种设计不可避免地会导致碎片化,除非每次分配的请求大小都完全相同。实际上只是碎片化的程度有所不同。如果你不进行压缩,几乎可以肯定会出现某种程度的碎片化。
由于堆充当中介,如果出现碎片化,可能会成为一个相当严重的问题,因为它可能永远不会将碎片化的内存返回给操作系统,如图 14 所示。然而,另一方面,直接与操作系统 VMM 进行交互而没有中间人(VirtualAlloc)则令人担忧的程度要小得多,因为内存实际上被返回给了操作系统(VirtualFree)。在这种情况下,通常只是地址空间可能会碎片化,而不是内存。
许多知名的堆,甚至从一开始就是如此,都使用了类似的算法,这些算法或多或少地试图重用死对象留下的空白区域来满足未来的分配请求。例如,默认进程堆、CRT 堆(C 运行时)、.NET 大对象堆 (LOH)、在进程中使用 HeapCreate 创建的私有堆,都采用这种有些相似的算法。它们不使用压缩。当你稍加思考就会发现,这是有道理的。托管内存世界拥有压缩范例的便利,这是一种副产品。由于托管内存世界没有指向原始内存地址的指针,我们实际上可以自由地在内存中移动对象而不会造成混乱。本质上,这就是 SOH 使用压缩设计的原因。在非托管内存世界中,我们没有这种便利。指针指向原始内存地址。如果我们移动一个对象,所有指向该原始地址的指针现在都将失效,指向内存中的其他内容或只是纯粹的垃圾内存。因此,可以理解为什么这些传统风格的堆不采用压缩。
有人可能会争辩说——压缩胜过一切。你可能会认为,操作系统中的堆使用类似 SOH 的压缩设计会更好。然而,这种争论是徒劳的,因为非托管内存世界实际上没有这种便利。SOH 完全利用了它在托管内存世界中的地位所带来的好处。然而,LOH 却没有那么幸运。压缩内存中非常大对象的固有开销,促使 LOH 的设计者选择 LOH 的传统堆设计,即使它存在固有的碎片化。这就是为什么我们的新技术旨在完全消除使用 LOH。我们想完全消除使用传统的堆设计。没有传统的堆——就没有碎片化——这就是我们的想法。我们的新技术通过直接从系统检索内存来替换 LOH,并本质上利用 SOH 来控制释放这些内存。我们不使用传统的堆(LOH)作为中间人从系统检索内存,而是直接从系统检索我们自己的内存(VirtualAlloc)。从而消除了堆及其固有的碎片化。中间人会遭受碎片化,所以我们只需移除中间人来消除碎片化。这干净利落。
跳过中间人(LOH)直接从系统检索我们自己的内存(VirtualAlloc)乍一看可能显得有些非正统和冒险。然而,经过仔细审查,您会发现许多传统的堆在分配请求的大小超过特定阈值时,内部会采用类似的功能。例如,一个用 HeapCreate 创建的堆,这类堆非常多,当分配请求超过 512KB(32 位)和 1024KB(64 位)时,它实际上会绕过堆直接使用 VirtualAlloc 与系统交互,如图 9 所示。因此,您可能会认为我们的新技术不仅仅是利用未经证实的、过于危险的技术的冒险技巧,而是恰恰相反,它只是一个相当成熟的、在堆实现中已广泛使用多年的技术的一个合乎逻辑的进展。
不使用堆——论点
可以理解,为什么传统的堆设计在计算机的早期被创建并普遍被认为是明智之举。即使存在相当著名的碎片化问题,在内存资源非常有限的情况下,高效地分配少量内存(例如四个字节)而不浪费整个内存页面,也大大超过了对堆中碎片化风险的担忧和固有的风险。32 位操作系统加上机器上有限的内存,使得使用堆作为中间人变得至关重要。您需要一个中间人来以最高效的方式分配更小的内存块,以保持在这些有限的约束内。无法通过为仅几个字节的小型分配使用整个页面内存来浪费内存。此外,由于 32 位操作系统,进程的总虚拟地址空间非常有限。这就是最终使我们的技术在 32 位操作系统上不可行的原因,因为虚拟地址空间碎片化(虚拟地址空间中可用地址数量本身的碎片化,而不是堆内存的碎片化)。
我们的技术确实会将内存释放回操作系统(因此内存不会碎片化),但是可用地址数量会增加(因为虚拟地址空间会碎片化),直到最终没有更多可用的地址数量用于新的分配。本质上,我们的技术消除了堆内存碎片化和内存不足的问题,将其替换为虚拟地址空间碎片化和地址不足的问题。我们不会因为内存不足异常而耗尽堆内存——而是因为分配失败异常而耗尽虚拟地址空间中的地址数量。不太好!对于 64 位操作系统,我们不会很快耗尽可用地址数量,因为虚拟地址空间非常大(128TB),如图 10 所示。出于所有实际目的,我们作为交换获得的虚拟地址空间碎片化问题几乎已经消除。因此,可以说,我们现在已经解决了堆内存碎片化问题以及虚拟地址空间碎片化问题。这就是为什么我们的技术仅针对运行 64 位操作系统的服务器设计,而不是 32 位操作系统。
如今,64 位操作系统相当普遍,企业级生产服务器上拥有数百 GB 的内存也相当普遍;你可能会争辩说,现在不仅可行,而且技术上可以消除中间人(堆)的使用,以逃避其相当著名的固有碎片化问题。然而,消除中间人(堆)的使用会以内存利用效率低下的代价。如果你只需要四个字节——你不想浪费整个内存页面!哎呀!这就是堆仍然存在的原因之一。完全消除它们没有意义。尽管如此,LOH 只适用于大对象(85,000 字节或更多),从不适用于仅几个字节的对象。几个字节最终会被放入 SOH。因此,有人可能会争辩说,为每个非常大的对象浪费最后几页内存的少量开销,远不如为每个小型对象(几个字节)浪费整个页面数量巨大。这本质上使得这个论点只有在你使用我们的新技术来替换一个恰好只处理非常大对象的传统堆时才令人信服。LOH 似乎很符合这一点。非常酷!
恰如其分地说,我们的技术旨在通过直接与操作系统 VMM 交互来完全消除使用 LOH,从而消除中间人。避免碎片化——通过消除碎片化原因——本质上是这样。在最后一页内存上可能仍然存在少量浪费(类似于堆中因使用比对象大的可用空间而导致的内部碎片化),但在我们的测试中,与 LOH 的巨大外部碎片化相比,这是微不足道的。讽刺的是,使这个论点令人信服的原因与使我们的技术只能用于这种情况的原因相同——而不是作为所有堆的通用替代品。
如今,大多数专家建议只需升级到 64 位操作系统,您的 OOM 异常问题就会神奇地消失。真是奇迹!您需要小心,不要被愚弄,认为问题已完全解决。当然,OOM 异常问题似乎已解决。然而,实际上,您只是将 OOM 异常问题换成了另一个更阴险的问题——您的进程使用的内存远超其实际利用量。表面上看,这似乎不是一个非常严重的问题。然而,实际上,操作系统认为您的进程确实正在使用那么多内存,这可能很快就会成为一个极其严重的问题。当您的进程内存消耗达到巨量水平时,最终可能导致操作系统陷入灾难性的状态,并对您的进程进行真正的报复。切换到 64 位操作系统并不能真正解决问题——它只是掩盖了它。切换到 64 位操作系统并结合我们的新技术,实际上可以在某些情况下解决问题。下表 5 是一个总结
32 位进程
问题 | 结果 |
堆内存碎片化 (LOH) | OOM 异常 |
虚拟地址空间碎片化 / 地址不足 | OOM 异常 |
64 位进程(未使用 技术)
问题 | 结果 |
堆内存碎片化 (LOH) | 不再有 OOM 异常——但仍然碎片化(这是一个严重问题,因为操作系统认为进程使用的内存远超其实际利用量;导致内存消耗过大、磁盘分页过多,最终导致服务器崩溃) |
虚拟地址空间碎片化 / 地址不足 | 未碎片化 |
64 位进程(使用 技术)
问题 | 结果 |
堆内存碎片化 (LOH) | 未碎片化(不再使用 LOH) |
虚拟地址空间碎片化 / 地址不足 | 碎片化(但不再是问题,因为地址空间非常大,并且实际物理内存已释放回操作系统) |
总的来说,似乎连续性(contiguous-ness)本身的概念才是导致碎片化的真正原因。虚拟地址空间是连续的——物理内存 (RAM) 不必是连续的,这是因为在虚拟内存系统中,虚拟地址映射到物理地址,如图 14 所示。堆使用虚拟地址空间,因此,它也是连续的。由于其连续性,堆和虚拟地址空间在很大程度上必须应对碎片化。本质上,堆会碎片化内存——虚拟地址空间会碎片化虚拟地址。如今,随着 64 位地址空间如此之大,我们不必过于担心虚拟地址空间碎片化。然而,我们确实需要非常关注内存碎片化。内存碎片化可能对您的服务器产生严重后果。内存碎片化可能导致您的进程消耗远超其实际利用量的内存。这最终可能导致内存消耗过大、磁盘分页过多,并最终可能导致您的服务器“着火”。不好!
不使用堆,直接使用 VirtualAlloc 与操作系统交互,在某些情况下仍可能导致虚拟地址碎片化。然而,凭借巨大的 64 位虚拟地址空间(128TB),我们不必过于担心。然后,另一方面,直接使用 VirtualAlloc 与操作系统交互,几乎消除了堆固有的内存碎片化问题。在某种意义上,您只是将一种问题(堆内存碎片化)换成了另一种问题(地址不足);只有后一种问题破坏性小得多,并且随着 64 位的出现,已不再那么重要。64 位的出现才使得我们的新技术变得引人注目和可行。
Windows Server 2012 R2 之前的服务器操作系统(例如 Windows Server 2008 R2)仅支持进程 8TB(44 位)的用户模式虚拟地址空间,因为它们不支持 CPU 指令 CMPXCHG16B。尽管 8TB 的用户模式虚拟地址空间非常大,但虚拟地址空间碎片化可能会导致在长时间运行过程中地址不足,具体取决于服务器上的内存使用模式。Windows Server 2012 R2 及以上版本现在支持 CMPXCHG16B 指令,它将用户模式虚拟地址空间增加到惊人的 128TB(47 位)。有关更多详细信息,请参阅以下内容:(https://en.wikipedia.org/wiki/X86-64)。
当然,我们的技术在某些情况下可能会遭受虚拟地址空间碎片化,但凭借这巨大的 64 位虚拟地址空间(128TB),它已不再是一个大问题。在所有虚拟地址被消耗殆尽之前,还需要相当长的时间。这在一定程度上消除了地址不足的担忧,现在使得我们的技术不仅更加可行,而且更具吸引力。此外,由于我们在 C# 对象被释放时立即将内存返回给操作系统(因此虚拟地址空间不再映射到物理内存),就操作系统而言,我们的进程不再消耗物理内存,尽管存在虚拟地址空间碎片化,如下表 6 所示
表 6 总结
内存碎片化 | VAS 碎片化 | 提交大小 (CommitSize) | 工作集 (WorkingSet) | 私有工作集 (PrivateWorking Set) | |
进程(使用 LOH) | 高 | 高 | 高 | 高 | |
进程(使用技术) | 高 | 高 |
使用进程(使用 LOH),我们正在丢失未被重用的完整页面内存,因为碎片化——外部碎片化。使用进程(使用技术),我们只在对象不在精确的页面边界时,在最后一页内存上浪费内存——内部碎片化。对于非常大的对象,内部碎片化的重要性会变得很小。
什么是页表(Page Table)?
简单来说,页表是用于将虚拟内存地址映射到物理内存地址的数据结构,或者更具体地说,是将虚拟页号映射到物理页帧号的数据结构,如图 15 所示。每个进程都有一个驻留在内核中的页表数据结构。进程在其分配对象时会使用其虚拟地址空间 (VAS) 中的虚拟内存地址。页表仅将这些来自 VAS 的对象的虚拟内存地址映射到实际的物理内存 (RAM) 或备份它们的页面文件,也如图 15 所示。当您将其分解为基本构建块时,整个过程会变得出奇地简单易懂。
内存管理单元 (MMU) 基本上是将虚拟地址(虚拟页号)转换为物理地址(物理页帧号)的硬件机制。每次访问虚拟地址时,它都必须经过 MMU 来将该虚拟地址映射到物理地址。MMU 使用页表将虚拟页面映射到物理页帧。在简单的单级线性页表中,每个页面有一个页表项 (PTE),如图 15 所示。
转换查找缓冲区 (TLB) 是 MMU 最近 PTE 的硬件缓存,效率非常高。页表不像 TLB 那样高效,因为它位于内存中,而不是硬件中。MMU 首先在 TLB 中查找 PTE,如果找到(TLB 命中),它将完全绕过查找页表。如果未找到(TLB 缺失),MMU 将回退到页表(页表遍历)查找 PTE,并更新 TLB 以供将来查找。
每个 PTE 都包含一个标志(有效位),指示页面当前是在 RAM 中,还是在磁盘上的页面文件中。如果页面不在 RAM 中(有效位 = 0),将发生“硬页错误”,导致操作系统将页帧从页面文件换回到 RAM,并使用新的页帧号更新 PTE。
具有合理大小虚拟地址空间的 32 位服务器可以使用简单的单级线性页表,如图 15 所示。然而,现代 64 位服务器由于其巨大的虚拟地址空间,需要一个更先进的多级分层页表,包含四级。对于如此大的虚拟地址空间,使用简单的单级线性页表效率太低。
VirtualAlloc 函数在进程的 VAS 中分配一个页面范围(虚拟地址)。页面是零填充的(在整个页面上用所有零初始化)。分配可以一步完成,也可以分成两个单独的步骤。
使用 MEM_RESERVE 的 VirtualAlloc 通过在进程的 VAD 树中创建虚拟地址描述符 (VAD) 来保留进程 VAS 中的一个连续页面范围(虚拟地址)。此时,页面(虚拟地址)仅在进程的 VAS 中保留,尚未映射到物理存储(RAM 或页面文件)。因此,它们尚未显示在进程的工作集、私有工作集或提交大小中。
使用 MEM_COMMIT 的 VirtualAlloc 为保留的页面(虚拟地址)在物理存储(RAM 或页面文件)中保留空间,以便操作系统可以保证在实际需要时提供所需的内存。此时,它仅保留空间,但尚未实际使用任何实际空间。提交的页面(虚拟地址)在首次被访问之前实际上不会消耗任何物理存储(RAM 或页面文件)。因此,它仅显示在进程的提交大小中,而不是在工作集或私有工作集中。该金额被添加到系统范围的“提交费用”(Commit Charge),该费用不得超过“提交限制”(Commit Limit)(系统中的 RAM 和页面文件的总和),以便操作系统能够兑现其稍后提供该内存的承诺。该金额也被添加到“进程页面文件配额”(Process Page File Quota) 中,该配额表示您的单个进程添加到系统范围“提交费用”的金额。然而,作为一项优化,它会推迟创建 PTE,直到页面首次在程序中被访问,这有点像“惰性加载”。首次访问已提交的内存页面会触发所谓的“按需零页错误”(demand-zero page fault)。对于极其大的分配,立即为每个页面创建 PTE 将是一个缓慢而繁重的操作。相反,这种“惰性加载”优化将开销推迟到实际需要时,甚至对于从不访问的页面可以完全避免。真正酷的是,金额仅显示在进程的提交大小中,直到虚拟地址首次被访问,它才会在进程的工作集或私有工作集中计数。非常方便的优化!
VirtualAlloc
同时使用 MEM_RESERVE
| MEM_COMMIT
,一步完成所有操作。图 14 和图 15 试图在某种程度上帮助可视化 VirtualAlloc
在概念上正在做什么。
要记住的一个重要细节是;虚拟地址空间 (VAS) 是连续的,物理内存 (RAM) 不是连续的。当你停下来思考时,需要连续性这一点,确实是导致碎片化如此严重的原因。碎片化本质上意味着不连续。反过来说,当某物不需要连续时,碎片化的概念就变得有些无关紧要了。
堆通常使用 VirtualAlloc
来获取连续的 VAS,从而继承了碎片化的问题。另一方面,物理内存 (RAM) 不需要连续,这使得碎片化问题变得有些无关紧要。这几乎就像将页面从 RAM 交换到页面文件以及反之亦然的操作本身在某种程度上就像 RAM 的自动碎片整理器。非常酷!
未来增强
理论上,您可以添加对 ArrayNoLOH<char>
的支持,它可能用作 .NET 字符串的内部后备存储,从而将大型字符串从 LOH 中移除。这对于大型 JSON 字符串来说可能非常有用。对于 Unicode,.NET char 为 2 字节,因此字符串长度只需超过大约 42,500 个字符就会被放入 LOH。
为了使其正常工作,可能需要对 .NET 字符串类进行一些修改。在 string.cs 的第 60 行 (https://referencesource.microsoft.com/#mscorlib/system/string.cs) 有一个私有成员变量(m_firstChar
),它似乎保存着字符串的 `char[]` 后备存储的第一个 char。许多代码似乎只是对该第一个 char 的地址执行操作。如果您以某种方式将 ArrayNoLOH<char>
实例的地址属性分配给(m_firstChar
),同时记住在字符串的 dispose 实现中释放它,您可能就可以成功地使用非托管内存作为字符串的后备存储,而无需托管字符串的任何察觉。
我们尚未尝试过这一点,目前纯粹是理论上的。它实际上可能会激怒 GC!GC 可能无法保持字符串的活动状态,因为它不知道非托管内存,我们并不确定。我们将这次实验留给读者作为一个发人深省的练习。
进一步研究
现代操作系统中的内存管理是一个极其复杂的主题,涵盖了广泛的复杂计算机科学概念。对 .NET 垃圾回收器和操作系统虚拟内存管理器设计所包含的所有复杂细节的完整解释超出了本文的范围。然而,对其有一个基本了解,在尝试理解本文提出的概念时将大有帮助。我们已尽力以极其简化和实用的方式进行解释,以便于可视化实际问题以及我们简单解决方案技术中涉及的概念。
讽刺的是,解释解决方案实际上是容易的部分——解释问题才是困难的。解决方案技术很简单——问题很复杂。文章的大部分内容都花在了介绍问题的背景,以解释为什么解决方案技术是必要的。尽管如此,我们希望它也能作为一份简单清晰的概述,让您更好地理解 GC 和内存,并希望激发您自己的新想法。
有关 .NET GC 内部工作原理的详尽详细概述,请参阅 Microsoft Developer Network 在线文档的 .NET Framework 开发指南中“内存管理和垃圾回收”部分的“垃圾回收”主题(https://msdn.microsoft.com/en-us/library/hh156531(v=vs.110).aspx)。
总结
在本文(第一部分和第二部分)中,我们探讨了一个理论;为了避免 LOH 碎片化,在处理 .NET 中的非常大的对象时,不使用堆结构是否更好?用地址碎片化换取内存碎片化,在如今巨大的 64 位地址空间中?用 LOH 碎片化换取 VAS 碎片化?是否使用堆,这确实是那个价值百万美元的问题……
我们概述了一种简单的编码技术,该技术直接通过 VirtualAlloc 与操作系统交互来处理大对象,从而完全避免 LOH,这有点类似于使用 HeapCreate 创建的传统非托管堆在分配超过一定大小时直接使用 VirtualAlloc 与操作系统交互的方式。本质上,该技术允许您将一个大对象容纳在一个小的托管对象中,使其放在 SOH 而不是 LOH,以避免 LOH 碎片化。大非托管内存是在构造函数中分配的,并在小托管对象的 dispose 中释放,从而防止内存泄漏,同时仍继续获得垃圾回收的好处。大的非托管内存基本上由 SOH 控制。基本上,它演示了如何在小型托管对象中使用大的非托管内存,以便内存将在 gen0 中释放(这很快且经过压缩),而不是进入 LOH。非常酷!
这种编码技术可能不是最佳实践,除非您遇到灾难性的服务器问题,否则不应在生产环境中使用。它仅仅是一种变通方法,可能在某些情况下为遇到 LOH 碎片化的服务器提供实际解决方案;然而,它仍然值得思考。
由于许多(如果不是大多数)堆是使用 HeapCreate()
创建的,其中大于 512KB(32 位)和 1024KB(64 位)的分配实际上已经绕过堆直接使用 VirtualAlloc 与操作系统交互,因此我们的技术对于大于约 83KB 的分配使用 VirtualAlloc 实际上并没有最初看起来那么不寻常。
即使您不使用我们的新技术,我们仍然希望为您提供一个实现流式接口的内存流类,该类具有可扩展的自定义后备存储,可用作模板来修改您的项目。您可以通过简单地替换 VirtualAlloc 代码来放置您自己的自定义后备存储。
我们提到,如果开发人员将机器配置为在并发模式下使用服务器垃圾回收以减少应用程序因垃圾回收而导致的冻结;这仍然不能解决 LOH 的碎片化问题。
我们还提到,如果开发人员手动从代码中压缩 LOH;压缩仅发生在下一次阻塞垃圾回收时,这意味着应用程序在垃圾回收期间仍然可能会暂停和冻结,同时将存活对象的内存移动。
我们试图以最简单的方式解释这个复杂的主题。所有这些都是希望它能激发有胆量的人跳出固有思维模式,想出一些创新的新想法,这些想法有潜力成为一个真正能解决问题的长期解决方案,而不是仅仅是伪装成永久修复的优化和变通方法。无论如何,探索这个主题本身就是一段极其有趣的旅程。
作为基础起点,我们包含了一个示例项目,实现了处理几个常见场景的代码,这些场景有时会导致 LOH 过度碎片化;其余的乐趣留给您继续探索……祝您编码愉快!