[翻译]:内存泄漏及Pinning
原文:https://blogs.msdn.com/yunjin/archive/2004/01/27/63642.aspx
翻译:overred
关键词:内存泄露 内存碎片
众所周知,CLR托管程序内存管理(CLR memory management)是靠GC(Garbage collector)来完成的。当GC在GC堆(GC heap)上找不到满足一个新对象内存大小的“块”时,就会导致内存溢出(谢谢anders06),抛出一个异常。
问题根源概述
内存泄漏问题在我们的日常.Net开发中非常常见,主因素可以归纳为以下两大原因:
1,虚拟地址空间杂乱无序,比较琐碎。此现象一般是由于在.Net程序中使用非托管代码导致。非托管代码所”占”住(pinning)的内存区而GC这个大管家无法对其进行垃圾回收,使之一直处于使用状态。
在老版本的.Net Framework中(如v1.0和v1.1,译者注:即CLR1.0)当我们的程序在CLR上以workstation方式运行时,GC预分配的内存区大小是16M,而以server方式运行则预分配32M。这就意味着CLR必须为我们动态分配更多内存,因为我们的程序所占用的内存大多数情况下会高于这个。
试想一下,如果我们主内存大小为2G,一般情况下,这个大小足以满足我们的需求,因为我们的GC会在适当情况下进行回收,从而达到内存空闲复用。但是程序中如果使用非托管代码,则情况就不同喽。因为GC无法对非托管代码的虚拟内存空间进行回收,使之一直长期占用下去,并且这些内存块是非连续的,这样为一个新的大对象分配内存时,CLR为找不到一块“舒服”的内存块而郁闷。
2,GC堆自身碎片问题。内存没有被非托管代码长期“霸占”,但CLR也并未在内存预分配区(reserved segments)进行分配。本文主要是针对此问题进行展开。
GC Heap一览图
①对象地址空间在堆上是连续的(A和B),且最上端为空闲区.
|--------- |
|free |
|_________|
|Object B |
| |
|_________|
|Object A |
|_________|
| ... |
②在内存堆上分配一新对象C后:
|---------|
|free |
|_________|
|Object C |
|_________|
|Object B |
| |
|_________|
|Object A |
|_________|
| ... |
③当Free内存不足够时,GC将对堆对象进行垃圾回收(此处假设对象B无根引用,将要被回收,对象A和C被标记为可到达对象(reachable objects))。
|---------|
|Object C | (marked)
|_________|
|Object B |
| |
|_________|
|Object A | (marked)
|_________|
| ... |
④GC回收后(活动对象A和C地址被重新分配,对象B被回收掉)
|---------|
|free |
|_________|
|Object C |
|_________|
|Object A |
|_________|
| ... |
GC Heap的空闲内存区
从以上的内存分配模型可以看出,新分配的对象始终处于堆的顶部,这样应该不会产生内存碎片?哦,其实不然,大多数情况下,我们的新对象不会如意般被放在堆顶,而是被搁置于已分配对象之间的内存区域,只要这块内存足够大。理由如下:
1,多数情况下,GC不轻易对那些碎片进行压缩,然后重新分配地址,因为这样没必要,而其代价也较高。GC会维护一个空闲内存区列表,适当情况下对其进行压缩。此类情况不会导致内存碎片。
2,被Pinned对象,在堆上是无法被移动的。因此在进行GC后,那些被回收掉的内存区域就加塞在这些不可移动对象之间,也就是所谓的内存碎片。如图所示:
before GC: after GC:
|---------| |---------|
|Object C | (pinned, reachable) |Object C | (pinned)
|_________| |_________|
|Object B | (unreachable) | free |
| | | |
|_________| |_________|
|Object A | (reachable) |Object A |
|_________| |_________|
| ... | | ... |
为什么pinning会导致GC Heap碎片
设想一下,有这么一种情况,我们在程序中new一个对象后,然后pinning一个对象,然后继续new一个对象,继续pinning一个对象。。。如此下去,如图:
pinned一个新对象Pinned 1 :
|---------|
|free |
|_________|
|Pinned 1 |
|_________|
|Object A |
|_________|
| ... |
对对象进行多次分配和pinned后:
|---------|
|free |
|_________|
|Pinned 2 |
|_________|
| ... |
|_________|
|Pinned 1 |
|_________|
|Object A |
|_________|
| ... |
更多:
|_________|
|Pinned n |
|_________|
| ... |
|_________|
|Pinned 2 |
|_________|
| ... |
|_________|
|Pinned 1 |
|_________|
|Object A |
|_________|
| ... |
当GC发生后,空闲区域因为pinned对象具有不可移动性成为“碎片”:
|_________|
|Pinned n |
|_________|
| free |
|_________|
|Pinned 2 |
|_________|
| free |
|_________|
|Pinned 1 |
|_________|
| free |
|_________|
| ... |
这样我们的碎片就产生了,它多但又由于太小不会被GC压缩重组。如果不停的产生这些“小东西”,最终会导致内存泄漏。这下地主家也没余粮喽~~
在内存中产生pinned对象,有时候并非我们程序员导致。在很多的异步操作(APM)中CLR会自己把一些对象进行pinning操作,因为异步回调线程跟主线程不是同一线程,在异步的时候要确保对象不会被回收掉而pinned。这种情况在我们使用异步IO时多易发生。
如何诊断内存碎片?
既然病症有了,我们该如何诊断自己的程序是否有内存碎片呢?
那就是望:使用windbg的!dumpheap命令观察,该命令可以显示所有托管GC堆上的对象(包括内存地址以及大小和个数)。
Address MT Size
00a71000 0015cde8 12 Free
00a7100c 0015cde8 12 Free
00a71018 0015cde8 12 Free
00a71024 5ba58328 68
00a71068 5ba58380 68
00a710ac 5ba58430 68
00a710f0 5ba5dba4 68
00a91000 5ba88bd8 2064
00a91810 0019fe48 2032 Free
00a92000 5ba88bd8 4096
00a93000 0019fe48 8192 Free
00a95000 5ba88bd8 4096
total 1892 objects
Statistics:
MT Count TotalSize Class Name
5ba7607c 1 12 System.Security.Permissions.HostProtectionResource
5ba75d54 1 12 System.Security.Permissions.SecurityPermissionFlag
5ba61f18 1 12 System.Collections.CaseInsensitiveComparer
0015cde8 6 10260 Free
5ba57bf8 318 18136 System.String

