.NET内存管理(1) - 内存分配
27 May 2015最近比较高产,主要是因为在复习一些 .NET 基础。和以前不同的是,现在我决定书每看完一部分就要动手实践,并在博客里记录下来 (在这两个过程中通常会发现很多问题)。这其实是谁都懂得道理,我也在很多年前就“懂”了,但实际上,各种原因 (主要是懒) 会让我把这个简单有效的道理忘得一干二净,这导致我看的很多书约等于白看 (看一遍吸收 1% 的意思)。幸运的是,我对这个道理重新认识得还不算太晚,所以…废话不多说,进入正题吧。
在 .NET 中创建一个对象的开销到底有多大?如果你还在纠结多创建几个对象会不会影响性能这样的问题,就需要来一起好好地探讨一下 (当然,实际应用中我们需要 Profiling,不能仅凭经验来下结论)。
托管内存的分配
众所周知,CLR 的世界里有一个叫“托管堆 (Managed Heap)”的东西,引用类型的对象都会被分配在托管堆上 (值类型在装箱后也会分配在托管堆上)。创建一个对象需要做许多事,包括计算存储对象所需的空间,添加 header,分配内存等。接下来我们主要讨论内存的分配。
假设托管堆空间充足,那为一个对象分配内存的开销其实非常小。首先托管堆上的对象是连续分配的 (注意在进行垃圾回收之前,是只分配而不回收内存的哦),而托管堆内部维护了一个NextObjPtr
的指针,指在下一个“空位”上,所以为一个对象分配内存,不过是从NextObjPtr
开始预留一段内存空间用来存放新对象,然后再把NextObjPtr
往后移而已。
假设为对象 C 分配内存前,托管堆如下所示:
+---+---+------------------------+
| A | B | |
+---+---+------------------------+
↑
NextObjPtr
现在为 C 分配内存,分配之后,就变成:
+---+---+---+--------------------+
| A | B | C | |
+---+---+---+--------------------+
↑
NextObjPtr
但我们不可能这样无止境地分配而不回收,所以,当托管堆内存被分配到一定程度后,GC (Garbage Collector) 会开始工作,除了回收不再被引用的对象的内存外,它还有一个重要的任务是压缩托管堆。
接上图的例子,假设现在 B 对象不再被引用 (变成垃圾),并且 GC 开始工作,于是 B 对象占用的内存被回收,同时托管堆被压缩,然后变成这样:
+---+---+------------------------+
| A | C | |
+---+---+------------------------+
↑
NextObjPtr
需要注意,因为这个“压缩”的存在,托管堆中不会有“空洞”存在 (碎片, Fragmentation)。
这样的机制有两大优势:
(1) 在没有进行垃圾回收的时候,内存的分配非常快 (基本上就是移动一下指针),快到什么程度呢?可能比 C 语言的malloc
还快;
(2) 因为对象是连续分配的,所以还有局部性 (Locality) 的优势,因为局部性原理,代码中要使用的对象可能都已经乖乖躺在 CPU Cache 中了,这就减少了对内存的访问。
C 语言内存的分配
这里指的是 C 语言标准库中的 malloc
函数。在 C 里面,我们可以调用malloc
在进程堆上分配内存,然后再调用free
释放内存。和 CLR 托管堆相比,它主要有两个问题:
内存碎片 (Fragmentation)
一开始时可能很好,像 CLR 的托管堆一样,在进程堆上连续分配了一片内存,但当你释放掉一些内存的时候,就可能会出现很多“空洞“(内存碎片),这时就有点纠结了,下次分配内存时分配在哪里呢?如果“洞”够大,那直接分配在“洞”里就可以了,但要是“洞”太小,这个“洞”里的空闲内存就显得有点鸡肋了。更要命的是,如果“小洞”非常多,即使合计起来总剩余空间非常充足,但因为每个“洞”都太小,导致没有任何一个“洞”可以满足内存分配的请求。
+---+---+---+---+---+---+---+---+---+
| A | | B | C | | D | E | | F |
+---+---+---+---+---+---+---+---+---+
G:我要放哪里?
+---+---+
| G |
+---+---+
如上图所示,堆中还有三个空位,加起来本足以放下 G 了,但是却不行,因为每个“洞”都太小。
内存分配效率
如前面所述,C 语言中内存的分配和回收可以由程序员控制,所以空闲内存并不是连续的,因此需要一种机制来把这些不连续的空闲内存给管理起来,它就是 Free List,如下图所示。
(图源: 深入理解计算机系统)
上图的箭头把空闲内存块都串起来了,没错,这是一个链表,所以在 C 中分配内存时,需要遍历链表直到找到一块合适的空闲内存 (不同的分配策略有所不同,比如采用 First fit 时只要找到第一块合适的内存块就停止查找,而 Best fit 则要遍历整个链表,然后在合适的内存块里选最小的那个),这显然比 CLR 托管堆中的内存分配要慢得多,尤其当内存碎片较多的时候。
但即使碎片再多,C 运行时也不能随意压缩进程堆,因为这会导致已创建对象的内存地址发生变化,这显然是不能接受的 (C中可直接使用指针)。.NET 则不同,在 .NET 中我们拿到手的都是对象引用,引用不是指针,在 GC 压缩托管堆之后,所有对象引用所指向的内存地址都会被 CLR 纠正。
当然,C# 也允许在非安全代码中直接使用指针,既然 GC 会压缩托管堆,那我们还能好好地使用指针吗?当然可以,要不然干嘛允许我们用。这是因为 CLR 提供了一些机制,允许指定对象被临时 Pin 住,这可以保证在 GC 压缩托管堆的时候,这些对象不会被移动,这些机制包括GCHandle
及 C# 的fixed
关键字等。
总结
所以,如果没有进行垃圾回收,CLR 内存分配的效率是非常高的,但这也不是说垃圾回收是多么可怕的一件事,事实上 M$ 对 CLR 做了很多优化,只要我们稍加注意,就不会有太大影响,具体细节我们下回分解。
参考资料
- 《CLR via C#》第3版
- 《深入理解计算机系统》第2版
本博客托管于GitCafe,如发现错误,或觉得某些地方用其它方式来说明更通俗易懂,欢迎向我提交Pull Request。