18

.NET中的内存管理

 4 years ago
source link: http://mp.weixin.qq.com/s?__biz=MzAwNTMxMzg1MA%3D%3D&%3Bmid=2654077978&%3Bidx=4&%3Bsn=dcce8c9e5ae093e2976e48f37988de9c
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

e22MB3I.jpg!web

原文来自互联网,由长沙DotNET技术社区编译。 

.NET中的内存管理

资源分配

Microsoft .NET公共语言运行时要求从托管堆分配所有资源。当应用程序不再需要对象时,它们将自动释放。

初始化进程后,运行时将保留地址空间的连续区域,该区域最初没有为其分配存储空间。该地址空间区域是托管堆。堆还维护一个指针。该指针指示下一个对象将在堆中分配的位置。最初,将指针设置为保留地址空间区域的基地址。

应用程序使用new运算符创建一个对象。该运算符首先确保新对象所需的字节适合保留区域(必要时进行存储)。如果对象合适,则指针指向堆中的对象,调用该对象的构造函数,并且new运算符返回该对象的地址。

Bf2au2Q.jpg

上图显示了一个由三个对象组成的托管堆:A,B和C。要分配的下一个对象将放置在NextObjPtr指向的位置(紧随对象C之后)。

当应用程序调用new运算符创建对象时,该区域中可能没有足够的地址空间分配给该对象。堆通过将新对象的大小添加到NextObjPtr来检测到这一点。如果NextObjPtr超出地址空间区域的末尾,则堆已满,必须执行收集。

实际上,当第0代完全填满时发生收集。简而言之,生成是由垃圾收集器实现以提高性能的一种机制。这个想法是,新创建的对象是年轻一代的一部分,而在应用程序生命周期的早期创建的对象是老一代的对象。将对象分成几代可以使垃圾收集器收集特定的世代,而不是收集托管堆中的所有对象。

垃圾收集算法

垃圾收集器检查以查看堆中是否有不再由应用程序使用的对象。如果存在此类对象,则可以回收这些对象使用的内存。(如果没有更多的内存可用于堆,则new运算符将引发OutOfMemoryException。)

每个应用程序都有一组根。根标识存储位置,这些存储位置引用托管堆上的对象或设置为null的对象。例如,应用程序中的所有全局和静态对象指针都被视为应用程序根目录的一部分。另外,线程堆栈上的任何局部变量/参数对象指针都被视为应用程序根目录的一部分。最后,任何包含指向托管堆中对象的指针的CPU寄存器也被视为应用程序根目录的一部分。活动根的列表由即时(JIT)编译器和公共语言运行时维护,并且可以由垃圾收集器的算法访问。

当垃圾收集器开始运行时,它假定堆中的所有对象都是垃圾。换句话说,它假定应用程序的任何根都没有引用堆中的任何对象。现在,垃圾收集器开始遍历根目录,并为从根目录可访问的所有对象建立图形。例如,垃圾收集器可以定位一个指向堆中对象的全局变量。

下图显示了具有几个已分配对象的堆,其中应用程序的根直接引用对象A,C,D和F。所有这些对象都成为图形的一部分。在添加对象D时,收集器会注意到该对象引用了对象H,并且对象H也已添加到图中。收集器将继续递归遍历所有可到达的对象。

图的这一部分完成后,垃圾收集器将检查下一个根并再次遍历对象。当垃圾收集器从一个对象移动到另一个对象时,如果它试图将一个对象添加到先前添加的图形中,则垃圾收集器可以停止沿该路径移动。这有两个目的。首先,它不会多次遍历一组对象,因此可以显着提高性能。其次,如果您有任何循环链接的对象列表,它可以防止无限循环。

一旦检查完所有的根,垃圾收集器的图形就会包含从应用程序的根以某种方式可以访问的所有对象的集合。应用程序无法访问该图中未包含的任何对象,因此将其视为垃圾。

