C#的内存管理原理解析+标准Dispose模式的实现

时间:2023-11-09 19:03:44

本文内容是本人参考多本经典C#书籍和一些前辈的博文做的总结

尽管.NET运行库负责处理大部分内存管理工作,但C#程序员仍然必须理解内存管理的工作原理,了解如何高效地处理非托管的资源,才能在非常注重性能的系统中高效地处理内存。

C#编程的一个优点就是程序员不必担心具体的内存管理,垃圾回收器会自动处理所有的内存清理工作。用户可以得到近乎像C++语言那样的效率,而不必考虑像C++中复杂的内存管理工作。但我们仍需要理解程序在后台如何处理内存,才有助于提高应用程序的速度和性能。

先了解一下Windows系统中的虚拟寻址系统:

该系统把程序可用的内存地址映射到硬件内存中的实际地址上,在32位处理器上的每个进程都可以使用4GB的硬件内存(64位处理器更大),这个4GB的内存包含了程序的所有部分(包括可执行代码、代码加载的所有DLL、程序运行时使用的所有变量的内容)

这个4GB的内存称为虚拟地址空间,或虚拟内存。其中的每个存储单元都是从0开始排序的。要访问存储在内存的某个空间中的一个值,就需要提供表示该存储单元的数字。编译器负责把变量名转换为处理器可以理解的内存地址。

值类型和引用类型在C#中的数据类型分为值类型和引用类型,对他们使用了不同但又相似的内存管理机制。

1.值数据类型的内存管理

在进程的虚拟内存中,有一个区域称为栈。C#的值类型数据、传递给方法的参数副本都存储在这个栈中。在栈中存储数据时,是从高内存地址向低内存地址填充的。

操作系统维护一个变量,称为栈指针。栈指针为当前变量所占内存的最后一个字节地址,栈指针会根据需要随时调整,它总是会调整为指向栈中下一个空闲存储单元的地址。当有新的内存需求时,就根据当前栈指针的值开始往下来为该需求分配足够的内存单元,分配完后,栈指针更新为当前变量所占内存的最后一个字节地址,它将在下一次分配内存时调整为指向下一个空闲单元。

如:int a= 10;

声明一个整型的变量需要32位,也就是4个字节内存,假设当前栈指针为89999,则系统就会为变量a分配4个内存单元,分别为89996~89999,之后,栈指针更新为89995

double d = 20.13; //需要64位,也就是8个字节内存,存储在89988~89995

栈的工作方式是先进后出(FIFO):在释放变量时,总是先释放后面声明的变量(后面分配内存)。

2.引用数据类型的内存管理

引用类型对象的引用存储在栈中(占4个字节的空间),而它的实际数据存储在主托管堆或大对象堆上,托管堆是可用的4GB虚拟内存中的另一个内存区域。

大对象堆:在.NET下,因为压缩较大对象(大于85000个字节)很影响性能,所以为它们分配了自己的托管堆。.NET垃圾回收器不对大对象堆执行压缩过程。

如:Person arabel= new Person();

声明变量arabel时,在栈上为该变量分配4个字节的空间以存储一个引用,new运算符为对象Person对象在堆上分配空间,然后把该空间的地址赋给变量arabel,而构造函数则用来初始化。

.NET运行库为了给对象arabel分配空间,需要搜索堆,选取第一个未使用的且足够容纳对象所有数据的连续块。但垃圾回收器程序在回收堆中所有无引用的对象后,会执行压缩操作,即:把剩下的有用对象移动到堆的端部,挨在一起形成一个连续的内存块,并更新所有对象的引用为新地址,同时更新堆指针,方便为下一个新对象分配堆空间。

一般情况下,垃圾回收器在.NET运行库认为需要它时运行。

System.GC类是一个表示垃圾回收器的.NET类,可以调用System.GC.Collect()方法,强迫垃圾回收器在代码的某个地方运行。

当代码中有大量的对象刚刚取消引用,就比较适合调用垃圾回收器,但不能保证所有未引用的对象都能从堆中删除。

垃圾回收器运行时,它实际上会降低程序的性能,因为在它执行期间,将会暂停应用程序的其它所有线程。

但.NET垃圾回收器使用了"世代垃圾回收器(generational)":

托管堆分为几个部分:第0代,第1代,第2代

所有新对象都被分配在第0代部分,在给新对象分配堆空间时,如果超出了第0代对应的部分的容量(),或者调用了GC.Collect()方法,就会开始进行垃圾回收。

每当垃圾回收器执行压缩时,第0代部分留下来的对象将会被移动到第1代上,此时第0代部分就变成空,用来放置下一个新对象。

