C#托管和非托管的资源(二)——后台内存管理

来源:互联网 发布:linux下sleep头文件 编辑:程序博客网 时间:2024/05/21 16:52

C#编程的一个优点是程序员不需要担心具体的内存管理,垃圾回收器会自动处理所有的内存清理工作。用户可以得到像C++语言那样的效率,而不需要考虑像在C++中那样内存管理工作的复杂性。虽然不必手动管理内存,但仍需要理解后台发生的事情。理解程序在后台如何管理内存有助于提高应用程序的速度和性能。以下要介绍给变量分配内存时在计算机的内存中发生的情况。

1、值数据类型

Windows使用一个虚拟寻址系统,该系统把程序可用的内存地址映射到硬件内存中的实际地址上,这些任务完全由Windows在后台管理。其实际结果是32位处理器上的每个进程都可以使用4GB的内存——无论计算机上实际有多少物理内存,(在64位处理器上,每个进程都可以使用128GB的内存)。这个4GB的内存实际上包含了程序的所有部分,包括可执行代码、代码加载的所有DLL,以及程序运行时使用的所有变量的内容。这个4GB的内存称为虚拟地址空间,或虚拟内存。

 注意:.NETCore应用程序中,在Visual Studio项目属性的Debug设置中选择体系结构,指定是调试32位还是64位应用程序。选择x86时,就调试运行在32位和64位系统上的32位应用程序;选择x64时,就调试运行在64位系统上的64位应用程序。如果看不到不同的选项,就必须安装具体的运行库。(如下图1)


4GB中的每个存储单元都是从0开始往上排序的。要访问存储在内存的某个空间中的一个值,就需要提供表示该存储单元的数字。在任何复杂的高级语言中,编译器负责把人们可以理解的变量名转换为处理器可以理解的内存地址。在处理器的虚拟内存中,有一个区域称为栈。栈存储不是对象成员的值数据类型。另外,在调用一个方法时,也使用栈存储传递给方法的所有参数的副本。为了理解栈的工作原理,需要注意在C#中的变量作用域。如果变量a在变量b之前进入作用域,b就会首先超出作用域。考虑下面的代码:

{

int a;

//执行某些操作

{

int b;

 //执行另一些操作

}

 }

首先声明变量a。接着在内部代码块中声明了b。然后内部代码块终止,b就超出作用域,最后a超出作用域。所以b的生存期完全包含在a的生存期中。在释放变量时,其顺序总是与给它们分配内存的顺序相反,这就是栈的工作方式。

还要注意,b在另一个代码块中(通过另一对嵌套的花括号来定义)。因此,它包含在另一个作用域中。这称为块作用域或结构作用域。我们不知道栈具体在地址空间的什么地方,这些信息在进行C#开发时是不需要知道的。栈指针(操作系统维护的一个变量)表示栈中下一个空闲存储单元的地址。程序第一次开始运行时,栈指针指向为栈保留的内存块末尾。栈实际上是向下填充的,即从高内存地址向低内存地址填充。当数据入栈后,栈指针就会随之调整,以始终指向下一个空闲存储单元。这种情况如下图所示。在该图中,显示了栈指针800000(十六进制的0xC3500),下一个空闲空闲存储单元是地址799999


下面的代码会告诉编译器,需要一些存储空间以存储一个整数和一个双精度浮点数,这些存储单元分别称为nRacingCarsengineSize。声明每个变量的代码行表示开始请求访问这个变量,闭合花括号标识这两个变量超出作用域的地方。

{

 int nRacingCars = 10;

double engineSize = 3000. 0;

// 执行计算

}

假定使用如上图所示的栈。nRacingCars变量进入作用域,赋值为10,这个值放在存储单元799996799999上,这4个字节就在栈指针所指空间的下面。有4个字节是因为存储int要使用4个字节。为了容纳该int,应从栈指针对应的值中减去4,所以它现在指向位置799996,即下一个空闲单元(799995)。下一行代码声明变量engineSize(这是一个double数),把它初始化为3000.0。一个double数要占用8个字节,所以值3000.0放在栈上的存储单元799988799995上,栈指针对应的值减去8,再次指向栈上的下一个空闲单元。当engineSize超出作用域时,运行库就知道不再需要这个变量了。因为变量的生存期总是嵌套的,当engineSize在 作用域 中 时, 无论 发生 什么 情况, 都可以 保证 栈 指针 总是 会 指向 存储engineSize 的 空间。 为了 从 内存 中 删除 这个 变量, 应 给 栈 指针 对应 的 值 递增8, 现在 它 指向engineSize 末尾 紧接着 的 空间。 此处 就是 放置 闭合 花 括号 的 地方。 当nRacingCars 也 超出 作用域 时, 栈 指针 对应 的 值 就再 次 递增4。 从栈中删除enginesizenRacingCars之后,此时如果在作用域中又放入另一个变量,从799999开始的存储单元就会被覆盖,这些空间以前是存储nRacingCars的。如果编译器遇到int ij 这样的代码行,则这两个变量进入作用域的顺序是不确定的。两个变量是同时声明的,也是同时超出作用域的。此时,变量以什么顺序从内存中删除就不重要了。编译器在内部会确保先放在内存中的那个那个变量后删除,这样就能保证该规则不会与变量的生存期冲突。