垃圾收集器现在线性地遍历堆,寻找垃圾对象的连续块(现在被认为是可用空间)。然后,垃圾收集器将非垃圾对象向下移动到内存中(使用标准的memcpy函数),从而消除了堆中的所有间隙。当然,在内存中移动对象会使指向该对象的所有指针无效。因此,垃圾收集器必须修改应用程序的根,以便指针指向对象的新位置。另外,如果任何对象包含指向另一个对象的指针,则垃圾回收器还负责更正这些指针。

下图显示了收集后的托管堆。

RrUN7fA.jpg

在识别完所有垃圾之后,所有非垃圾都已压缩,所有非垃圾指针都已固定,NextObjPtr定位在最后一个非垃圾对象之后。此时,再次尝试新操作,并成功创建应用程序请求的资源。

GC会对性能产生重大影响,这是使用托管堆的主要缺点。但是,请记住,GC仅在堆已满时才发生,并且在此之前,托管堆要比C运行时堆快得多。运行时的垃圾收集器还使用Generations提供了一些优化,可以大大提高垃圾收集的性能。

您不再需要实现管理应用程序使用的任何资源的生存期的任何代码。现在,不可能泄漏资源,因为可以在某个时候收集从应用程序的根目录无法访问的任何资源。此外,也无法访问已释放的资源,因为如果可访问资源将不会被释放。如果无法访问,则您的应用程序无法访问它。

以下代码演示了如何分配和管理资源:

class Application

{

public static int Main(String[] args)

{

// ArrayList object created in heap, myArray is now in root

ArrayList myArray = new ArrayList();

// Create 10000 objects in the heap

for (int x = 0; x < 10000; x++)

{

myArray.Add(new Object()); // Object object created in heap

}

// Right now, myArray is a root (on the thread's stack). So,

// myArray is reachable and the 10000 objects it points to are also reachable.

Console.WriteLine(myArray.Count);

// After the last reference to myArray in the code, myArray is not a root.

// Note that the method doesn't have to return, the JIT compiler knows

// to make myArray not a root after the last reference to it in the code.

// Since myArray is not a root, all 10001 objects are not reachable

// and are considered garbage. However, the objects are not

// collected until a GC is performed.

}

}

如果GC非常出色,那么您可能想知道为什么它不在ANSI C ++中。 原因是垃圾收集器必须能够标识应用程序的根,还必须能够找到所有对象指针。 C ++的问题在于它允许将指针从一种类型转换为另一种类型,并且无法知道指针所指的是什么。 在公共语言运行库中,托管堆始终知道对象的实际类型,并且元数据信息用于确定对象的哪些成员引用其他对象。

世代

纯粹为了提高性能而存在的垃圾收集器的一个功能称为“世代”。分代垃圾收集器(也称为临时垃圾收集器)进行以下假设:

对象越新,其生存期就会越短。 对象越旧,其寿命将越长。 较新的对象往往彼此之间具有很强的关系,并且经常在同一时间访问。 压缩一部分堆比压缩整个堆要快。

初始化后,托管堆不包含任何对象。如下图所示,添加到堆中的对象被称为第0代。简而言之,第0代中的对象是从未被垃圾收集器检查过的年轻对象。

ziU3Qf3.jpg

Memory6.gif

现在,如果将更多对象添加到堆中,则将填充堆,并且必须进行垃圾回收。垃圾收集器分析堆时,将构建垃圾(此处以绿色显示)和非垃圾对象的图形。可以将收集到的所有对象压缩到堆的最左侧。这些对象在收藏中幸存下来,并且更旧,现在被认为是第一代。

jQFJvmM.jpg

随着更多对象添加到堆中,这些新的年轻对象将放置在第0代中。如果再次填充第0代,则会执行GC。这次,将第1代中幸存的所有对象压缩并视为第2代(请参见下图)。现在压缩了第0代中的所有幸存者,并认为它们是第1代。第0代当前不包含任何对象,但是所有新对象将进入第0代。

FjqmueY.jpg

当前,第二代是运行时的垃圾收集器支持的最高一代。当将来发生收集时,当前第2代中尚存的所有对象仅保留在第2代中。

世代GC性能优化

