【CSND】Unity游戏开发学习群
加入我们一起学习吧:1022366216
对象生存期
大家应该都熟悉值类型和引用类型,这两种类型的主要区别在于内存分配上,引用类型对象分配在托管堆上,而值类型则位于线程栈上。然而托管堆不可能无限大,分配于托管堆上的对象可能在使用完后就再也不会被访问了,因此,需要一种机制来销毁托管堆上的对象,以释放内存。在.NET中,这一过程被称作垃圾收集(Garbage collection)。
注意:只有引用类型的对象才会分配在托管堆上,因此,只有引用类型的对象才需要进行垃圾收集,值类型是不需要垃圾回收的。
托管堆(Managed Heap): 。托管堆不过是分配给应用程序的一块内存区域,在创建引用类型的对象时,对象将会被创建在托管堆上,并占用此区域的一段空间。
再来看一下几个相关的概念:类(Class)、对象(Object)、引用(Reference)。类是一个静态的模板,它定义了类型所具有的数据和行为。比方说,可以定义一个Phone类,包含了品牌和价格属性,分别为Brand和Price,以及拨打电话的方法MakeCall()。
public class Phone
{
public string Brand{get;set;}
public float Price{get;set;}
public void MakeCall(int number){...}
}
有了类的定义之后,就可以创建类的实例,也称作对象。有两个例子可以形象地描述它们之间的关系:第一个就是建筑图纸和房子,设计师设计出建筑图之后,工程队就可以按照建筑图建设成批的房子;第二个例子就是模具和产品,工厂有了产品的模具以后,就可以依据模具生产出大量的产品。类就相当于建筑图纸和模具,而对象则相当于房子和产品。
注意
在C#中,创建对象使用的是new关键字。要注意的是new操作返回的并不是对象本身,而是对象的一个引用(Reference)。引用通常会赋值给该类型的变量(也可以不赋给变量),变量位于当前的线程栈上,通过这个引用,就可以访问对象的属性或方法,而真正的对象本身,则位于托管堆上。Phone item=new Phone()产生的结果可以用图来描述。
如果使用item=null;语句,将上面的item变量赋值为null,不过是切断了变量和对象之间的引用关系,对象并没有销毁,还停留在托管堆上,如图所示。
并不是将item设为null才会将对象变为无法访问的。如果方法结束后,再没有其他地方引用对象,那么它也会变成无法访问的。例如下面代码,在RunTest()方法中创建的Phone对象会在方法结束后无法访问。
static void RunTest()
{
Phone item = new Phone();
item.MakeCall(10086);
//Anything u want to do
}
垃圾回收机制
在C++中,对象的删除是由程序员负责的,可以使用delete关键字来执行这一操作。如果程序员忘记删除对象,则可能出现内存泄露(memory leak)的问题,包括未释放内存,访问未分配的内存区域,访问不存在的对象等。这些问题经常又很难重现,在调试时需要花费很多的时间去追踪。
在.NET中,为了将开发人员从内存管理的繁琐过程中解脱出来,将更多的精力用在业务逻辑上,CLR提供了自动执行垃圾回收的机制来进行内存管理,开发人员甚至感觉不到这一过程的存在。CLR执行垃圾回收的过程,有几个要点,这也是本章的主要内容:如何判断哪些对象是可以进行回收的,哪些是要保留的?对象在堆上是如何分布的?何时执行垃圾回收?垃圾回收的过程是如何进行的?有哪些优化策略?
判断哪些对象需要进行回收
执行垃圾回收,要解决的第一个问题就是判断哪些对象需要被垃圾回收。
在继续看看刚刚的例子,没有任何变量引用到的Phone对象。如果它继续停留在托管堆上,那么势必要持续地占用内存资源。如果程序中存在大量创建对象的代码,例如创建一个成员很多的数组,占用的内存资源会更多。因此,需要进行垃圾回收的对象就是:在代码中的任何位置也无法访问到的对象。
那么如何判断哪些对象能够被访问,哪些对象无法被访问呢?CLR需要借助于应用程序根(Application Roots)和对象图(Object Graph)。应用程序根保存了对堆上对象的引用,因此,上面RunTest()方法中的item变量,即是应用程序根的一种。如果一个对象没有直接或间接被应用程序根所引用,那么就说明没有任何代码可以访问到它,因此这个对象可以被回收。应用程序根有下面几种:
- 本地变量或者全局变量(C#不支持全局变量,但CIL允许)
- 类型的静态成员或者静态属性
- 传递到方法中的参数变量
- CPU寄存器所引用的对象
应用程序根只是一个入口点,一个对象可能持有其他一个或多个对象的引用,这种对象间的引用关系构成了对象图。每次创建新对象,在复制引用、删除引用,或者执行垃圾回收之后,CLR都会自动更新它。下图显示了这种关系
在上图中 ,共有两个应用程序根,一个是静态的_item,一个是RunTest()方法中的item,它们引用了同一个Phone对象。在RunTest()方法执行结束后,Phone对象还会继续存活,因为静态成员_item仍然保持着对它的引用。托管堆上的5个对象构成了一个对象图。注意对象3不存在应用程序根(对象3持有对象2的引用,但是对象2并不持有对象3的引用),因此对象3没有任何代码可以访问到,因此对象3,以及它所持有的对象4都将在下一次垃圾回收时被清理掉。
对象如何分配在堆上
在非托管环境下,对象被分配在原生的操作系统内存中,操作系统采用链式表来进行管理,每次分配时,都会寻找足够大的位置来存放对象,时间久了以后,内存就会碎片化,链式表中的项目也变得很长,从而影响到应用程序的性能。
.NET中的对象创建在托管堆上,托管堆维护着一个指针,这个指针标识了下一个将要创建的新对象所在的位置,通常称作新对象指针(new object pointer)。该指针总是位于托管堆的末尾,这样就避免了上面遍历链式表的过程。当使用new运算符创建对象时,CLR将会执行下面几个主要操作:
计算此对象所要分配的内存大小。
检查托管堆,确保托管堆有足够的空间来建立这个对象。如果空间够,将会调用构造函数来创建对象,并在托管堆的末尾创建对象,然后返回对象在堆上的引用。引用的地址即为新对象指针所指向的地址。如果空间不够,则会执行一次垃圾回收来释放空间。
修改新对象指针的值,使它指向堆上一个可用的地址。
上面的过程可以画成这样:
垃圾回收的执行过程
先将执行垃圾回收的执行时机再做一个归纳:
1)当通过new关键字创建对象时,如果发现已托管堆所占用的内存已经达到了一个临界点,就会进行垃圾回收;
2)这个过程也可以通过调用System.GC.Collect()方法来强制执行;
3)应用程序退出时也会执行一次垃圾回收。
接下来再来看一下垃圾回收的过程:
假设在托管堆上有A~F六个对象。在垃圾回收时,先将所有的对象标记为垃圾,然后以应用程序根作为入口,遍历对象图。在遍历对象图的过程中,每访问到一个对象,就将它标记为可访问的。
当遍历结束后,标记为垃圾的对象将会从堆上清除,并释放内存。这里分为了两种情况:
如果对象没有定义析构函数(Finalize()),那么它将立即被清理。
如果对象定义了析构函数,这些对象将被移入到一个单独队列中,并由一个专有的析构器线程(finalizer thread)执行它的析构方法。在析构方法执行完毕后,这些对象才会被清理掉。
在对象被清理之后,堆上就会多出一些空闲的不连续空间,此时就需要对托管堆进行压缩。显然,完成压缩以后,对象的内存地址就改变了,因此相应的应用程序根也需要修改,将它们的引用更新到正确的内存地址。最后,新对象指针也会移动,指向堆上的第一个空闲地址。
(灰色的是标记为垃圾的对象)
在垃圾回收的整个过程中,应用程序中的活动线程将会暂停,这样做是为了避免线程在垃圾回收时访问对象,而对象的地址可能已经变更,但是引用还没有进行更新。
垃圾回收的运作机制是将开发者从内存管理中解放出来,因此开发者几乎从来不需要自己去指定何时执行垃圾回收,只有一些特殊的情况例外。比如开发一个更新检查程序,每天自动执行一次,并持续地保持运行状态。那么当一次任务执行完以后,要等待一天才可能执行垃圾回收(因为在此期间不会再执行new操作符),如此就会一直占据内存。此时,在每天执行完任务以后,可以使用System.GC.Collect()方法来强制执行一次垃圾回收。
在垃圾回收的过程中,还采用了一些优化策略,主要是对象代(ObjectGeneration)和大对象堆。
1.对象代
就好像人类家族中有祖父、父亲、儿子这样的代级关系,在垃圾回收的过程中,也为每一个对象定义了代级。定义代级的目的是区分出哪些对象可能更长久地保留在堆上,哪些对象可能很快就变为可回收的了。对于那些会更长久保留在堆上的对象,就不需要每次执行垃圾回收时都检查它。例如,对于Windows应用程序来说,主窗口的存活时间是最久的,那么在垃圾回收时,就不需要每次都检查它;而一些位于方法作用域中的对象,在方法执行完毕后就需要立刻进行回收。对象代的基本逻辑是:如果一个对象在堆上保留的时间越久,它就越有可能继续保留下去。
对象共分为了三个代级:
第0代,新近分配在堆上的对象,从来没有被垃圾收集过。任何一个新对象,当它第一次被分配在托管堆上时,就是第0代。
第1代,经历过一次垃圾回收后,依然保留在堆上的对象。第2代,经历过两次或以上垃圾回收后,依然保留在堆上的对象。当进行垃圾回收时,垃圾回收器将会首先检查所有的第0代对象,并对其中可回收的对象进行清理。如果清理后获取到了足够的内存空间,经历过垃圾回收后的对象将提升为第1代对象。
(存活的0代对象升为1代对象)
如果所有第0代对象都检查过,但是内存空间还不够用,那么将会检查第1代对象的可访问性,并进行垃圾回收。此时,如果经历过垃圾回收的第1代对象仍保留在堆上,则会升级为第2代对象。类似地,如果内存仍不够用,将会对第2代对象进行检查和垃圾回收。如果第2代的部分对象在此次回收后仍保留在堆栈上,它依然是第2代对象,因为总共只定义了三代对象。如果第2代对象在进行完垃圾回收后空间仍然不够用,则会抛出OutOfMemoryException异常。
可见,当对堆上的对象进行代级区分以后,最容易被清理掉的,就是那些新对象,这些对象往往存在于一个很小的作用域内(例如for循环内,方法内),但数量往往又是最庞大的。通过使用对象分级,可以显著地提高垃圾回收的效率。
2.大对象堆
垃圾回收的过程中还有一个很影响性能的地方,就是在压缩的过程中,因为要批量地挪动对象,以填充腾出来的空间,如果对象很大,那么要挪动的数据量就会很大。除此以外,如果将大对象直接分配在第0代,那么第0代的空间很快就会被占满,从而迫使CLR执行一次垃圾回收,这样执行垃圾回收的次数就会变得很频繁。因此,第二个优化策略就是采用大对象堆(LOH,Large Object Heap),当对象的大小超过指定数值(85000字节)时,就会被分配在大对象堆上。大对象堆有几个特点:
- 没有代级的概念,所有对象都被视为第2代。
- 不进行对象移动和空间压缩,因为移动大对象是相对耗时的操作。因此,需要一个链表来维护空闲区域的位置。
- 对象不会被分配在末尾,而会在链表中寻找合适的位置,因此会存在碎片的问题。
对象析构
- Finalizer析构器
对象可能会持有一些资源,例如磁盘文件、TCP连接、通信端口、数据库连接等,当对象被垃圾回收时,只是被简单地覆盖掉,并不会释放这些资源。为了将这些非托管的外部资源释放掉,可以为对象建立析构器(Finializer)。析构器的语法和构造函数有些类似,它不需要加访问修饰符,不能传入参数,名称与类名相同,并且在名称前加“~”符号。例如,Phone类的析构函数为:
public class Phone
{
~Phone()
{
//析构代码...
}
}
对于有析构器的对象,垃圾回收的过程稍有不同。在类型中定义了析构器的对象将会被移动到一个专门的队列中,这个队列将作为它的应用程序根,而使队列中的对象存活得更久一些,在对象上面调用完析构函数后,对象才会从队列中清理掉。使用析构器有几个需要注意的地方:
开发者无法确切得知析构函数何时会调用。
析构函数会延长对象的存活时间。
不要在析构函数中编写阻塞方法或耗时的方法,析构函数应该是迅速释放完资源并结束的。
对于一组对象来说,调用它们的析构函数的时间是不确定的,因此不要编写“A对象先释放资源,然后B对象才能释放”的代码。
如果程序运行期间一直没有进行垃圾回收,那么在应用程序退出时会执行一次垃圾回收,并调用析构函数。
- Dispose()和Finalize()
Finalizer的执行时间是不确定的,有时候,我们期望客户端在对象使用完毕后立即释放资源,此时可以实现IDisposable()接口:
public interface IDisposable
{
void Despose();
}
IDisposable接口只定义了一个方法Dispose(),用于编写释放资源的代码。Dispose()方法的编写和使用有几个约定俗称的规则:
1)Dispose()的多次调用不应该对程序产生任何影响。例如:
item.Dispose();//第一次调用
//中间做了点其他事 忘记已经调用过了
item.Dispose();//第二次调用
item.Dispose();//第三次调用
其中,第二次调用和第三次调用是无关紧要的,不需要再次调用,但是调用了也不会产生影响。
2)如果对象A持有对象B,并且A、B都包含Dispose()方法,那么只要调用对象A的Dispose()方法,对象B的Dispose()方法也会被调用。例如:
FileStream fs = new FileStream("D: \test.txt" , FileMode.Open , FileAcess.Read);
StreamReader reader = new StreamReader(fs);
reader.DisPose();
只需要调用reader的Dispose()方法,它内部持有类型的Dispose()方法也会被调用。
3)如果对象B继承自对象A,并且A、B都包含Dispose()方法,那么调用对象B的Dispose()方法,对象A的Dispose()方法也应被调用。例如:
DBDataReaderdbReader = getDataReader();
SqlDataReader reader = (SqlDataReader) dbReader;
//使用reader进行其他操作
reader.Dispose();//也会调用DbDataReader的Dispose()
4)在调用了Dispose()方法后,对象被视为已使用完毕,再在它上面调用方法都应该抛出异常:
FileStream fs = new FileStream(@"D: \test.txt" , FileMode.Open , FileAcess.Read);
StreamReader reader = new StreamReader(fs);
reader.DisPose();
string line = reader.ReadLine();
5)如果类型支持Dispose()方法,那么在对象使用完毕后,应该总是调用它。如果类型来自第三方,是否实现Dispose()方法不确定(可能显示实现了IDisposable接口),那么可以使用下面的代码:
class Program
{
static void Main(string[] args)
{
Phone item = new Phone();
IDisposable dis = item as IDisposable;
if (dis != null)
{
dis.Dispose();
}
}
}
public class Phone : IDisposable
{
void IDisposable.Dispose()
{
Console.WriteLine("Disposed");
}
}
6)为了避免由于抛出异常而导致Dispose()方法未能执行,它应该总是放在finally块中:
class Program
{
static void Main(string[] args)
{
Phone item = new Phone();
try
{
item.DoSomething();
}
finally
{
IDisposable dis = item as IDisposable;
if (dis != null)
{
dis.Dispose();
}
}
}
}
public class Phone : IDisposable
{
public void DoSomething()
{
throw new Exception();
}
void IDisposable.Dispose()
{
Console.WriteLine("Disposed");
}
}
7)像上面这样编写代码是相当冗长的,因此C#中可以使用using关键字来简化代码的编写,下面的代码和上面的效果是相同的:
static void Main(string[] args)
{
Phone item = new Phone();
using (item as IDisposable)
{
item.DoSomething();
}
}
在一些类型中,不仅有Dispose()方法,还有Close()方法,很多初学者不清楚这两个方法有什么区别。有Close()方法的类型,通常还有一个Open()方法。简单点理解,调用Close()之后,是临时将连接关闭,或者暂时将资源释放,但并不是完全弃用对象,可能在后面代码的某个位置还要再次使用,此时只需要再调用Open()方法就可以了。调用Close()再调用Open()的开销,要比调用Dispose()再使用new操作符重新创建对象的开销小一些。
- 结合析构器函数和Dispose()
析构器函数的主要问题在于:它不是立即被调用,而是在以后某个不确定的时间,执行垃圾回收时被调用。Dispose()方法也有自己的问题,就是客户端不一定会调用它。因此,最好的方式就是将它们结合起来:如果客户端调用了Dispose()方法,那么就不要让CLR去执行析构器函数;如果客户端没有调用Dispose()方法,那么就让CLR在垃圾回收时调用析构器函数。
下面的代码是实现这种模式的一个模板,它还包含了上一小节所描述的一些约定俗成的规则:
public class BaseTemplate : IDisposable
{
private bool _disposed = false;
public void Dispose()
{
if (!_disposed)
{
_disposed = true;
cleanUp();
GC.SuppressFinalize(this);
}
}
protected virtual void cleanUp()
{
//这里编写释放资源的代码
}
~BaseTemplate()
{
cleanUp();
}
public void MethodA()
{
if (_disposed)
{
throw new ObjectDisposedException("对象已经被释放了");
}
}
public void MethodB()
{
if (_disposed)
{
throw new ObjectDisposedException("对象已经被释放了");
}
}
}
public class SubTemplate : BaseTemplate
{
protected override void cleanUp()
{
try
{
}
finally
{
base.cleanUp();//释放父类资源
}
}
}
代码部分相对简单,就不做详细说明了。唯一需要注意的就是GC.SuppressFinalize(this)语句,它会指示垃圾回收器,无视该对象的析构器,将其视为普通对象处理。在客户端调用了Dispose()方法后,显然不需要再执行一遍析构函数了,因为它们调用的是同一个方法cleanUp()。实际的资源释放操作,均在cleanUp方法中进行。