2、引用数据类型

尽管栈有非常高的性能,但它还没有灵活到可以用于所有的变量。变量的生存期必须嵌套,在许多情况下,这种要求都过于苛刻。通常我们希望使用一个方法分配内存,来存储一些数据,并在方法退出后的很长一段时间内数据仍是可用的。只要是用new运算符来请求分配存储空间,就存在这种可能性——例如,对于所有的引用类型。此时就要使用托管堆。

如果您以前编写过需要管理低级内存的C++代码,就会很熟悉堆(heap)。托管堆和C++使用的堆不同,它在垃圾回收器的控制下工作,与传统的堆相比有很显著的优势。托管堆(简称为堆)是处理器的可用内存中的另一个内存区域。要了解堆的工作原理和如何为引用数据类型分配内存,看看下面的代码:

void DoWork()

{

Customer arabel;

arabel = new Customer();

Customer otherCustomer2 = new EnhancedCustomer();

}

在这段代码中,假定存在两个类CustomerEnhancedCustomerEnhancedCustomer类扩展了Customer类。首先,声明一个Customer引用arabel,在栈上给这个引用分配存储空间,但这仅是一个引用,而不是实际的Customer对象。arabel引用占用4个字节的空间,足够包含Customer对象的存储地址(需要4个字节把04GB之间的内存地址表示为一个整数值)。然后看下一行代码:

arabel = new Customer();

这行代码完成了以下操作:首先,它分配堆上的内存,以存储Customer对象(一个真正的对象,不只是一个地址)。然后把变量arabel的值设置为分配给新Customer对象的内存地址(它还调用合适的Customer()构造函数初始化类实例中的字段,但此处我们不必担心这部分)。Customer实例没有放在栈中,而是放在堆中。在这个例子中,现在还不知道一个Customer对象占用多少字节,但为了讨论方便,假定是32个字节。这32个字节包含了Customer的实例字段,和.NET用于识别和管理其类实例的一些信息。

为了在堆上找到存储新Customer对象的一个存储位置,.NET运行库在堆中搜索,选取第一个未使用的且包含32个字节的连续块。为了讨论方便,假定其地址是200000,arabel引用占用栈中的799996~799999位置。这表示在实例化arabel对象前,内存的内容应如下图所示。



Customer对象分配空间后,内存的内容应如下图所示。注意,与栈不同,堆上的内存是向上分配的,所以空闲空间在已用空间的上面。


下一行代码声明了一个Customer引用,并实例化一个Customer对象。在这个例子中,用一行代码在栈上为otherCustomer2引用分配空间,同时在堆上为mrJones对象分配空间:

Customer otherCustomer2 = new EnhancedCustomer();

该行把栈上的4个字节分配给otherCustomer2引用,它存储在799992799995位置上,而otherCustomer2对象在堆上从200032开始向上分配空间。从这个例子可以看出,建立引用变量的过程要比建立值变量的过程更复杂,且不能避免性能的系统开销。实际上,我们对这个过程进行了过分的简化,因为.NET运行库需要保存堆的状态信息,在堆中添加新数据时,这些信息也需要更新。尽管有这些性能开销,但仍有一种机制,在给变量分配内存时,不会受到栈的限制。把一个引用变量的值赋予另一个相同类型的变量,就有两个变量引用内存中的同一对象了。当一个引用变量超出作用域时,它会从栈中删除,如上一节所述,但引用对象的数据仍保留在堆中,一直到程序终止,或垃圾回收器删除它为止,而只有在该数据不再被任何变量引用时,它才会被删除。

这就是引用数据类型的强大之处,在C#代码中广泛使用了这个特性。这说明,我们可以对数据的生存期进行非常强大的控制,因为只要保持对数据的引用,该数据就肯定存在于堆上。

垃圾回收

由上面的讨论和图3和图4可以看出,托管堆的工作方式非常类似于栈,对象会在内存中一个挨一个地放置,这样就很容易使用指向下一个空闲存储单元的堆指针来确定下一个对象的位置。在堆上添加更多的对象时,也容易调整。但这比较复杂,因为基于堆的对象的生存期与引用它们的基于栈的变量的作用域不匹配。

在垃圾回收器运行时,它会从堆中删除不再引用的所有对象。垃圾回收器在引用的根表中找到所有引用的对象,接着在引用的对象树中查找。在完成删除操作后,堆会立即把对象分散开来,与已经释放的内存混合在一起,如图5所示。



如果托管的其上给新对象分配内存就成为一个很难处理的过程,运行库必须搜索整个堆,才能找到足够大的内存块来存储每个新对象。但是,垃圾回收器不会让堆处于这种状态。只要它释放了能释放的所有对象,就会把其他对象移动回堆的端部,再次形成一个连续的内存块。因此,堆可以继续像栈那样确定在什么地方存储新对象。当然,在移动对象时,这些对象的所有引用都需要用正确的新地址来更新,但垃圾回收器也会处理更新问题。