分代垃圾收集提高了性能。当堆填满并发生收集时,垃圾收集器可以选择仅检查第0代中的对象,而忽略任何更大的后代中的对象。毕竟,对象越新,则预期寿命越短。因此,收集和压缩第0代对象很可能会从堆中回收大量空间,并且比收集器检查所有代的对象要快。

分代收集器可以通过不遍历托管堆中的每个对象来提供更多优化。如果根或对象引用的是旧对象,则垃圾收集器可以忽略任何较旧对象的内部引用,从而减少了构建可访问对象图所需的时间。当然,旧对象可能是指新对象。为了检查这些对象,收集器可以利用系统的写监视支持(由Kernel32.dll中的Win32 GetWriteWatch函数提供)。此支持使收集器知道自上次收集以来已将哪些旧对象(如果有)写入了。可以检查这些特定的旧对象的引用,以查看它们是否引用了任何新对象。

如果收集第0代未提供必要的存储量,则收集器可以尝试收集第1代和第0代的对象。如果所有其他操作均失败,则收集器可以收集第2代,第1代和第9代的所有对象。0。

前面提到的一种假设是,较新的对象之间往往具有很强的关系,并且经常在同一时间访问。由于新对象是在内存中连续分配的,因此您可以从引用的位置获得性能。更具体地说,很可能所有对象都可以驻留在CPU的缓存中。您的应用程序将以惊人的速度访问这些对象,因为CPU将能够执行其大多数操作,而不会导致强制RAM访问的高速缓存未命中。

微软的性能测试表明,托管堆分配比Win32 HeapAlloc函数执行的标准分配更快。这些测试还表明,在200 MHz Pentium上执行第0代完整GC所需的时间少于1毫秒。Microsoft的目标是使GC花费的时间不比普通页面错误多。

Win32堆的缺点:

大多数堆(例如C运行时堆)在找到可用空间的任何地方分配对象。因此,如果我连续创建多个对象,则这些对象很有可能将被兆字节的地址空间分隔开。但是,在托管堆中,连续分配几个对象可确保对象在内存中是连续的。 从Win32堆分配内存时,必须检查该堆以找到可以满足请求的内存块。这在托管堆中不是必需的,因为此处对象在内存中是连续的。 在Win32堆中,必须维护堆维护的数据结构。另一方面,托管堆仅需要增加堆指针。

终接器

垃圾收集器提供了您可能想利用的其他功能:终结处理。最终确定允许资源在被收集后对其进行适当的清理。通过使用终结处理,当垃圾回收器决定释放资源的内存时,代表文件或网络连接的资源便能够正确清理自身。

