内存不足(OutOfMemory)的调试分析

32位操作系统的寻址空间是4G,其中有2G被操作系统占用,也就是说留给用户进程的内存只有2G(其中还要扣除程序加载时映像占用的部分空间,一般只有1.6G~1.8G左右可以使用)。

如果进程运行中需要申请内存,而操作系统无法为其分配内存空间,则会产生内存不足的异常,在.net中为System.OutOfMemoryException(The exception that is thrown when there is not enough memory tocontinue the execution of a program.)。

虽然最终的表现都为OutOfMemoryException,但其产生的原因可能是不一样的,动手解决此问题之前需要先对进程当前内存的使用状态进行分析,找出正确的原因,才能对症下药。下面分享一下调试此类问题的一些心得。

一、使用Perfmon.exe

1)   命令行输入perfmon.exe。打开“性能”。

2)   在“性能日志与警报-计数器日志”上右键,选择“新建日志设置”。

3)   输入日志名称,如“OOM”。

4)   在“常规-计数器”中删除所有默认的计数器(如果有)。

5)   点击“添加计数器”,性能对象选择“.NET CLR Memory”,计数器选择并添加“Bytes in all heaps”、“Large Object Heap Size”。同样“性能对象”选择“Process”,计数器选择并添加“Virtual bytes”、”Private bytes”。注意点击“添加”前需要在“从列表选择范例”选择框选择需要监控的进程。


另外,如果当前系统登陆的用例对目标进程没有调试权限,需要在“运行方式”框里填入domain\username,并输入密码。

6)   数据采样间隔可以设置小一点,如1秒钟。

7)   点击“确定“,新的计数器日志就新建成功了。右边的框框中可以看到新的计数器,绿色表示正在运行中。”“日志文件名“列显示了本次监控结果将写入的日志文件名(同一个计数器运行多次,写入的日志文件名是不同的)。


8)   让程序与计数器运行一段时间,然后停止计数器(为什么要停止计数器?我的机器上测试的时候,需要先停止计数器后,才会把监控的结果写到日志文件中,如果不先停止,在下面的监视器中将看不到计数器运行这段时间的监控结果。)。

9)   点击“系统监视器“。点击”“查看日志数据”(图标为)按钮,在“来源”选项卡里添加日志文件为刚刚我们新建的计数器产生的日志文件。下方可选择时间范围,这里选全部即可。然后在“数据”选项卡里添加需要查看的计数器(此选择卡还可以定义不同的计数器显示的样式及显示比例)。



10) 从图上可以看到在计数器运行的时间段中,被监控进程的内存使用情况。在添加计数器的窗口中有对相应计数器的简单说明,下面是几个常用的计数器:

·           Bytes in all Heaps:.net托管堆(GC)使用的总内存。包括0代、1代、2代及大对象堆。

·           Large Object Heap size:大对象堆使用的内存。.net在分配内存时大于85K的对象会被放到这个堆中,不同于0、1、2代,大对象堆中的内存不是连续的,在垃圾回收时也不会移动大对象的地址(我系统显示为大于20K对象为大对象,实际上2.0应该为大于85K)。

·           Private bytes:该计数器记录了当前通过VirtualAlloc API Commit的Memory数量。无论是直接调用API申请的内存,被Heap Manager申请的内存,或者是CLR 的managed heap,都算在里面。跟Handle Count一样,如果在整个程序周期内总体趋势是连续向上,说明有MemoryLeak(摘自百度)。

·           Virtual bytes:该计数器记录了当前进程申请成功的用户态总内存地址,包括DLL/EXE占用的地址和通过VirtualAlloc API Reserve的Memory Space数量,所以该计数器应该总大于Private Bytes。一般来说,Virtual Bytes跟Private Bytes的变化大致一致。由于内存分片的存在, Virtual Bytes跟Private Byes一般保持一个相对稳定的比例关系。当Virtual Bytes跟Private Bytes的比例关系大于2的时候,程序往往有比较严重的内存地址分片(摘自百度,但对.net程序来说一般差别在200M以下还算是正常的)。

11) 有了上面几个计数器的结果之后,一般可以通过以下规则大致定位问题的所在:

·           Virtual bytes增长但Private bytes没有显著增长。为Virtual bytes泄露。

·           Private bytes增长但bytes in all heaps没有显著增长。为非托管资源泄露,检查有没有COM组件或其它非托管调用没有正确释放内存。

·           Bytes in all heaps显著增长。为.net托管内存泄露。由于.net内存是GC管理的,自动回收,这里有可能是缓存了过多的数据,或程序中引用混乱导致本来需要被回收的数据还被其它对象所引用从而GC没法回收这部分数据。

·           Bytes in all heaps有增长但使用不多,系统剩余可用内存也比较多(需要再添加相应的计数器)。这种情况比较少见,但我遇到过一次是由于非托管在存在大量碎片,导致.net在申请大对象时失败。

二、使用Windbg

如果是由于.net托管内存导致的内存泄露,可以用Windbg进一步排查问题(非托管的也可以,但还没有对这方面进行详细研究过:))。

1)   加载SOS.dll。

.loadC:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\sos.dll

2)   保存进程的映像文件。

.dump /ma “c:\oom.dmp”

3)   查看内存的使用情况。

!address –summary