类似的,当第一代满时,也会进行压缩,剩下对象移到下一代。

托管堆有一个堆指针,功能和栈指针类似。

3.总结:

使用.Net框架开发程序的时候,我们无需关心内存分配问题,因为有GC这个大管家给我们料理一切。C#中栈是编译期间就分配好的内存空间,因此你的代码中必须就栈的大小有明确的定义;堆是程序运行期间动态分配的内存空间,你可以根据程序的运行情况确定要分配的堆内存的大小

C#程序在CLR上运行的时候,内存从逻辑上划分两大块:栈,堆。这俩基本元素组成我们C#程序的运行环境

栈通常保存着我们代码执行的步骤,如 AddFive()方法,int pValue变量,int result变量等。而堆上存放的则多是对象,数据等。我们可以把栈想象成一个接着一个叠放在一起的盒子。当我们使用的时候,每次从最顶部取走一个盒子。栈也是如此,当一个方法(或类型)被调用完成的时候,就从栈顶取走(called a Frame:调用帧),接着下一个。

堆则不然,像是一个仓库,储存着我们使用的各种对象等信息,跟栈不同的是他们被调用完毕不会立即被清理掉(等待垃圾回收器来清理)。

栈内存无需我们管理,也不受GC管理。当栈顶元素使用完毕,立马释放。而堆则需要GC(Garbage collection:垃圾收集器)清理。

当我们的程序执行的时候,在栈和堆中分配有四种主要的类型:值类型,引用类型,指针,指令。

  • 值类型:在C#中,继承自System.ValueType的类型被称为值类型,bool byte char decimal double enum float int long sbyte short struct uint ulong ushort`
  • 引用类型:继承自System.Objectclass interface delegate object string
  • 指针:在内存区中,指向一个类型的引用,通常被称为“指针”,它是受CLR( Common Language Runtime:公共语言运行时)管理,我们不能显式使用。指针在内存中占一块内存区,它本身只代表一个内存地址(或者null),它所指向的另一块内存区才是我们真正的数据或者类型。
值类型、引用类型的内存分配:
  • 引用类型总是被分配在堆上
  • 值类型和指针总是分配在被定义的地方,他们不一定被分配到栈上,如果一个值类型被声明在一个方法体外并且在一个引用类型中,那它就会在堆上进行分配。

栈(Stack),在程序运行的时候,每个线程(Thread)都会维护一个自己的专属线程堆栈。

当一个方法被调用的时候,主线程开始在所属程序集的元数据中,查找被调用方法,然后通过JIT即时编译并把结果(一般是本地CPU指令)放在栈顶。CPU通过总线从栈顶取指令,驱动程序以执行下去。

当程序需要更多的堆空间时,GC需要进行垃圾清理工作,暂停所有线程,找出所有不可达到对象,即无被引用的对象,进行清理、压缩。并通知栈中的指针重新指向地址排序后的对象。

4.释放非托管的资源

有了垃圾回收器,意味着我们只要让不再需要的对象的所有引用都超出作用域,并允许垃圾回收器在需要时释放内存即可。

原则:在.net中,没有必要调用Dispose的时候,你就不要调用它(垃圾回收器运行时会占用/阻塞主线程)。

但是,垃圾回收器不知道如何释放非托管的资源(如文件句柄、网络连接、数据库连接)。

在定义一个类时,有两种机制来自动释放非托管的资源:(更保险的做法是同时使用两种机制,防止忘记调用Dispose()方法)

  1. 声明一个析构函数(终结器);
  2. 为类实现System.IDiposable接口,实现Dispose()方法;

5.析构函数:

C#编译器在编译析构函数时,它会隐式地把析构函数编译为等价于Finalize()方法,从而确保执行父类的Finalize()方法。

定义方式如下:析构函数无返回值、无参数、无访问修饰符

