六、内存相关
内存管理一直都是很大的话题,内存问题也是程序中最常见的问题。一般常见的内存问题有:悬挂指针(Dangling Pointer)、重复释放(Double Free)、内存泄露(Memory Leak)。悬挂指针是指某个变量指针指向的地址内存已经被释放掉了,这个指针地址已经无效,这种错误在托管代码中已经不存在了,所以这个不讲。跟悬挂指针关系比较密切的错误是Heap Corruption,这个错误发生的原因是在堆中已经分配的有效内存被更改掉了,记得大学时期很有名的PC版植物大战僵尸游戏修改器原理就是更改堆中的内存,只不过这种修改没有引起程序的抛错,而且把修改后的数据当做正常的数据读出来了。在.NET中如果发生Heap Corruption,很有可能会导致对象的类型索引被Corrupt掉,最后有可能抛出的错误就是非常有名的AV exception(Access Violation,思想已经邪恶的同学主动面壁去),幸运的是托管代码中也不会发生这样的错误,但是不能保证非托管的代码调用的时候发生Heap Corruption,Windbg SOS中有一个专门的命令是验证堆中对象的有效性,“!VerifyHeap”,这种错误就这么简单的提一下,因为不常见。重复释放内存的问题主要在C/C++中,略过。内存泄露估计是内存问题中最常见的错误了,而且还很难发现,这个也是笔者接下来要详细讲到的,内存泄露可能会导致的一个非常直观的错误是OOM(Out of Memory)。
想要解决内存相关的问题,对GC理论的了解远远重要于Windbg命令,所以在讲述内存相关的问题之前有必要复习一下GC(Garbage Collector)相关的一些知识,在这里主要介绍GC相关的三个概念:
- 代
- 终结器
- GC句柄表
6.1代
GC是基于代的垃圾回收器,一共有三代,分别称为第0代,第一代和第二代。当程序开始运行,托管堆初始化,这个时候托管堆中还没有任何对象。接着程序开始初始化对象,这个时候初始化的对象被分配在托管堆中,分配的对象处在第0代中。举个例子说,已经有5个对象初始化了,这五个对象都分配在堆中的第0代中,如图:
其中obj2在此时没有任何根(Root)引用这个对象,也就是说这个对象不可达,比如说调用方法A,在A中声明初始化了局部变量obj2对象,然后返回了,这个时候obj2就是属于不可达的对象(根的概念很重要,在后面还会提到)。当进行GC处理的时候,堆中内存变为:
从图中可以看到obj2这块内存被回收了,而且对内存进行了压缩。这个时候obj1,obj3,obj4,obj5所在内存区域从第0代变为了第一代。程序继续运行,然后当分配obj11的时候发现第0代又满了,这时的堆中内存如下:
其中第一代中obj4变为了不可达,第一代中的obj7,obj8,obj10变为了不可达,这个时候GC进行垃圾回收的时候还是会回收第0代的内存,回收压缩内存后变为:
可以看到第一代是没有被回收的。微软设计这种代的概念主要是基于三个原则:
1. 对象越新,生命可能越短
2. 对象越老,生命可能越长
3. 检测和回收堆中的部分对象要比检测回收所有的对象要快
所以这一次,GC并没有检测和回收第一代中的对象。
为了避免读者陷入误区,在这里对我上面说的关于代的概念做几点纠正:
1. 每次回收内存后,不一定会压缩内存,取决于两个条件:一是对象有没有被固定(pinned)住,这个我在后面讲GC句柄表(GC handle table)的时候会提到这个概念;二是当GC回收内存是以同步的模式(Concurrent)运行而且内存是绰绰有余的时候,这个时候是不会压缩内存的,毕竟移动内存和更新对象的引用也是需要消耗一部分性能的,所以这个过程能免则免。这里我写的有点含糊,就是什么时候内存是绰绰有余的,有没有一个准确的数据或者一个算法来判断当时运行时的可用内存是否“绰绰有余”,很抱歉,这个我也不知道,全部由GC决定。
2. 看到前面说的原则,会造成一个错觉,会认为每次第0代内存满的时候,好像都只是回收第0代的内存,这个肯定不是,不然第二代内存就没什么用处。那问题来了,什么时候会回收第一代中的内存?是不是第一代也有一个内存容量,当超过这个限制的时候开始回收第一代内存,回收后的第一代内存变为第二代?答案是不一定,一般情况是这样运行,我见过很多高手都是认定这种回收方式。但是GC不是特别死板,当第0代内存满的时候,这个时候GC会判断第0代回收能不能回收足够的内存,如果不能,那么就会回收第一代或者第二代,前面说的那种策略是GC判断决定回收第几代的众多策略中的一种策略。在这里回收第一代和回收第二代的方式又有点不一样:回收第一代时,会检测回收第0代和第一代中的对象,回收后存活的第一代对象变为第二代,第0代变为第一代,这个时候第0代内存空出内存了,然后执行新建对象分配内存的操作(分配内存一直都是在第0代内存中);回收第二代时,也会回收第一代和第0代,这里唯一的不同是,回收压缩完成后会加大第一代的内存容量。还有一个要注意的点是第二代内存被检测回收压缩后存活下来的对象,还是被认为是第二代的对象,没有第三代。
3. 既然第0代的容量可以扩大,那么第一代和第二代可不可以,当然可以,而且你也可以根据上面介绍的回收流程看到了扩大的现象,如果想不明白就在想想。
4. 还是回到容量扩增这个问题上,既然能扩增,能不能收缩(shrink)?可以,当第0代回收大部分内存的时候而且回收前第0代的对象占用的内存比较大,就会收缩第一代的内存容量。这里我又用了模糊的字眼,什么叫做回收了大部分内存?什么叫做占用的内存比较大?这个我也不知道准确的边界或什么算法决定这个,一切由GC决定。
5. 还有一个没说到的是大对象,前面所说的对象的分配都是在第0代,以及各个代的内存回收概念都是建立在对象为非大对象的情况下的概念。首先说什么算大对象,很简单,对象大小等于或大于85000字节的对象就是大对象,很明确?不一定,这个值还不是hardcode,微软可能会改,但目前还是这个值,算是比较明确。大对象创建的时候不是在第0代中分配内存,很多人认为大对象是分配在第二代内存中,其实这种说法不准确,因为从内存的分布来说,大对象分布的位置不在第二代的地址范围内,之所以很多人认为大对象属于第二代,是因为通过GC类的一个静态方法GetGeneration得到的值就是2,也就是第二代,代码如下:
staticvoid Main(string[]args) { byte[]largeObj = newbyte[85000]; Console.WriteLine(GC.GetGeneration(largeObj)); // Output is 2. } |
还有一个造成错觉的原因是大对象的检测回收是被当做第二代的对象对待的。之所以大对象被特殊对待,是因为当进行内存压缩的时候,大对象的内存移动会非常消耗性能,所以大对象不适合移动,这也是假设大对象的创建和回收不活跃。那么回收第二代内存的时候,大对象的移动不一样会损耗性能么?是的,这个是无法避免的,为了解决这个问题,所以大对象被回收后,存活下来的大对象是不进行移动和对内存的压缩的。当下一次给大对象分配内存的时候会首先从这些被释放的大对象的内存片段中找到大小合适可容纳新对象的内存,这样就可以尽量减少内存碎片的大小。
6. 大对象所在的内存范围被称为大对象堆,简称LOH(Large Object Heap),相对的像第0,一,二代的内存范围被称为小对象堆,简称SOH,大对象堆和小对象堆都属于GC堆。
回到Windbg,在SOS扩展命令中,可以使用“!eeheap -gc”打印出GC堆的简略信息:
0:006>!eeheap -gc Number of GC Heaps: 1 generation 0 starts at 0x02bc1018 generation 1 starts at 0x02bc100c generation 2 starts at 0x02bc1000 ephemeral segment allocation context: none segment begin allocated size 02bc0000 02bc1000 02bc5ff4 0x4ff4(20468) Large object heap starts at 0x03bc1000 segment begin allocated size 03bc0000 03bc1000 03bc54b8 0x44b8(17592) Total Size: Size: 0x94ac (38060) bytes. ------------------------------ GC Heap Size: Size: 0x94ac (38060) bytes. |
上表中可以看到命令打印的第一行表明了有多少个GC堆,在这里简单提一下为什么有这个信息,难道不一直都只有一个GC堆吗?显然不是,但是我们平常所看到的可能都只有一个,这是因为我们默认开发使用的GC模式是“WorkStation”,当我们把模式改为“Server”模式时,这时每个CPU都有对应的一个GC堆。在Server模式下,充分利用了每个CPU来执行GC,每个CPU有个对应的线程,每个线程处理该CPU对应的GC堆上的对象的回收和检测,各个线程是并行处理的。开启Server模式可在配置文件做如下配置:
<configuration> <runtime> <gcServerenabled="true" /> </runtime> </configuration> |
这个时候打印出来的堆信息如下:
0:011>!eeheap -gc Number of GC Heaps: 4 ------------------------------ Heap 0 (01023fe8) generation 0 starts at 0x02fb1018 generation 1 starts at 0x02fb100c generation 2 starts at 0x02fb1000 ephemeral segment allocation context: none segment begin allocated size 02fb0000 02fb1000 02fb1024 0x24(36) Large object heap starts at 0x12fb1000 segment begin allocated size 12fb0000 12fb1000 12fb54b8 0x44b8(17592) Heap Size: Size: 0x44dc (17628) bytes. ------------------------------ Heap 1 (01028df8) generation 0 starts at 0x06fb2f28 generation 1 starts at 0x06fb100c generation 2 starts at 0x06fb1000 ephemeral segment allocation context: none segment begin allocated size 06fb0000 06fb1000 06fb2f34 0x1f34(7988) Large object heap starts at 0x14fb1000 segment begin allocated size 14fb0000 14fb1000 14fef488 0x3e488(255112) Heap Size: Size: 0x403bc (263100) bytes. ------------------------------ Heap 2 (0102dfe8) generation 0 starts at 0x0afb1018 generation 1 starts at 0x0afb100c generation 2 starts at 0x0afb1000 ephemeral segment allocation context: none segment begin allocated size 0afb0000 0afb1000 0afb1024 0x24(36) Large object heap starts at 0x16fb1000 segment begin allocated size 16fb0000 16fb1000 16fb1010 0x10(16) Heap Size: Size: 0x34 (52) bytes. ------------------------------ Heap 3 (01033870) generation 0 starts at 0x0efb1018 generation 1 starts at 0x0efb100c generation 2 starts at 0x0efb1000 ephemeral segment allocation context: none segment begin allocated size 0efb0000 0efb1000 0efb3ff4 0x2ff4(12276) Large object heap starts at 0x18fb1000 segment begin allocated size 18fb0000 18fb1000 18fb1010 0x10(16) Heap Size: Size: 0x3004 (12292) bytes. ------------------------------ GC Heap Size: Size: 0x478d0 (293072) bytes. |
回到一开始打印出的堆信息内容,可以看到第0代的起始地址为0x02bc1018,第一代的起始地址为0x02bc100c,第二代为0x02bc1000,从这里可以看出第一代的地址范围为0x02bc100c ~ 0x02bc1018,第二代的地址范围为0x02bc1000~ 0x02bc100c,至于后面的ephemeral segment这些内容可以忽略,因为知道这些对于解决问题并没有什么卵用,一知半解反而会越来越困惑,而我就属于对此一知半解的状态,而我接下来在这里列出我所知道的并夹杂着我不知道的,对于基础不扎实的读者可以忽略我以下所列出来的内容,可以直接跳到这里,对于大神级的高手我希望能看完我所列出来的,并告诉我不知道的,或者更正我已经知道的但却是错误的知识:
1.ephemeral segment指这个SOH所对应的段,一个SOH里面可以有多个段
2.这个段的起始地址在02bc0000,开始分配对象的地址在02bc1000,这之间大小为0x1000的空间是固定的,具体用来干嘛,我不知道,我一直都猜测用来管理段内存使用,具体可能是建立类似索引,其中可能还包括段的地址范围,状态等信息。
3.(猜测)这个allocated应该就是第0代的内存容量边界,也就是说第0代的地址范围为0x02bc1018 ~ 0x02bc5ff4。当下一个分配的对象需要占用的内存超过这个大小时可能执行GC或者扩大第一代的容量。
4.根据命令“!address”可以推断出指定的段具体有多少内存已提交,多少内存已经被保留,具体使用命令如下:
0:006>!address 02bc0000 Usage: <unknown> Base Address: 02bc0000 End Address: 02bd2000 Region Size: 00012000 State: 00001000 MEM_COMMIT Protect: 00000004PAGE_READWRITE Type: 00020000 MEM_PRIVATE Allocation Base: 02bc0000 Allocation Protect: 00000004 PAGE_READWRITE
0:006>!address02bd2000 Usage: <unknown> Base Address: 02bd2000 End Address: 03bc0000 Region Size: 00fee000 State: 00002000 MEM_RESERVE Protect: <info not present at the target> Type: 00020000 MEM_PRIVATE Allocation Base: 02bc0000 Allocation Protect: 00000004 PAGE_READWRITE |
从上表可以看到保留下来准备投入使用的内存是03bc0000 - 02bc0000 =16M,这个值是针对32位的架构和Workstation的GC模式,至于其他的情况,初始保留的内存大小略有不同,在这里就不列举了。
5.如果给对象分配内存的段快满的时候,会给当前的SOH另外分配一个段,段的大小跟之前的段大小一样,在这里是16M,当找不到16M的连续内存时,这个时候会抛出OOM Exception。在这里我困惑的另外一个问题,就是在段中准备分配内存的时候还远远没到16M,这个时候就已经给当前的SOH分配新的段了,根据我的测试原来的段还有3M左右的内存属于保留状态,对于分配一个小对象绰绰有余,这3M左右的内存用来干嘛?为什么不置为提交状态并使用起来,这一点我百思不得其解。
接下来继续看GC堆的信息,后面紧接着是LOH的相关信息,包括起始地址0x03bc1000,和段的信息,段的信息就不再分析了,有兴趣可以看我所说的一知半解的部分。
对于“!eeheap-gc”命令,可以看到只能看到关于GC堆的简略信息,如何能知道指定的代中的包含的对象呢?这个可以通过SOSEX的扩展命令“!dumpgen <指定的代>”来查看,如下:
0:006>.load sosex.dll 0:006>!dumpgen 0 Object MT Size Name --------------------------------------------------- 02bc1018 00fceb70 10 **** FREE **** 02bc1024 5de91ec4 84 System.Exception 02bc1078 5de92018 84 System.OutOfMemoryException 02bc10cc 5de9205c 84 System.*Exception …… 0:006>!dumpgen 1 Object MT Size Name --------------------------------------------------- 02bc100c 00fceb70 10 **** FREE **** |
因为在内存这一块,关于代的内容比较多,所以单独作为一个Post发出来,后续继续讲解内存相关。