(译者注:可以使用!dumpheap -stat进行分组)
以上的Dump结果中,我们可以看到有3个小的内存碎片,分别是:
00a7100c 0015cde8 12 Free
00a71018 0015cde8 12 Free
大小均为12kb。
接着是4个大小为68kb的对象:
00a71068 5ba58380 68
00a710ac 5ba58430 68
00a710f0 5ba5dba4 68
再来看下总的统计结果(Statistics):
0015cde8 6 10260 Free
该GC托管堆上总共有6个内存空闲区,即Free,总大小为10260kb。
一般Free比较大且个数较多的话,说明有内存碎片,既然很多内存碎片是加塞在一些不能移动的对象之间,那我们就从这些Free的周围内存地址查起。
比如:
00a91810 0019fe48 2032 Free
这个大块头的后面是:
00a92000 5ba88bd8 4096 ,内存地址为00a92000 (译者注:该为32位系统)
接下来我们使用:!dumpobj+内存地址,该命令可以显示该地址下的对象信息,可以缩写为!do+内存地址
Name: System.Byte[]
MethodTable 0x00992c3c
EEClass 0x00992bc4
Size 4096(0x1000) bytes
Array: Rank 1, Type System.Byte
Element Type: System.Byte
分析:从结果中我们可以看出内存为00a92000是一个System.Byte[]。
为什么这个对象没有被GC回收掉呢?我们继续使用!gcroot+内存地址命令。该命令可以显示一个对象的根之间关系。
Scan Thread 0 (728)
Scan Thread 1 (730)
ESP:88cf548:Root:05066b48(System.IO.MemoryStream)->00a92000 (System.Byte[])
ESP:88cf568:Root:05066b48(System.IO.MemoryStream)->00a92000 (System.Byte[])
Scan HandleTable 9b130
Scan HandleTable 9ff18
HANDLE(Pinned):d41250:Root: 00a92000 (System.Byte[])
哦,它是被一个MemoryStream占用(注意,!gcroot的结果有时候不太真实,切勿上当受骗),霸为小妾。暂且先不管,继续使用!objsize命令列举出handles,注意被标记为pinned的。
HANDLE(Pinned):d41250: sizeof(00a92000) = 4096 ( 0x1000) bytes (System.Byte[])
HANDLE(Pinned):d41254: sizeof(00a95000) = 4096 ( 0x1000) bytes (System.Byte[])
HANDLE(Pinned):d41258: sizeof(00ac8b5b0) = 16 ( 0x10) bytes (System.Byte[])

使用windbg的sos命令,我们能更清楚的了解GC堆的一些状态,比如本例中的内存碎片以及为什么会产生。更多命令可参考:
https://blogs.msdn.com/mvstanton
解决之道
综上所述,我们得出以下结论:
1,如果多个pinned对象被分配的时间间隔越小,那么内存碎片就越小。
2,如果pinned对象越老,所处代龄越高,则情况比较明朗。大部分内存Free区在堆顶。
3,如果pinned对象较小,GC就越容易对内存进行压缩。
为改善对象pinned导致碎片产生,可以使用一个BufferPool,预留开辟一段内存。取之则用,不用则留。
代码如下:
完毕,希望对你能有所帮助!同时也谢谢你的斧正!