RegionUsageIsVAD:VirtualAlloc的内存,一般为GC占用。

RegionUsageFree:可用内存。

RegionUsageImage:加载dll或exe占用的内存。

RegionUsageStack:线程堆栈占用的内存(.net中如果一个递归函数有问题导致无限循环调用会产生StackOverflowException)。

其它的可以参考Windbg文档,或打!address -?获得命令说明。另外上面有一个重要的信息,即Largest free gegion,我这里关心其size为18280KB,即是说当前可申请的最大连续内存块为18M多,也意味着如果此时进程去申请大于此数值的内存,也会报OutOfMemory(尽管目前Free的内存总共还有400多M,打!address –RegionUsageFree可以看到这400多M的内存的分块情况),通常引起此问题的原因,可能是非托管调用引起的严重内存碎片,因为托管的内存是连续的。由于大对象申请失败的问题调试,后面还会再进一步详细说明。

4)   查看托管堆内存的使用情况。

!eeheap –gc


上面显示了GC各个代及大对象堆的大小及每个段(segment)的大小、地址范围等等信息。GC在分配内存的时候是按段申请,按段释放的,也就意味着,GC占用的内存要比你的程序中为对象实际申请的总内存要大一点,如果程序为对象申请一块内存,而当前段的最大可用内存不足以分配时,GC为向系统申请新的段,从上面看到段的大小为16M左右,应该是按某种算法得出新段的大小(比如当前可用内存,操作系统或.net framework的版本等,只是我的猜测,有兴趣的童鞋自己查查文档后告诉我:))。

5)   查看当前托管堆中的对象,及每种对象占用的内存大小。

!dumpheap –stat



从上图中看到最大的类型为字符串,共占用了135M内存。

6)   查看某种类型的所有实例地址。

!dumpheap -mt 793308ec


7)   查看某个对象的信息。

!do [对象地址]

对象地址可以在!dumpheap –mt命令的第一列中得到。

8)   查看某个对象占用的内存大小。

!objsize [对象地址]

如果对象引用了其它对象,此命令会把其引用的其它对象占用的内存也算进去。

9)   查看数组中的元素。

!dumparray [数组对象地址]

如果用!do得到的对象为数组,用此命令得到数组中每个元素的地址,再用!do打出数组元素的信息。

10) 查看对象与其它对象的引用情况。

!gcroot [对象地址]

这个命令在判断.net托管内存泄露很有用,它可以得到某个对象没有被GC释放掉的原因(因为存在根对象的引用关系)。

11) 查看对象大小大于某个数值的所有对象。

!dumpheap –min 10000000

12) 查看大对象堆的对象。

!dumpheap –startatlowerbound [大对象堆的起始地址]

大对象堆的起始地址可以由命令!eeheap –gc得到。

13) 调试由大对象内存分配不足引起的OutOfMemory。

有些情况下,明明内存还剩下很多,但是由于非托管带来的内存碎片,导致连接内存不足以分配程序申请的大对象的内存,这时也会报内存不足的异常。要确定内存不足是否由此原因引起的可按以下步骤调试:

在程序申请大对象的时候,用windbg打一个断点,并把大对象申请的内存的大小打印出来。

0:027>x mscorwks!WKS*allocate_large*

79f7d9ebmscorwks!WKS::gc_heap::allocate_large_object = <no type information>

0:027>bp 79f7d9eb "?@ebx;!clrstack"

如果程序申请大对象,会有类似下面的输出,大对象的大小为52M。

Evaluateexpression: 52679596 = 0323d3ac

此时可以在输出里看到堆栈,确定是程序哪个代码需要申请这么大的对象,是否属于正常。也可以用!address –summary看当前可申请的最大连接内存块大小,如果小于待申请的大对象大小,则会出现内存不足。

另外,在以前的调试中我得到这么一个结论,如果程序声明了一个长度大于85000/4=21000的数组,这时数组实际占用的内存大于85K,GC会把这个数组放在大对象堆中,对于List类型,其长度是可以动态增加的,如果长度从小于21000到达到21000,GC也会把它移到到大对象中(刚一开始长度小于21000时不在大对象堆中)。

14) 查看GC的终结队列及线程。

另一种导致托管内存没有被释放的原因(除了对象被引用)就是GC的终结线程被阻塞了,从而导致可以释放的对象来不及被释放。可以按以下步骤调试此类问题:

!finalizequeue

有类似以下的输出:

这里需要关注的是Ready for finalization XX objexts,表示终结队列中有多少个对象正在等待被回收,如果数量比较大,可以进一步看看终结线程的堆栈。

!threads

后面带有Finalizer的即终结线程。

~[线程号]s

!clrstack

根据堆栈信息,可以初步断定引起终结线程阻塞的代码位置,然后针对代码做进一步的分析。

三、结语

本文只是列出了调试问题的一些方法或工具,在调试过程中还是要灵活处理,耐心分析,必要时还要查阅文档或研究代码,毕竟导致问题的原因是多种多样的,很难有一个标准的调试过程。另一个重要的就是在开发过程中多注意细节,了解开发语言的特性,能够把问题消灭在开发过程中,那就再好不过了。


转载请注明原文地址:)





已标记关键词 清除标记
©️2020 CSDN 皮肤主题: 大白 设计师:CSDN官方博客 返回首页