垃圾回收器的这个压缩操作是托管的堆与非托管的堆的区别所在。使用托管的堆,就只需要读取堆指针的值即可,而不需要遍历地址的链表,来查找一个地方放置新数据。

注意:一般情况下,垃圾回收器在.NET运行库确定需要进行垃圾回收时运行。可以调用System.GC.Collect()方法,强迫垃圾回收器在代码的某个地方运行。System.GC类是一个表示垃圾回收器的.NET类,Collect()方法启动一个垃圾回收过程。但是,GC类适用的场合很少,例如,代码中有大量的对象刚刚取消引用,就适合调用垃圾回收器。但是,垃圾回收器的逻辑不能保证在一次垃圾收集过程中,所有未引用的对象都从堆中删除。 

注意:在测试过程中运行GC是很有用的。这样,这样,就可以看到应该回收的对象仍然未回收而导致的内存泄漏。因为垃圾回收器的工作做得很好,所以不要在生产代码中以编程方式回收内存。如果以编程方式调用Collect,对象会更快地移入下一代,如下所示。这将导致GC运行更多的时间。

创建对象时,会把这些对象放在托管堆上。堆的第一部分称为第0代。创建新对象时,会把它们移动到堆的这个部分中。因此,这里驻留了最新的对象。

对象会继续放在这个部分,直到垃圾回收过程第一次进行回收。这个清理过程之后仍保留的对象会被压缩,然后移动到堆的下一部分上或世代部分——第1代对应的部分。此时,第0代对应的部分为空,所有的新对象都再次放在这一部分上。在垃圾回收过程中遗留下来的旧对象放在第1代对应的部分上。老对象的这种移动会再次发生。接着重复下一次回收过程。这意味着,第1代中在垃圾回收过程中遗留下来的对象会移动到堆的第2代,位于第0代的对象会移动到第1代,第0代仍用于放置新对象。 

注意:有趣的是,在给对象分配内存空间时,如果超出了第0代对应的部分的容量,或者调用了GC.Collect()方法,就会进行垃圾回收。

这个过程极大地提高了应用程序的性能。一般而言,最新的对象通常是可以回收的对象,而且可能也会回收大量比较新的对象。如果这些对象在堆中的位置是相邻的,垃圾回收过程就会更快。另外,相关的对象相邻放置也会使程序执行得更快。在.NET中,垃圾回收提高性能的另一个领域是架构处理堆上较大对象的方式。在.NET下,较大对象有自己的托管堆,称为大对象堆。使用大于85000个字节的对象时,它们就会放在这个特殊的堆上,而不是主堆上。.NET应用程序不知道两者的区别,因为这是自动完成的。其原因是在堆上压缩大对象是比较昂贵的,因此驻留在大对象堆上的对象不执行压缩过程。

在进一步改进垃圾回收过程后,第二代和大对象堆上的回收现在放在后台线程上进行。这表示,应用程序线程仅会为第0代和第1代的回收而阻塞,减少了总暂停时间,对于大型服务器应用程序尤其如此。服务器和工作站默认打开这个功能。

有助于提高应用程序性能的另一个优化是垃圾回收的平衡,它专用于服务器的垃圾回收。服务器一般有一个线程池,执行大致相同的工作。内存分配在所有线程上都是类似的。对于服务器,每个逻辑服务器都有一个垃圾回收堆。因此其中一个堆用尽了内存,触发了垃圾回收过程时,所有其他堆也可能会得益于垃圾的回收。如果一个线程使用的内存远远多于其他线程,导致垃圾回收,其他线程可能不需要垃圾回收,这就不是很高效。垃圾回收过程会平衡这些堆——小对象堆和大对象堆。进行这个平衡过程,可以减少不必要的回收。

为了利用包含大量内存的硬件,垃圾回收过程添加了GCSettings.LatencyMode属性。把这个属性设置为GCLatencyMode枚举的一个值,可以控制垃圾回收器进行回收的方式。下表列出了GCLatencyMode可用的值。

成员

说明

Batch

禁用并发设置,把垃圾回收设置为最大吞吐量。这会重写配置设置

Interactive

工作站的默认行为。它使用垃圾回收并发设置,平衡吞吐量和响应

LowLatency

保守的垃圾回收。只有系统存在内存压力时,才进行完整的回收。只应用于较短时间,执行特定的操作

SustainedLowLatency

只有系统存在内存压力时,才进行完整的内存块回收

NoGCRegion

.NET4.6新增成员。对于GCSettings,这是一个只读属性。可以在代码块中调用GC.TryStartNoGC-RegionEndNoGCRegion来设置它。调用TryStartNoGCRegion,定义需要可用的、GC试图访问的内存大小。成功调用TryStartNoGCRegion后,指定不应运行的垃圾回收器,直到调用EndNoGCRegion为止

LowLatencyNoGCRegion设置使用的时间应为最小值,分配的内存量应尽可能小。如果不小心,就可能出现溢出内存错误。



原创粉丝点击