赞
踩
虽然我们只进入了第四章,但关于内存管理的各个方面,我们已经经历了相当长的旅程。 对它们进行了一般性讨论,以便对该主题进行更具理论性的介绍。 对 .NET 的具体引用非常罕见,毕竟这就是本书的主题。 是时候改变这个频率了。 从本章到本书结束,.NET 将一直伴随着我们。 在本章中,我们将从更广泛的角度来看待它,我们将学习它背后的一些机制,并且我们将开始深入研究与它如何管理内存相关的主题。 我强烈建议您在继续阅读本章之前先从前三章中获取知识,但将其视为一种可选方法。 从现在开始,随着我们越来越深入地了解 .NET,我还将假设一些有关 x86/x64 平台汇编语言的基本知识。 如果您需要更新一些知识,请阅读一本优秀的书,例如 Daniel Kusswurm 所著的《现代 X86 汇编语言编程》(Apress,2014 年)。
如果.NET Framework是一个男人,他现在就已经上初中了,几年后慢慢开始准备高考。 换句话说,它是一个已经开发和使用了大约15年的产品。 在此期间,丰富的附带库集合和运行时环境本身都发生了显着的发展。 所有 .NET 开发人员都必须熟悉基础知识 - C# 的标准库和语法知识 - C# 是 .NET 环境中使用的主要编程语言(或其他语言,例如 VB.NET 不断失去人气,而 F# 不断获得流行)。 这是我们的“日常面包”。 然而,随着年龄的增长,或者随你的喜好,随着经验的增长,常常会反思值得了解更多。 那么让我们多了解一点吧!
请注意,本书重点关注内存管理,仅简要提及其他与 .NET 相关的主题。 因此,例如,不要期望详细描述 C# 语言功能或解决多线程问题。 还有许多其他专门针对它们的优秀书籍和在线材料。
.NET 环境并不像乍一看那样同质。 它通常与最流行的 .NET Framework 版本相关联,该版本从 1.0 版运行,经过 2.0、3.5 或 4.0 等版本,一直到当前版本 4.7.2。 但是,当我们谈论 .NET 环境时,您现在确实可以想到它的版本和实现的许多丰富性。 实现如此丰富的一个重要方法是标准化。 从一开始,整个 .NET 概念就基于称为公共语言基础设施 (CLI) 的规范。 这一基本技术标准(2003 年标准化为 ECMA 335 和 ISO/IEC 23271)描述了代码和运行时环境的概念,允许其在不同的机器上使用而无需重新编译。 我将在本章中多次提到它,因为没有比这更好的事实来源了。
描述 CLI 的所有组件,包括所有实现变化以及它们之间的差异,是非常诱人的。 然而,我们将主要关注它们如何影响我们所关注的主题。 现在让我们看一下内存管理和垃圾收集上下文中的各种 .NET 变体:
.NET Framework 1.0 - 4.7.2 - 自 2002 年开发以来,是我们众所周知的商业且最成熟的产品。 它已经存在多年,因此垃圾收集器的核心已经在各个版本中得到开发和改进。 多年来,这个主题被视为一个黑匣子,在发布新的 .NET 版本时或多或少随意地描述过。 由于.NET Framework的商业运行时代码是封闭的,这些机制到底是如何运作的,我们主要可以通过微软自己提供的信息来了解。 这些信息非常详细,使我们能够理解和诊断应用程序中的内存问题。 但开发人员仍然有点不满意,特别是当你面对源代码的开放性时,例如 Java。
共享源 CLI(也称为 Rotor)- 于 2002 年(版本 1.0)和 2006 年(版本 2.0)发布,用于教育和学术目的的运行时实现。 它从来没有打算运行生产代码。 它让您了解 CLR 的众多实现细节。 甚至还有一本很棒的书,即由 David Stutz、Ted Neward 和 Geoff Shilling 合着的《Shared Source CLI Essentials》(O’Reilly Media,2003 年),其中详细介绍了此版本。 然而,首先,它并没有完全实现一个“成熟”的.NET 2.0框架。 其次,不幸的是,它的实现有时与正确的 CLR 非常不同,尤其是在内存管理领域。 那里只实现了一个非常简化的垃圾收集器。
.NET Compact Framework - 自 Windows CE/Mobile 和 Xbox 360 时代以来的 .NET“移动”版本。 它的垃圾收集器与主版本有很大不同,并且更加简化,例如,它不包括生成概念(我们将在下一章中了解)。 不过,这已经是一个历史制度了,或许我们不必再担心了。 但在该框架的开发过程中我们吸取了很多经验教训,特别是因为针对运行 Windows CE 设备的各种处理器等平台的移植。 这就是我们所知的 CoreCLR 的概念起点。\
Silverlight - 一个 Web 浏览器插件,允许您像普通窗口应用程序一样运行应用程序。 由于 Microsoft 在 .NET 2.0 时代开始构建它,因此它基于该时期的运行时副本。 如果您仍然使用它,许多有关当前 .NET 的信息也将适用于此处。 只不过这必须是有关 .NET 2.0 较旧运行时版本的信息。 这是移植到 OSX 平台的运行时,为当前的 CoreCLR (.NET Core) 运行时提供了代码库。
.NET Core(其运行时称为 CoreCLR) - 开源版本的出现。 NET 已经发生了很大的变化。 从现在开始,就有了可用于生产的运行时代码,我们可以自己深入研究。 更重要的是,垃圾收集器代码实际上是从商业运行时代码复制而来的。 看来 .NET Core 可以慢慢开始超越 .NET Framework 的功能,而 .NET Framework 的更改将陆续“合并”回来。 .NET Core也是官方支持的跨平台解决方案。 它适用于 Windows、Linux 和 MacOS
Windows Phone 7.x、Windows Phone 8.x 和 Windows 10 Mobile - 系统的旧版本基于 .NET Compact Framework 3.7 中已知的简单内存管理。 Windows Phone 8.x 引入了内部 .NET 运行时的重大增强,它基于成熟的 .NET Framework 4.5 版本,继承了其垃圾收集器。
.NET Native - 一种允许 CIL 代码直接编译为机器代码的技术。 它基于名为 CoreRT(以前称为 MRT)的轻量级运行时。 他们与 .NET Core 共享垃圾收集器代码。
.NET Micro Framework - 针对小型设备的单独实现,具有开源代码。 最流行的应用程序是 .NET Gadgeeter,它包含自己的垃圾收集器的简化版本。 由于该解决方案的利基和业余爱好性质,我们不会在本书中讨论它。
WinRT - 一种向开发人员公开操作系统功能的新方法,它设置了用于构建以 JavaScript、C++、C# 和 VB.NET 语言提供的 Metro 风格应用程序的 API,并将取代 Win32。 它是用 C++ 编写的,实际上根本不是 .NET 实现。 但它是面向对象的,并且基于 .NET 元数据格式,因此它可能看起来像普通的 .NET 库(尤其是在 .NET 内部使用时)。
Mono - CLI 的完全独立的跨平台实现,具有自己的内存管理。 了解它对于理解 .NET 的主题并没有多大帮助。 然而,至少有两种非常流行的基于该技术的解决方案:Xamarin,用于编写移动应用程序的框架; 以及流行的游戏引擎Unity3D。 由于这些项目的受欢迎程度,我们有时会通过比较来看待 Unity。
从上面的列表中可以看到一幅非常积极的图景 - 内存管理机制与当前使用的所有主要 .NET 平台非常相似(不是说 - 几乎相同) - .NET Framework、.NET Core 和 用于.NET Native。
本书基于.NET Core 2.1源代码,对.NET中垃圾收集器的内部机制进行了全面的解释。 正如我们提到的,此实现与 .NET Framework 的主要变体和移动变体有很大的融合。 因此,依赖 .NET Core 的源代码是一种非常有价值且全面的信息获取形式。 在下文中,当显示 .NET 源代码示例时,除非另有说明,我默认指的是 .NET Core 2.1 源代码。 我还参考了与运行时本身并行开发的所谓“运行时之书”开源文档,可从 https://github.com/dotnet/coreclr/blob/master/Documentation/botr/README.md 获取。 它包含有关运行时实现的许多有价值的信息。
我们应该了解一些 .NET 内部结构才能充分理解内存管理主题。 然而,我们现在将通过省略本文中不需要的许多信息来研究它们。 还有许多其他有价值的资源,您可以在其中找到更多信息,包括 Jeffrey Richter 撰写的《CLR via C#》一书(Microsoft Press,2012 年); Pro .NET Performance,作者:Sasha Goldshtein(Apress,2012 年); 或编写高性能 .NET 代码,作者:Ben Watson(Ben Watson,2014 年)。
当用 C 或 C++ 编写程序时,编译器将其编译为可执行文件。 然后它可以直接在目标机器上执行,因为除了与操作系统配合的库之外,它还包含由处理器直接执行的二进制代码。
另一方面,.NET 运行时环境有很多重要的职责,这些职责共同使整个事情完成它应该做的事情 - 执行我们编写的应用程序。 与用 C 或 C++ 编写的程序不同,当您用 C#、F# 或任何其他 .NET 兼容语言编写程序时,它会被编译为所谓的 CIL(通用中间语言)。 然后,公共语言运行时 (CLR) 使用此代码。 CLR 是所有托管魔法发生的地方。 在 CLR 之上,有整个 .NET 框架的更一般概念 - 包括所有标准库和工具(因此我们有各种 .NET 框架版本,可能包括也可能不包括运行时更改)。 CLR有几个职责,其中我们主要可以区分:
我们经常将这些职责分为两个主要部分:
所有这些元素一起工作,就像在一台充满大大小小的块的折叠良好的机器中一样。 移除其中一个并期望整台机器继续工作是很困难的。 内存管理也是如此。 我们可以讨论内存管理机制,但最好认识到其他组件与其密切合作。 例如,JIT 编译器生成变量的生命周期信息,然后供垃圾收集器使用。 类型系统提供做出关键决策所需的信息 - 例如,类型是否具有所谓的终结器。 异常处理必须以了解内存回收机制的方式编写 - 例如,在垃圾收集发生时停止。 CLR 中各个组件的许多此类功能都是非常有趣的小事实。
我们可能经常听说 .NET 环境中的托管代码。 它的具体含义是,运行时执行的代码应该能够与其配合来提供上述职责。 正如 ECMA-335 标准所说:
托管代码:包含足够信息以允许 CLI 提供一组核心服务的代码。 例如,给定代码内方法的地址,CLI 必须能够找到描述该方法的元数据。 它还必须能够遍历堆栈、处理异常以及存储和检索安全信息。
总而言之,让我们看一下执行应用程序的 .NET 运行时的鸟瞰图(见图 4-1)。
图 4-1。 源代码(文本文件)被编译为通用中间语言(二进制文件)。 然后,在安装了 .NET 运行时的目标计算机上,它由运行时本身运行。 它由两个主要单元组成:执行引擎(EE)和垃圾收集(GC)。 EE 从二进制文件中获取 CIL,并将其在内存中转换为机器代码。
我们可以将这个过程描述为包含以下步骤:
现在可能是解释我们可能遇到的与 .NET 环境相关的一些常见误解的好时机:
现在让我们逐步了解编译和运行一个简单的 Hello world 应用程序(参见清单 4-1),以更好地理解一些 .NET 内部结构。 这将使我们熟悉稍后需要的一些基本概念。 每个学习过 C# 的人可能都认识这个示例,其唯一目的是在控制台上显示简短的文本。 我们将使用它作为在 Windows 上的 .NET Core 2.1 运行时下运行的游乐场。 显然,我们不会在这里太深入,因为我们最感兴趣的是内存管理的东西。 如果您确实对 .NET 运行时如何加载自身、管理其类型和类似主题感兴趣,那么我再次推荐之前介绍的优秀书籍。
清单 4-1。 用 C# 编写的示例 Hello World 程序
using System;
namespace HelloWorld
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello world!");
}
}
}
清单 4-1 中的示例代码在由 C# 编译器(此处使用 Roslyn,此处使用 Visual Studio 2017)编译时,将生成一个 DLL 文件,在我的例子中,该文件称为 CoreCLR.HelloWorld.dll。 该文件包含运行此类程序所需的所有数据。 我们可以详细地看到它,例如,通过dnSpy工具打开它。 完成此操作后,我们可以浏览文件的各个解码部分(见图 4-2):
每个方法或类型都有其唯一的标识符(称为令牌),并且由于上面提到的元数据流,它的位置在文件中是可识别的。 因此,我们可以识别包含每个方法主体的文件区域。 例如,要查看 Main 方法主体,请从 Assembly Explorer 中选择它,然后使用其上下文菜单中的 Show Method Body in Hex Editor 选项(参见图 4-3)。
图 4-3。 包含 Program.Main 方法的通用中间语言指令的几个字节(为了清楚起见,添加了箭头)
当然,查看原始字节,确实很难理解它们的含义。 但是,借助第 3 章中提到的反编译,我们可以将每个方法的 CIL 解码为更易读的形式。为此,只需在 Assembly Explorer 中选择 Main 方法,然后从 dnSpy 菜单中选择 IL 作为反编译语言。
从 CoreCLR.HelloWorld.dll 反编译 Program 类型的结果如清单 4-2 所示(为了清楚起见,已删除构造函数)。 在注释中,我们可以看到给定指令的原始字节码(例如,字节 2A 代表 ret CIL 指令),因此现在我们可以完全理解图 4-3 中突出显示的 7201000070280C00000A2A 字节。
如果我们看一下 Main 方法的简单 CIL 代码(参见清单 4-2),我们将看到它是如何编译成堆栈机器代码的:
清单 4-2。 清单 4-1 中的示例程序已转换为通用中间语言。 输出来自 dnSpy 工具。
// Token: 0x02000002 .class private auto ansi beforefieldinit CoreCLR.HelloWorld.Program extends [System.Runtime]System.Object { // Token: 0x06000001 .method private hidebysig static void Main ( string[] args ) cil managed { // Header Size: 1 byte // Code Size: 11 (0xB) bytes .maxstack 8 .entrypoint /* 7201000070 */ IL_0000: ldstr "Hello World!" /* 280C00000A */ IL_0005: call void [System.Console]System. Console::WriteLine(string) /* 2A */ IL_000A: ret } // end of method Program::Main } // end of class CoreCLR.HelloWorld.Program
如果仔细观察清单4-2的代码,可以看到一条.maxstack 8指令,这似乎与程序执行有关。 然而,这不是 CIL 指令。 各种工具可以使用此类元数据描述来验证代码安全性。 maxstack 告诉由于方法执行可以在计算堆栈上分配多少个最大字节。 对于 Main 方法,字符串文字引用需要八个字节。 像 PEVerfiy 这样的工具可以使用此信息来对抗方法的 CIL 代码想要执行的操作。 这使得 .NET 代码可验证且安全,因为多种缓冲区溢出是计算机环境中最危险的威胁。
在考虑 .NET 堆栈计算机时,我们应该提到一个重要的位置概念。 当考虑存储程序执行所需的各种值时,存在一些逻辑位置:
如何将每个位置映射到特定的计算机体系结构是 JIT 编译器的唯一责任,我们将深入探讨这一点。
注意 目前 .NET 生态系统中可用的 JIT 编译引擎很少:
- .NET Runtime(直到版本 4.5.2)和 .NET Core 1.0/1.1 用于 x86 架构(32 位版本)的旧版 x86 JIT
- .NET Runtime 使用旧版 x64 JIT,直至版本 4.5.2
- .NET Core 2.0(及更高版本)和 .NET Framework 4.6(及更高版本)使用新的 RyuJIT 进行 32 位和 64 位编译。
- 适用于 x86 和 x64 平台的 Mono JIT
由于替换旧引擎是一项持续的工作,因此我在这里仅关注新的 RyuJIT 引擎。
现在,如果我们想看看在 64 位 Windows 下我们的程序如何通过 JIT 翻译成机器代码,我们可以使用 WinDbg。 显然,我们需要运行我们的应用程序,因为它会触发引导运行时和必要方法的 JIT 编译。
假设我们使用作为通用 Windows 应用程序分发的最新 WinDbg,我们可以从“文件”面板中选择“启动可执行文件(高级)”并提供以下参数(假设我们的解决方案位于 C:\Projects):
许多人喜欢从命令行启动 WinDbg 来调试程序。 在我们的例子中,要启动调试会话,您可以使用以下命令:windbgx C:\ Program Files\dotnet\dotnet.exe C:\Projects\CoreCLR.HelloWorld\ bin\x64\Release\netcoreapp2.1\CoreCLR.HelloWorld .dll
单击“确定”后,Hello world 应用程序将启动,并且其执行将立即中断。 我们现在需要设置一个断点,在程序终止之前(在打印 Hello World! 消息之后)停止程序。 我们可以指定以下命令:
bp coreclr!EEShutDown
现在点击 Go 并稍等片刻,直到到达该断点。 之后我们应该加载一个 SOS 扩展(在第 3 章中提到)并使用命令查找 Main 方法:
.loadby sos coreclr
!name2ee *!CoreCLR.HelloWorld.Program.Main
第二个应该产生以下输出 - 表示 Main 方法的 JITted 代码位于地址 00007ffbca3e06b0 下:
Module: 00007ffbca284d78
Assembly: CoreCLR.HelloWorld.dll
Token: 0000000006000001
MethodDesc: 00007ffbca285d30
Name: CoreCLR.HelloWorld.Program.Main(System.String[])
JITTED Code Address: 00007ffbca3e06b0
我们可以使用 !U 00007ffbca3b0480 命令来查看发出的汇编代码,结果如清单 4-3 所示。 我们看到执行步骤如下:
清单 4-3。 由清单 4-2 中的 JITting 代码生成的机器代码
Normal JIT generated code
CoreCLR.HelloWorld.Program.Main(System.String[])
Begin 00007ffbca3b0480, size 1c
00007ffbca3b0480 4883ec28 sub rsp,28h 00007ffb
ca3b0484 48b96830ca6c4d020000 mov rcx,24D6CCA3068h
00007ffbca3b048e 488b09 mov rcx,qword ptr [rcx] 00007ffb
ca3b0491 e89afeffff call 00007ffbca3b0330 (System. Console.WriteLine(System.String), mdToken: 0000000006000083) 00007ffb
ca3b0496 90 nop
00007ffbca3b0497 4883c428 add rsp,28h 00007ffb
ca3b049b c3 ret
这就是我们简单的 C# 程序通过 CIL 转换为可执行代码的方式。 ldstr 和 call CIL 指令使用的计算堆栈位置已被 JIT 编译器用作 CPU 寄存器 rcx。 Main 方法内部没有栈或堆分配 - 但请记住,运行时本身和框架程序集已经进行了一些分配。
由于在函数调用期间使用寄存器和内存的可能方法有很多,因此存在称为调用约定的标准化方法。 它们定义如何在方法调用期间传递参数和管理堆栈以及如何返回值。 在本书中说明汇编代码时,我假设采用 Microsoft x64 调用约定。 为了我们的目的,这套规则进行了简化,规定:
请注意,Linux x64 调用约定有所不同,因此如果需要,请随时阅读此内容。
我希望这个非常短暂但可能且有点令人难以承受的旅程向您展示什么是 .NET 运行时。 最后,所有方法都被 JIT 编译为常规汇编代码,可选地利用运行时的一些“托管”部分。
.NET 环境中的基本功能单元称为程序集。 1 它可以被视为一堆可以由 .NET 运行时执行的存储的 CIL 代码。 程序至少由一个或多个程序集组成。 例如,当我们编译清单 4-1 中的代码时,我们生成了由 CoreCLR.HelloWorld.dll 文件表示的单个程序集。 此类程序还使用各种其他程序集,从基本类库(称为 mscorlib,包括 System.IO、System.Collections.Generic 等重要的命名空间)等开始。 复杂的 .NET 应用程序可能由许多不同的程序集组成,其中包含我们的代码。 在源项目管理方面,存在简单的对应关系 - 我们的解决方案中的一个项目被构建到单个程序集中。 还可以在程序执行期间创建动态程序集(通常用于将动态创建的代码发送到此类动态程序集中),这是各种序列化程序经常使用的功能。
换句话说,程序集可以被视为托管代码的部署单元,它通常与某些 DLL 或 EXE 文件一一对应(此类文件称为模块)。
.NET Framework 提供了隔离托管应用程序代码(程序集)的不同部分的可能性,将它们分成所谓的应用程序域(通常缩写为 BCL 类型名称的 AppDomains)。 由于安全性、可靠性或版本控制的需要,可能需要这种分离。 要从程序集执行代码,我们必须将其加载到某个应用程序域(这同样适用于动态创建的程序集)。
程序集和 AppDomain 之间存在相当复杂但有详细记录的关系。 请参阅这个优秀的 .NET Framework 文档:https://docs.microsoft.com/en-us/dotnet/framework/appdomains/application-domains 了解详细信息。
保持 .NET Core 较小需要删除一些功能,AppDomains 就是其中之一。 对于它们提供的功能和它们所需的功能来说,它们太重了。 因此,.NET Core 中没有公开与应用程序域处理相关的 AppDomain API。 然而,负责它们的代码片段在 CoreCLR 中仍然可用,因为运行时本身在内部使用它们。 对于开发人员,微软建议使用普通的旧进程或闪亮的新容器来隔离 .NET Core 应用程序。 至于程序集的动态加载,您可以查看一个新的 AssemblyLoadContext 类。
AppDomain 符合我们的兴趣,因为它们影响 .NET 进程的内存结构。 一般来说,运行时可以创建几个不同的应用程序域:
对于 .NET Core,显然没有动态创建的域。 所有共享代码都有共享域责任。 所有用户代码都有一个默认的 AppDomain。 系统域在进程内存中物理上不可见,但也包括其结构和逻辑。
我们加载的程序集包含一个清单,描述它们需要哪些其他程序集。 标准 CLR 行为包括将所有必需的程序集加载到主应用程序域中 - 该域将在整个程序执行过程中存在。 这对于大多数情况来说都很好,但在某些情况下我们希望对程序集的生命周期有更多的控制:
对于 .NET Framework,可以通过卸载其加载到的整个应用程序域来间接卸载程序集。 因此,例如,处理用户定义脚本的典型场景包括创建动态应用程序域、使用已编译脚本发出程序集、将其加载到临时应用程序域中、执行代码,以及最终卸载此类应用程序域。 对于 .NET Core,由于 AppDomain 的 API 不可用,此类场景目前不可用(在撰写本文时,使用 .NET Core 2.1)。
虽然在 .NET Framework 情况下,它是一个完美工作的解决方案,但它有自己的注意事项 - 特别是应用程序域之间的远程通信的成本。
正是由于提到的开销,大多数情况下,即使需要创建动态程序集,它们也只是简单地加载到主应用程序域中 - 即使这意味着它们之后无法卸载(因为这需要卸载应用程序本身)。 这是我们在 .NET 中遇到的流行 XmlSerializer 的情况,这可能会导致本章后面的场景 4-4 中描述的内存泄漏。
因此,出现了一种更轻质、可收藏的组件的想法。 可收集程序集是可以卸载的动态程序集,而无需卸载它所在的应用程序域。 它在上述所有场景中都非常有意义。 但是,它们目前在两个 Microsoft .NET 运行时中均不可用。 请继续关注 .NET Core 公告,因为有关可卸载 AssemblyLoadContext 的工作正在进行中
在 .NET Framework 中,可收集程序集已实现,但仅部分实现,以防在 Reflection.Emit 的帮助下手动发出代码。 正如 MSDN 文档所述:“ Reflection Emit 是唯一支持加载可收集程序集的机制。 通过任何其他形式的组件加载方式加载的组件都无法卸载。”
正如第 2 章中提到的和图 2-20 所示,进程内的 .NET 运行时管理多个内存区域。 当我们考虑 .NET 进程的内存使用情况时,我们应该考虑其中的每一个。 让我们一一研究这些领域,以了解 .NET 流程的剖析。 我们将使用优秀的 VMMap 工具,它向我们显示我们所附加的进程中使用的内存区域。 下面显示的内存区域来自清单 4-1 中退出应用程序之前的那一刻。
当我们查看 Hello World 应用程序内部时,我们将看到如图 4-4 所列的内存区域。 为了解释这样的 VMMap 输出,有必要回顾一下第 2 章中介绍的虚拟内存区域的描述。正如我们所看到的,该进程有近 128 TB 的空闲内存(对应于 64 上的 128 TB 虚拟地址空间) 位平台)。
图 4-4。 VMMap 工具中显示的内存区域,用于清单 1.64 位 .NET Core 2.0 运行时中正在运行的应用程序
让我们从 .NET 角度看一下所有这些项目以及简要描述和含义:
图像(大约 37 MiB)- 二进制图像,包含各种二进制文件的图像,包括 .NET 运行时本身和带有 .NET 程序集的库。 请注意,此空间大部分是共享的,只有 772 KiB 是私人工作集。 这些是应用程序启动期间从磁盘读取的文件。
堆栈(大约 4.5 MiB) - 我们的 Hello World 应用程序中有三个线程,因此有三个专用于它们的栈区域。
堆和私有数据(大约 9 MiB) - 这些是 .NET 运行时出于其内部目的而管理的各种本机内存区域。 它们主要存储与我们无关的东西(甚至在没有深入的 CoreCLR 源分析的情况下不知道)。 但是,我们可能会注意到这里存储了一些供执行引擎和垃圾收集器使用的基本数据结构,例如:
我们可以将上面表示为托管堆的组进一步分为以下几类:
注意 默认情况下,这些堆非常小,相当于单页的数量级 - 通常约为 64 KiB。 我们可以在 CoreCLR 默认大小定义中看到这一点:
#define LOW_FREQUENCY_HEAP_RESERVE_SIZE (3 * GetOsPageSize())
#define LOW_FREQUENCY_HEAP_COMMIT_SIZE (1 * GetOsPageSize())
#define HIGH_FREQUENCY_HEAP_RESERVE_SIZE (10 * GetOsPageSize())
#define HIGH_FREQUENCY_HEAP_COMMIT_SIZE (1 * GetOsPageSize())
#define STUB_HEAP_RESERVE_SIZE (3 * GetOsPageSize())
#define STUB_HEAP_COMMIT_SIZE (1 * GetOsPageSize())
请记住,任何类型一旦加载到 Loader Heap 区域,在整个相应的 AppDomain 被卸载之前都不会被卸载。 如果我们不断加载大量类型(例如,动态加载或生成程序集),最终可能会占用大量内存。 此外,默认的 AppDomain 在程序停止之前不会被卸载。
正如第 2 章中提到的,可以更改程序线程的默认堆栈大小。 这可以借助 Visual Studio 附带的 dumpbin 命令行程序来实现。 通过发出以下命令,它会适当地编辑所提供的可执行文件的二进制头:
editbin DotNet.HelloWorld.exe /stack:8000000
对于基于 .NET Framework 的可执行文件(如上所述),它当前可以工作,但应被视为不受支持的方法 - 不能保证将来 .NET Framework 在创建线程时不会忽略这些值。 对于基于 .NET Core 的构建,可执行文件本身就是运行时启动器,通常位于 C:\Program Files\dotnet\dotnet.exe。 我们需要借助 editbin 来编辑此文件,以更改 .NET Core 应用程序中线程的堆栈大小,这在大多数情况下显然是不可接受的。 因此,尽管以所描述的方式操纵堆栈大小是可能的,但我们根本不应该依赖它。
现在让我们转向本书的重要部分之一——我们的第一个场景。 一如既往,它由一些情况描述以及如何分析和解决问题的描述组成。
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。