class MyClass
{
~MyClass()
{
}
}
//以下版本是编译析构函数实际调用的等价代码:
protected override void Finalize()
{
try
{ //释放自身资源 }
finally
{ base.Finalize(); }
}
析构函数的缺点:

由于C#使用垃圾回收器的工作方式,无法确定C#对象的析构函数何时执行。

定义了析构函数的对象需要经过两次垃圾回收处理才能被销毁(第二次调用析构函数时才真正删除对象),而没有定义析构函数的对象反而只需要一次处理即可删除。

如果频繁使用析构函数,而且执行长时间的清理任务,会严重影响性能。

6.IDiposable接口:

所以,推荐通过为类实现System.IDisposable接口,实现Dispose()方法,来替代析构函数。IDisposable接口定义的模式为释放非托管资源提供了确定的机制,并避免了对垃圾回收器依赖的问题。

IDisposable接口声明了Dispose()方法,无参数,无返回值。可以为Dispose()方法实现代码来显式地释放由对象直接使用的所有非托管资源,并在所有也实现IDisposable接口的封装对象中调用Dispose()方法。这样,该方法可以可以精确地控制非托管资源的释放。

注意:如果在Dispose()方法调用之前的运行代码抛出了异常,则该方法就执行不到了,所以应该使用try...finally,并把Dispose()方法放在finally块内,以确保它的执行。如下:

Person person = null;  //假设Person类实现了IDisposable接口
try
{
person = new Person();
}
finally
{
if(person != null)
{
person.Dispose();
}
}

C#提供了using关键字语法,可以确保在实现了IDisposable接口的对象的引用超出作用域时,在该对象上自动调用Dispose()方法,如下:

using ( Person person = new Person() )
{ ..... }

using语句后面是一对"()",其中是引用变量的声明和实例化,该语句是其中的变量放在随后的语句块中,并且在变量超出作用域时,即使抛出异常,也会自动调用Dispose()方法。

然后,在需要捕获其它异常时,使用try...finally的方式就会比较清晰。而常常为Dispose()方法定义一个包装方法Close(),这样显得更清晰明了(Close()方法内仅调用Dispose()方法)

为了防止忘记调用Dispose()方法,更保险的做法是同时实现两种机制:即实现IDisposable接口的Dispose()方法,也定义析构函数。

7.C#中标准Dispose模式的实现

摘要:C#程序中的Dispose方法,一旦被调用了该方法的对象,虽然还没有垃圾回收,但实际上已经不能再使用了。

先了解一下C#程序(或者说.NET)中的资源分类。简单的说来,C#中的每一个类型都代表一种资源,而资源又分为两类:

  • 托管资源:由CLR管理分配和释放的资源,即由CLR里new出来的对象;
  • 非托管资源:不受CLR管理的对象,windows内核对象,如文件、数据库连接、套接字、COM对象等;

      毫无例外地,如果我们的类型使用到了非托管资源,或者需要显式释放的托管资源,那么,就需要让类型继承接口IDisposable。这相当于是告诉调用者,该类型是需要显式释放资源的,你需要调用我的Dispose方法。

      不过,这一切并不这么简单,一个标准的继承了IDisposable接口的类型应该像下面这样去实现。这种实现我们称之为Dispose模式:
    public class SampleClass : IDisposable
{
//演示创建一个非托管资源
private IntPtr nativeResource = Marshal.AllocHGlobal(100);
//演示创建一个托管资源
private AnotherResource managedResource = new AnotherResource();
private bool disposed = false; /// <summary>
/// 实现IDisposable中的Dispose方法,用于手动调用
/// </summary>
public void Dispose()
{
//必须为true
Dispose(true);
//通知垃圾回收机制不再调用终结器(析构器)因为我们已经自己清理了,没必要继续浪费系统资源
//即:从等待终结的Finalize队列中移除this
GC.SuppressFinalize(this);
} /// <summary>
/// 不是必要的,提供一个Close方法仅仅是为了更符合其他语言(如C++)的规范
/// </summary>
public void Close()
{
Dispose();
} /// <summary>
/// 必须,以备程序员忘记了显式调用Dispose方法
/// </summary>
~SampleClass()
{
//必须为false,跳过托管资源的清理,只手动清理非托管的资源,垃圾回收器会自动清理托管资源
Dispose(false);
} /// <summary>
/// 非密封类修饰用protected virtual
/// 密封类修饰用private
/// </summary>
/// <param name="disposing"></param>
protected virtual void Dispose(bool disposing)
{
if (disposed)
{
return;
}
if (disposing)
{
// 清理托管资源
if (managedResource != null)
{
managedResource.Dispose();
managedResource = null;
}
}
// 清理非托管资源
if (nativeResource != IntPtr.Zero)
{
Marshal.FreeHGlobal(nativeResource);
nativeResource = IntPtr.Zero;
}
//让类型知道自己已经被释放
disposed = true;
}
public void SamplePublicMethod()
{
//确保在执行对象的任何方法之前,该对象可用(未被释放)
if (disposed)
{
throw new ObjectDisposedException("SampleClass", "SampleClass is disposed");
}
//在这里可以使用对象
}
}

  在Dispose模式中,几乎每一行都有特殊的含义。

  在标准的Dispose模式中,我们注意到一个以~开头的方法:

        /// <summary>
/// 必须,以备程序员忘记了显式调用Dispose方法
/// </summary>
~SampleClass()
{
//必须为false
Dispose(false);
}

  这个方法叫做类型的终结器。提供终结器的全部意义在于:我们不能奢望类型的调用者肯定会主动调用Dispose方法,基于终结器会被垃圾回收器调用这个特点,终结器被用做资源释放的补救措施。

  一个类型的Dispose方法应该允许被多次调用而不抛异常。鉴于这个原因,类型内部维护了一个私有的布尔型变量disposed:

private bool disposed = false;

  在实际处理代码清理的方法中,加入了如下的判断语句:

            if (disposed)
{
return;
}
//省略清理部分的代码,并在方法的最后为disposed赋值为true
disposed = true;

  这意味着类型如果被清理过一次,则清理工作将不再进行。

  应该注意到:在标准的Dispose模式中,真正实现IDisposable接口的Dispose方法,并没有实际的清理工作,它实际调用的是下面这个带布尔参数的受保护的虚方法:

        /// <summary>
/// 非密封类修饰用protected virtual
/// 密封类修饰用private
/// </summary>
/// <param name="disposing"></param>
protected virtual void Dispose(bool disposing)
{
//省略代码
}

  之所以提供这样一个受保护的虚方法,是为了考虑到这个类型会被其他类继承的情况。如果类型存在一个子类,子类也许会实现自己的Dispose模式。受保护的虚方法用来提醒子类必须在实现自己的清理方法的时候注意到父类的清理工作,即子类需要在自己的释放方法中调用base.Dispose方法。

  还有,我们应该已经注意到了真正撰写资源释放代码的那个虚方法是带有一个布尔参数的。之所以提供这个参数,是因为我们在资源释放时要区别对待托管资源和非托管资源。

  在供调用者调用的显式释放资源的无参Dispose方法中,调用参数是true:

        public void Dispose()
{
//必须为true
Dispose(true);
//其他省略
}

  这表明,这个时候代码要同时处理托管资源和非托管资源。

  在供垃圾回收器调用的隐式清理资源的终结器中,调用参数是false:

        ~SampleClass()
{
//必须为false
Dispose(false);
}

  这表明,隐式清理时,只要处理非托管资源就可以了。

  那么,为什么要区别对待托管资源和非托管资源。在认真阐述这个问题之前,我们需要首先弄明白:托管资源需要手动清理吗?不妨先将C#中的类型分为两类,一类继承了IDisposable接口,一类则没有继承。前者,我们暂时称之为非普通类型,后者我们称之为普通类型。

  非普通类型因为包含非托管资源,所以它需要继承IDisposable接口,但是,这个包含非托管资源的类型本身,它是一个托管资源。所以说,托管资源需要手动清理吗?这个问题的答案是:托管资源中的普通类型,不需要手动清理,而非普通类型,是需要手动清理的(即调用Dispose方法)。

  Dispose模式设计的思路基于:如果调用者显式调用了Dispose方法,那么类型就该按部就班为自己的所以资源全部释放掉。如果调用者忘记调用Dispose方法,那么类型就假定自己的所有托管资源(哪怕是那些上段中阐述的非普通类型)全部交给垃圾回收器去回收,而不进行手工清理。理解了这一点,我们就理解了为什么Dispose方法中,虚方法传入的参数是true,而终结器中,虚方法传入的参数是false。

8.及时让不再需要的静态字段的引用等于null:

在CLR托管应用程序中,存在一个根的概念,类型的静态字段、方法参数以及局部变量都可以作为根存在(值类型不能作为根,只有引用类型的指针才能作为根)。垃圾回收器会沿着线程栈上行检查根,如果发现该根的引用为空,则标记该根为可被释放。

而JIT编译器是一个经过优化的编译器,无论我们是否为变量赋值为null,该语句都会被忽略掉,在我们将项目设置为Release模式下,该语句将根本不会被编译进运行时内。

但是,在另外一种情况下,却要注意及时为变量赋值为null。那就是类型的静态字段。而且,为类型对象赋值为null,并不意味着同时为该类型的静态字段赋值为null:当执行垃圾回收时,当类型的对象被回收的时候,该类型的静态字段并没有被回收(因为静态字段是属于类的,它日后可能会被该类型的其它实例继续使用)。

实际工作中,一旦我们感觉到自己的静态引用类型参数占用内存空间比较大,并且使用完毕后不再使用,则可以立刻将其赋值为null。这也许并不必要,但这绝对是一个好习惯。