当垃圾收集器检测到对象是垃圾时,垃圾收集器将调用对象的Finalize方法(如果存在),然后回收该对象的内存。例如,假设您具有以下类型(在C#中):

public class BaseObj

{

public BaseObj()

{

}

protected override void Finalize()

{

// Perform resource cleanup code here

// Example: Close file/Close network connection

Console.WriteLine("In Finalize.");

}

}

现在,您可以通过调用以下内容来创建该对象的实例:

BaseObj bo = new BaseObj();

将来的某个时候,垃圾收集器将确定该对象为垃圾。发生这种情况时,垃圾收集器将看到该类型具有Finalize方法,并将调用该方法,从而使“ In Finalize”出现在控制台窗口中并回收该对象使用的内存块。

许多习惯于使用C ++进行编程的开发人员都会在析构函数和Finalize方法之间建立直接的关联。但是,对象终结处理和析构函数具有非常不同的语义,在考虑终结处理时,最好忘记您对析构函数的了解。受管对象永远不会有析构函数。

设计类型时,最好避免使用Finalize方法。有几个原因:

可终结对象被提升为较早的一代,这增加了内存压力,并在垃圾收集器确定对象为垃圾时阻止了对象的内存被收集。此外,该对象直接或间接引用的所有对象也将得到提升。

可终结对象需要更长的分配时间。

强制垃圾收集器执行Finalize方法会严重影响性能。请记住,每个对象都已完成。因此,如果我有10,000个对象的数组,则每个对象都必须调用其Finalize方法。

终结对象可以引用其他(不可终结)对象,从而不必要地延长其寿命。实际上,您可能需要考虑将类型分为两种不同的类型:一种轻型类型,其具有不引用任何其他对象的Finalize方法,一个单独的类型,其类型不具有引用其他对象的Finalize方法。

您无法控制Finalize方法何时执行。该对象可能会保留资源,直到下一次垃圾收集器运行为止。

当应用程序终止时,某些对象仍然可以访问,并且不会调用其Finalize方法。如果后台线程正在使用对象,或者在应用程序关闭或AppDomain卸载期间创建了对象,则会发生这种情况。此外,默认情况下,应用程序退出时,不可达对象不会调用Finalize方法,因此应用程序可能会迅速终止。当然,将回收所有操作系统资源,但是托管堆中的任何对象都无法正常清理。您可以通过调用System.GC类型的RequestFinalizeOnShutdown方法来更改此默认行为。但是,应谨慎使用此方法,因为调用它意味着您的类型正在控制整个应用程序的策略。

运行时无法保证Finalize方法的调用顺序。例如,假设有一个对象包含一个指向内部对象的指针。垃圾收集器检测到两个对象都是垃圾。此外,假设首先调用内部对象的Finalize方法。现在,允许外部对象的Finalize方法访问内部对象并对其调用方法,但是内部对象已完成,并且结果可能无法预测。因此,强烈建议Finalize方法不要访问任何内部成员对象。

如果确定类型必须实现Finalize方法,则请确保代码尽快执行。避免所有会阻止Finalize方法的操作,包括任何线程同步操作。另外,如果您让任何异常转义了Finalize方法,则系统仅假定Finalize方法已返回,并继续调用其他对象的Finalize方法。

当编译器为构造函数生成代码时,编译器会自动插入对基本类型的构造函数的调用。同样,当C ++编译器为析构函数生成代码时,编译器会自动插入对基本类型的析构函数的调用。终结方法不同于析构函数。编译器对Finalize方法没有特殊知识,因此编译器不会自动生成代码以调用基本类型的Finalize方法。如果您想要这种行为,并且经常这样做,那么必须从类型的Finalize方法中显式调用基本类型的Finalize方法:

 

public class BaseObj

{

public BaseObj()

{

}

protected override void Finalize()

{

Console.WriteLine("In Finalize.");

base.Finalize(); // Call base type's Finalize

}

}

请注意,通常将基类型的Finalize方法称为派生类型的Finalize方法中的最后一条语句。这样可以使基础对象保持尽可能长的生命。由于调用基本类型的Finalize方法很常见,因此C#的语法简化了您的工作。在C#中,以下代码:

class MyObject { MyObject() { } }

终结内部

当应用程序创建新对象时,新运算符将从堆中分配内存。如果对象的类型包含Finalize方法,则将指向该对象的指针放在终结队列中。终结队列是由垃圾收集器控制的内部数据结构。队列中的每个条目都指向一个对象,在可以回收该对象的内存之前,应调用该对象的Finalize方法。

下图显示了包含多个对象的堆。从应用程序的根目录可以访问其中的某些对象,而某些则不能。创建对象C,E,F,I和J时,系统检测到这些对象具有Finalize方法,并将指向这些对象的指针添加到了终结队列中。

NFVjmeq.jpg

发生GC时,对象B,E,G,H,I和J被确定为垃圾。垃圾收集器扫描完成队列,以查找指向这些对象的指针。当找到一个指针时,该指针将从终结队列中删除,并附加到易碎队列(发音为“ F-reachable”)。易碎队列是由垃圾收集器控制的另一个内部数据结构。易碎队列中的每个指针都标识一个对象,该对象已准备好调用其Finalize方法。

收集之后,托管堆如下图所示。在这里,您看到对象B,G和H占用的内存已被回收,因为这些对象没有需要调用的Finalize方法。但是,无法回收对象E,I和J占用的内存,因为尚未调用它们的Finalize方法。

bq2e6vE.jpg

有一个专用的运行时线程专用于调用Finalize方法。当可访问队列为空时(通常是这种情况),该线程进入睡眠状态。但是,当出现条目时,该线程将唤醒,从队列中删除每个条目,并调用每个对象的Finalize方法。因此,您不应在Finalize方法中执行任何有关执行代码的线程的假设的代码。例如,避免在Finalize方法中访问线程本地存储。

终结队列与易碎队列的交互非常有趣。首先,让我告诉您易碎队列的名称。f很明显,代表定稿;易碎队列中的每个条目都应调用其Finalize方法。名称的“可到达”部分表示对象可到达。换句话说,易碎队列被视为根,就像全局变量和静态变量是根一样。因此,如果对象在易碎队列中,则该对象可访问且不是垃圾。

简而言之,当对象不可访问时,垃圾收集器将其视为对象垃圾。然后,当垃圾收集器将对象的条目从终结队列移到可访问队列时,该对象不再被视为垃圾,并且不回收其内存。至此,垃圾收集器已经完成了对垃圾的识别。某些标识为垃圾的对象已被重新分类为非垃圾。垃圾收集器压缩可回收内存,特殊的运行时线程清空易碎队列,执行每个对象的Finalize方法。

f6FNBri.jpg

下次调用垃圾回收器时,它会看到最终对象是真正的垃圾,因为应用程序的根不指向该对象,并且易碎队列不再指向该对象。现在,只需回收该对象的内存即可。这里要了解的重要一点是,需要两个GC来回收需要终结处理的对象使用的内存。实际上,可能需要两个以上的集合,因为这些对象可以提升为较老的一代。上图显示了第二个GC之后托管堆的外观。

处置方法

使用此方法可以关闭或释放由实现此接口的类的实例持有的非托管资源,例如文件,流和句柄。按照惯例,此方法用于与释放对象拥有的资源或准备对象重用相关的所有任务。

在实现此方法时,对象必须设法通过在包含层次结构中传播调用来确保释放所有保留的资源。例如,如果对象A分配了对象B,而对象B分配了对象C,则A的Dispose实现必须调用B上的Dispose,后者又必须调用C上的Dispose。对象还必须调用其基类的Dispose方法。如果基类实现IDisposable。

如果多次调用对象的 Dispose方法,则该对象必须忽略第一个调用之后的所有调用。如果多次调用其Dispose方法,则该对象不得引发异常。如果由于已释放资源并且以前未调用过Dispose而发生错误,则Dispose可能引发异常。

因为必须显式调用 Dispose方法,所以实现IDisposable的对象还必须实现终结器,以在不调用Dispose时处理释放资源。默认情况下,垃圾回收器将在回收对象的内存之前自动调用其终结器。但是,一旦调用了Dispose方法,垃圾收集器通常就不需要调用已处理对象的终结器。为了防止自动完成,Dispose实现可以调用GC.SuppressFinalize方法。

通过System.GC直接控制

System.GC类型使您的应用程序可以直接控制垃圾收集器。您可以通过读取GC.MaxGeneration属性来查询托管堆支持的最大生成量。当前,GC.MaxGeneration属性始终返回2。

也可以通过调用此处显示的两个方法之一来强制垃圾收集器执行收集:

void GC.Collect(Int32 Generation)

void GC.Collect()

第一种方法允许您指定要收集的世代。您可以将0范围内的任何整数传递给GC.MaxGeneration(含)。传递0导致生成0被收集;传递1导致收集第1代和第0代;传递2会导致生成2、1、0和0。不带参数的Collect方法的版本强制所有世代的完整集合,等效于调用:

GC.Collect(GC.MaxGeneration);

GC类型还提供了WaitForPendingFinalizers方法。此方法只是挂起调用线程,直到处理易碎队列的线程清空了队列,然后调用每个对象的Finalize方法。在大多数应用程序中,您不太可能需要调用此方法。

最后,垃圾收集器提供了两种方法,可让您确定对象当前处于哪个世代:

Int32 GetGeneration(Object obj) Int32 GetGeneration(WeakReference wr)

GetGeneration的第一个版本将对象引用作为参数,而第二个版本将WeakReference引用作为参数。当然,返回的值将介于0到GC.MaxGeneration之间(含)。

2EVvMv6.jpg!web


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK