![](https://csdnimg.cn/release/download_crawler_static/722318/bg1.jpg)
内存管理和指针
本章介绍内存管理和内存访问的各个方面。尽管运行库负责为程序员处理大部分内存
管理工作,但程序员仍必须理解内存管理的工作原理,了解如何处理未托管的资源。
如果很好地理解了内存管理和C#提供的指针功能,也就能很好地集成C#代码和原来的
代码,并能在非常注重性能的系统中高效地处理内存。
本章的主要内容如下:
● 运行库如何在堆栈和堆上分配空间
● 垃圾收集的工作原理
● 如何使用析构函数和System.IDisposable接口来确保正确释放未托管的资源
● C#中使用指针的语法
● 如何使用指针实现基于堆栈的高性能数组
11.1 后台内存管理
C#编程的一个优点是程序员不需要担心具体的内存管理,尤其是垃圾收集器会处理所
有的内存清理工作。用户可以得到像C++语言那样的效率,而不需要考虑像在C++中那样内
存管理工作的复杂性。虽然不必手工管理内存,但如果要编写高效的代码,就仍需理解后台
发生的事情。本节要介绍给变量分配内存时计算机内存中发生的情况。
注意:
本节的许多内容是没有经过事实证明的。您应把这一节看作是一般规则的简化向导,
而不是实现的确切说明。
第 章
11
0
![](https://csdnimg.cn/release/download_crawler_static/722318/bg2.jpg)
第Ⅰ部分 C# 语 言
11.1.1 值数据类型
Windows使用一个系统:虚拟寻址系统,该系统把程序可用的内存地址映射到硬件内存
中的实际地址上,这些任务完全由Windows在后台管理,其实际结果是32位处理器上的每个
进程都可以使用4GB的内存—— 无论计算机上有多少硬盘空间。(在64位处理器上,这个数
字会更大)。这个4GB内存实际上包含了程序的所有部分,包括可执行代码、代码加载的所
有DLL,以及程序运行时使用的所有变量的内容。这个4GB内存称为虚拟地址空间,或虚拟
内存,为了方便起见,本章将它简称为内存。
4GB中的每个存储单元都是从0开始往上排序的。要访问存储在内存的某个空间中的一
个值,就需要提供表示该存储单元的数字。在任何复杂的高级语言中,例如C#、VB、C+
+和Java,编译器负责把人们可以理解的变量名称转换为处理器可以理解的内存地址。
在进程的虚拟内存中,有一个区域称为堆栈。堆栈存储不是对象成员的值数据类型。
另外,在调用一个方法时,也使用堆栈存储传递给方法的所有参数的复本。为了理解堆栈的
工作原理,需要注意在C#中变量的作用域。如果变量a在变量b之前进入作用域,b就会先出
作用域。下面的代码:
{
int a;
// do something
{
int b;
// do something else
}
}
首先声明a。在内部的代码块中声明了b。然后内部的代码块终止,b就出作用域,最后
a出作用域。所以b的生存期会完全包含在a的生存期中。在释放变量时,其顺序总是与给它
们分配内存的顺序相反,这就是堆栈的工作方式。
我们不知道堆栈在地址空间的什么地方,这些信息在进行C#开发是不需要知道的。堆
栈指针(操作系统维护的一个变量) 表示堆栈中下一个自由空间的地址。程序第一次运行时,
堆栈指针指向为堆栈保留的内存块末尾。堆栈实际上是向下填充的,即从高内存地址向低内
存地址填充。当数据入栈后,堆栈指针就会随之调整,以始终指向下一个自由空间。这种情
况如图11-1所示。在该图中,显示了堆栈指针800000(十六进制的0xC3500),下一个自由空
间是地址799999。
294
![](https://csdnimg.cn/release/download_crawler_static/722318/bg3.jpg)
第 11 章 内存管理和指针
图 11-1
下面的代码会告诉编译器,需要一些存储单元以存储一个整数和一个双精度浮点数,
这些存储单元会分别分配给nRacingCars和engineSize,声明每个变量的代码表示开始请求访
问这个变量,闭合花括号表示这两个变量出作用域的地方。
{
int nRacingCars = 10;
double engineSize = 3000.0;
// do calculations;
}
假定使用如图11-1所示的堆栈。变量nRacingCars进入作用域,赋值为10,这个值放在存
储单元799996~799999上,这4个字节就在堆栈指针所指空间的下面。有4个字节是因为存储
int要使用4个字节。为了容纳该int,应从堆栈指针中减去4,所以它现在指向位置799996,
即下一个自由空间 (799995)。
下一行代码声明变量engineSize(这是一个double),把它初始化为3000.0。double要占用
8个字节,所以值3000.0占据栈上的存储单元799988~799995上,堆栈指针减去8,再次指向
堆栈上的下一个自由空间。
当engineSize出作用域时,计算机就知道不再需要这个变量了。因为变量的生存期总是
嵌套的,当engineSize在作用域中时,无论发生什么情况,都可以保证堆栈指针总是会指向
存储engineSize的空间。为了从内存中删除这个变量,应给堆栈指针递增 8,现在指向
engineSize使用过的空间。此处就是放置闭合花括号的地方。当nRacingCars也出作用域时,
堆栈指针就再次递增4,此时如果内存中又放入另一个变量,从799999开始的存储单元就会
被覆盖,这些空间以前是存储nRacingCars的。
如果编译器遇到像int i、j这样的代码,则这两个变量进入作用域的顺序就是不确定的:
两个变量是同时声明的,也是同时出作用域的。此时,变量以什么顺序从内存中删除就不重
要了。编译器在内部会确保先放在内存中的那个变量后删除,这样就能保证该规则不会与变
量的生存期冲突。
11.1.2 引用数据类型
堆栈有非常高的性能,但对于所有的变量来说还是不太灵活。变量的生存期必须嵌套
295
![](https://csdnimg.cn/release/download_crawler_static/722318/bg4.jpg)
第Ⅰ部分 C# 语 言
在许多情况下,这种要求都过于苛刻。通常我们希望使用一个方法分配内存,来存储一些数
据,并在方法退出后的很长一段时间内数据仍是可以使用的。只要是用new运算符来请求存
储空间,就存在这种可能性——例如所有的引用类型。此时就要使用托管堆。
如果以前编写过需要管理低级内存的C++代码,就会很熟悉堆(heap)。托管堆和C++使
用的堆不同,它在垃圾收集器的控制下工作,与传统的堆相比有很显著的性能优势。
托管堆(简称为堆)是进程的可用4GB中的另一个内存区域。要了解堆的工作原理和如何
为引用数据类型分配内存,看看下面的代码:
void DoWork()
{
Customer arabel;
arabel = new Customer();
Customer otherCustomer2 = new EnhancedCustomer();
}
在这段代码中,假定存在两个类Customer 和 EnhancedCustomer。EnhancedCustomer类
扩展了Customer类。
首先,声明一个Customer引用arabel,在堆栈上给这个引用分配存储空间,但这仅是一
个引用,而不是实际的Cust omer对象。arab el引用占用4个字节的空间,包含了存储
Customer对象的地址(需要4个字节把内存地址表示为0到4GB之间的一个整数值)。
然后看下一行代码:
arabel = new Customer();
这行代码完成了以下操作:首先,分配堆上的内存,以存储Customer实例(一个真正的
实例,不只是一个地址)。然后把变量arabel的值设置为分配给新Customer对象的内存地址
(它还调用合适的Customer()构造函数初始化类实例中的字段,但我们不必担心这部分)。
Customer实例没有放在堆栈中,而是放在内存的堆中。在这个例子中,现在还不知道一
个Cust om er对象占用多少字节,但为了讨论方便,假定是 32字节。这32字节包含了
Customer实例字段,和.NET用于识别和管理其类实例的一些信息。
为了在堆上找到一个存储新Customer对象的存储位置,.NET运行库在堆中搜索,选取
第一个未使用的、32字节的连续块。为了讨论方便,假定其地址是200000,arabel引用占用
堆栈中的799996~799999位置。这表示在实例化arabel对象前,内存的内容应如图11-2所示。
图 11-2
296
![](https://csdnimg.cn/release/download_crawler_static/722318/bg5.jpg)
第 11 章 内存管理和指针
给Customer对象分配空间后,内存内容应如图11-3所示。注意,与堆栈不同,堆上的内
存是向上分配的,所以自由空间在已用空间的上面。
图 11-3
下一行代码声明了一个Customer引用,并实例化一个Customer对象。在这个例子中,需
要在堆栈上为mrJones引用分配空间,同时,也需要在堆上为它分配空间:
Customer otherCustomer2 = new EnhancedCustomer();
该行把堆栈上的4字节分配给otherCustomer2引用,它存储在799992~799995位置上,而
otherCustomer2对象在堆上从200032开始向上分配空间。
从这个例子可以看出,建立引用变量的过程要比建立值变量的过程更复杂,且不能避
免性能的降低。实际上,我们对这个过程进行了过分的简化,因为.NET运行库需要保存堆
的状态信息,在堆中添加新数据时,这些信息也需要更新。尽管有这些性能损失,但仍有一
种机制,在给变量分配内存时,不会受到堆栈的限制。把一个引用变量的值赋予另一个相同
类型的变量,就有两个引用内存中同一对象的变量了。当一个引用变量出作用域时,它会从
堆栈中删除,如上一节所述,但引用对象的数据仍保留在堆中,一直到程序停止,或垃圾收
集器删除它为止,而只有在该数据不再被任何变量引用时,才会被删除。
这就是引用数据类型的强大之处,在C#代码中广泛使用了这个特性。这说明,我们可
以对数据的生存期进行非常强大的控制,因为只要有对数据的引用,该数据就肯定存在于堆
上。
11.1.3 垃圾收集
由上面的讨论和图可以看出,托管堆的工作方式非常类似于堆栈,在某种程度上,对
象会在内存中一个挨一个地放置,这样就很容易使用指向下一个空闲存储单元的堆指针,来
确定下一个对象的位置。在堆上添加更多的对象时,也容易调整。但这比较复杂,因为基于
堆的对象的生存期与引用它们的基于堆栈的变量的作用域不匹配。
在垃圾收集器运行时,会在堆中删除不再引用的所有对象。在完成删除动作后,堆会
立即把对象分散开来,与已经释放的内存混合在一起,如图11-4所示。
297