使用.NET工具调试System.OutOfMemoryException
在这篇文章中,我将讨论一个更棘手的异常:System.OutOfMemoryException。顾名思义,当.NET应用程序内存不足时抛出异常。有在MSDN文章中,OutOfMemoryException有两种不同的原因:
- 试图将StringBuilder对象扩展到其StringBuilder.MaxCapacity属性定义的长度之外。此类错误通常会附加以下消息:“内存不足,无法继续执行程序。”
- 公共语言运行库(CLR)无法分配足够的连续内存。
在我过去的.NET开发生涯中,我没有遇到过第一个问题,为什么我不会花太多时间在上面。简而言之,这样做将导致System.OutOfMemoryException:
StringBuilder sb = new StringBuilder(1, 1);
sb.Insert(0, "x", 2);
“内存不足”不是指物理内存
我开始在x86计算机上编程,当时英特尔处理器启用的内存管理策略发生了巨大而迅速的变化。不得不知道“扩展内存”和“扩展内存”之间的区别的痛苦随着时间的推移而逐渐消失,幸运的是,我的记忆中也有了确切的区别。
作为早期经验的结果,我偶尔会惊讶于这样一个事实:许多专业程序员似乎对内存管理有着自“80286保护模式”之前就没有过的想法。
例如,我偶尔会问“我有一个‘内存不足’的错误,但是我检查了一下,机器有足够的内存,这是怎么回事?
想象一下,当你用完机器的内存时,你会想到你的内存量是相关的!多迷人啊!
我认为,用大多数方法来描述现代虚拟内存管理的问题在于,它们从假设DOS世界开始——即“内存”等于RAM,也就是“物理内存”,而“虚拟内存”只是一个让物理内存看起来更大的聪明技巧。尽管从历史上看,这是虚拟内存在Windows上的发展方式,也是一种合理的方法,但我个人并不是这样将虚拟内存管理概念化的。
所以,快速勾勒一下我对虚拟记忆的概念。但首先要注意。现代的Windows内存管理系统远比这个简图复杂和有趣,简图的目的是给人一种虚拟内存管理系统的总体感觉,以及一些清晰思考存储和寻址之间关系的心理工具。它绝不是真正的内存管理器的教程。
我首先假设您理解两个不需要额外解释的概念:操作系统管理进程,操作系统管理磁盘上的文件。
每个进程都可以拥有它所需要的数据存储空间。它要求操作系统为它创建一定数量的数据存储,而操作系统就是这样做的。
现在,我已经确信,神话和先入之见开始涌入。当然,这个过程不能要求“它想要多少”。当然,32位进程最多只能要求2 GB。或者说,32位进程所需的数据存储空间肯定只有RAM那么多。这两个假设都不对。为进程保留的数据存储量仅受操作系统可以在磁盘上获得的空间量限制。
这是关键的一点:在我看来,我们称之为“进程内存”的数据存储最好被看作是磁盘上的一个大文件。
因此,假设32位进程需要大量存储,并且它多次请求存储。可能它总共需要5 GB的存储空间。操作系统在文件中找到足够的5GB磁盘空间,并告诉进程存储空间确实可用。然后该进程如何写入该存储?该进程只有32位指针,但唯一标识5GB存储空间中的每个字节至少需要33位。
解决这个问题是事情开始变得有点棘手的地方。
5GB的存储空间被分成几个块,通常每个块4KB,称为“页面”。操作系统为进程提供了4GB的“虚拟地址空间”(超过100万页),可以通过32位指针寻址。然后,这个过程告诉操作系统应该将5GB磁盘存储中的哪些页“映射”到32位地址空间。
映射完成后,操作系统知道当进程98试图在其地址空间中使用指针0x12340000时,这对应于,例如,页2477开头的字节,并且操作系统知道该页存储在磁盘上的位置。当从该指针读取或写入该指针时,操作系统可以确定引用了磁盘存储的哪个字节,并执行相应的读或写操作。
“内存不足”错误几乎从来不会发生,因为没有足够的可用存储空间;正如我们所看到的,存储空间就是磁盘空间,现在磁盘很大。相反,会发生“内存不足”错误,因为进程无法在其虚拟地址空间中找到足够大的连续未使用页部分来执行请求的映射。
4GB地址空间的一半(或者在某些情况下,四分之一)被预留给操作系统来存储特定于进程的数据。在剩余的“用户”地址空间的一半中,很大一部分被构成应用程序代码的EXE和DLL文件占用。即使总共有足够的空间,地址空间中也可能没有足够大的未映射“洞”来满足进程的需要。
该进程可以通过尝试标识不再需要映射的虚拟地址空间部分来处理这种情况,“取消映射”它们,然后将它们映射到存储文件中的其他一些页。如果32位进程设计用于处理大量的多GB数据存储,显然这就是它必须做的。通常这样的程序正在做视频处理或其他类似的事情,并且可以安全和容易地将大块的地址空间重新映射到“内存文件”的其他部分。
但如果不是呢?如果进程是一个更正常、性能更好的进程,只需要几亿字节的存储空间,那会怎么样?如果这样一个进程只是正常运行,然后试图分配一些大量字符串,那么操作系统几乎肯定能够提供磁盘空间。但是这个过程如何将大量字符串的页面映射到地址空间呢?
如果碰巧没有足够的连续地址空间,那么进程将无法获得指向该数据的指针,并且它实际上是无用的。在这种情况下,进程会发出“内存不足”错误。现在用词不当。它真的应该是一个“找不到足够的连续地址空间”错误;有足够的内存,因为内存等于磁盘空间。
我还没提到RAM。RAM可以看作是一种性能优化。在RAM中访问数据,其中的信息存储在以接近光速传播的电场中,比在磁盘上访问数据快得多,磁盘上的信息存储在以接近Miata速度移动的巨大重金属分子中。
操作系统跟踪访问进程最频繁的存储页面,并在RAM中复制它们,以提高速度。当进程访问与当前未缓存在RAM中的页相对应的指针时,操作系统会执行“页错误”操作,跳出磁盘,将页从一个磁盘复制到另一个RAM,从而合理地假设不久将再次访问该页。
操作系统在共享只读资源方面也非常聪明。如果两个进程都从同一个DLL加载同一页代码,那么操作系统可以在两个进程之间共享RAM缓存。由于代码可能不会被任何进程更改,所以通过共享来保存重复的RAM页是非常明智的。
但即使有了巧妙的共享,最终这个缓存系统也会耗尽RAM。当这种情况发生时,操作系统会猜测哪些页面最不可能很快再次被访问,如果它们已经更改,则会将它们写到磁盘上,并释放RAM以读入更可能很快再次被访问的内容。
当操作系统猜错了,或者更可能的是,当没有足够的内存来存储所有运行进程中所有频繁访问的页面时,机器就会开始“颠簸”。操作系统把所有的时间都花在写和读昂贵的磁盘存储器上,磁盘不停地运行,而你没有完成任何工作。
这也意味着“内存不足”很少导致“内存不足”错误。它不是一个错误,而是导致了糟糕的性能,因为存储实际上在磁盘上这一事实的全部成本突然变得相关起来。
另一种看法是,程序消耗的虚拟内存总量实际上与其性能没有很大关系。相关的不是虚拟内存的总消耗量,而是(1)有多少内存没有与其他进程共享,(2)常用页的“工作集”有多大,以及(3)所有活动进程的工作集是否大于可用RAM。
现在应该很清楚为什么“内存不足”错误通常与您拥有多少物理内存,甚至与可用的存储量无关。它几乎总是与地址空间有关,在32位窗口上,地址空间相对较小,并且很容易被分割。
当然,这些问题中的许多在64位窗口上都会消失,因为64位窗口的地址空间是原来的数十亿倍,因此很难分割。(当然,如果物理内存小于总工作集,那么无论地址空间有多大,都会出现抖动问题。)
这种虚拟内存的概念化方式完全与通常的设想背道而驰。通常,它被认为是一块物理内存,当物理内存太满时,物理内存的内容会交换到磁盘上。但我更愿意把存储看作是磁盘存储的一块,而物理内存则是一种智能缓存机制,可以使磁盘看起来更快。也许我疯了,但这有助于我更好地理解它。
好吧,我撒谎了。32位Windows将磁盘上的进程存储总量限制为16 TB,64位Windows将其限制为256 TB。但是如果有足够的磁盘空间,没有理由单个进程不能分配多GB的磁盘空间。许多电气工程师向我指出,当然,单个电子的移动根本不快;正是电场的移动如此之快。我已经更新了文本,希望你们现在都满意。在某些虚拟内存系统中,可以将页标记为“此页的性能非常关键,因此必须始终保留在RAM中”。如果这样的页面多于可用的RAM页面,那么您可能会因为没有足够的RAM而出现“内存不足”错误。但这种情况比地址空间不足要难得多。
Visual Studio调试器指南---异常助手
**“异常助手”**是一项用于调试 Visual Basic 和 C# 应用程序的功能。 **“异常助手”比其他语言(例如 C/C++)所使用的“异常”**对话框提供了更多有关异常的信息。这样便更易于查找异常原因并解决问题。
**“异常助手”**在发生运行时异常时出现,它显示异常的类型、故障排除提示以及纠正操作。 **“异常助手”**还可以用来查看异常对象的详细信息。异常是从 Exception 类继承的对象。 异常在出现问题时由代码引发,并在堆栈中向上传递,直到应用程序对其进行处理或者程序失败。
单击**“操作”窗格中的“查看详细信息”**。 即出现一个对话框,显示异常的属性。
下表列出并介绍了一个异常对象的属性。 并非所有属性都会出现,具体取决于异常的类型。
Property |
说明 |
---|---|
数据 |
包含用户定义的键/值对的 IDictionary 对象。 默认值为空集合。 |
FileName |
导致异常的文件的名称。 |
FusionLog |
描述程序集加载失败的原因的日志文件。 |
指向与异常相关联的帮助文件的链接。 |
|
赋给特定异常的编码数值。 |
|
导致当前异常的 Exception 实例。 有时,在帮助器例程中捕获引发的异常,并引发对错误更具描述性的新异常,从而提供更多信息,这么做很有用。 在这种情况下,InnerException 属性将设置为原始异常。 |
|
与异常相关联的消息。 消息以引发异常的线程的 CurrentUICulture 属性所指定的语言显示。 |
|
导致异常的应用程序或对象的名称。 如果未设置 Source,将返回产生异常的程序集的名称。 |
|
引发当前异常时调用堆栈上的方法调用的字符串表示形式。 如果有可用的调试信息,则堆栈跟踪包含源文件名和程序行号。 由于优化期间发生代码转换,StackTrace 报告的方法调用可能没有预期的多。 堆栈跟踪被捕获后,将立即引发异常。 |
|
引发当前异常的方法。 如果引发异常的方法不可用并且堆栈跟踪不是空引用(Visual Basic 中的 Nothing),TargetSite 将从堆栈跟踪获取该方法。 如果堆栈跟踪为空引用,TargetSite 也返回空引用。 |
关于STATUS_WX86_BREAKPOINT(0x4000001F)异常
简介
STATUS_WX86_BREAKPOINT,值为0x4000001F,称为中断指令异常,表示在系统未附加内核调试器时遇到断点或断言。其定义如下:
/
// MessageId: STATUS_WX86_BREAKPOINT
//
// MessageText:
//
// Exception status code used by Win32 x86 emulation subsystem.
//
#define STATUS_WX86_BREAKPOINT ((NTSTATUS)0x4000001FL)
说明
当断点(int 3)异常来自在WOW64模式下执行的代码(64位Windows中的32位代码)时,64位调试器确实获得了状态WX86。从64位代码中断点时-状态中断点。同样,在单步异常中,如果此异常来自64位代码,64位调试器将获得STATUS_single_step;如果异常来自WOW64代码,则获得STATUS_WX86_single_step。
可以像处理STATUS_BREAKPOINT(0x80000003)一样处理它。WX86提供了从哪个模式(WOW64或本机)中断的附加信息。但在这两种情况下,这都是断点异常。以及如何处理它的逻辑。但是,这是您的选择,决定如何处理断点、单步或其他异常。
还要注意,STATUS_WX86_BREAKPOINT和STATUS_WX86_SINGLE_STEP只得到64位调试器。32位调试器始终获取状态断点,64位调试器获取状态断点,而x64调试器获取状态断点时不获取任何信息。例如,在WOW64进程启动时-64位调试器获取了2个断点-第一个状态断点来自64位模式(在64位ntdll.LdrpDoDebugBreak中),然后是状态断点来自32位ntdll.LdrpDoDebugBreak。而32位调试器只有第二个断点(来自32位代码)具有状态断点。
异常结构填充
ExceptionAddress: 03264043 (libcef!GetHandleVerifier+0x00845ba3)
ExceptionCode: 4000001f (WOW64 breakpoint)
ExceptionFlags: 00000001
NumberParameters: 1
Parameter[0]: 00000000
Visual Studio调试器指南---“解决方案属性页”对话框 ->“通用属性”->“调试源文件”
该属性页指定调试解决方案时调试器查找源文件的位置。
若要访问“调试源文件”属性页,右击“解决方案资源管理器”中的“解决方案”,并从快捷菜单中选择“属性”。 展开“通用属性”文件夹并单击“调试源文件”页。
包含源代码的目录
包含调试器在调试解决方案时搜索源文件的目录列表。 还可搜索指定目录的所有子目录。不查找下面这些源文件
输入不希望调试器读取的任何文件的名称。 如果调试器在以上指定的某个目录中找到这些文件之一,它将忽略该文件。 如果在调试期间出现“查找源”对话框,这时您单击“取消”,那么,您搜索的文件将被添加到此列表中,以使调试器不再继续搜索该文件。