首页 新闻 论坛 群组 Blog 文档 下载 读书 Tag 网摘 搜索 开源 FAQ 第二书店 博文视点 程序员
频道: 研发 数据库 中间件 信息化 视频 .NET Java 游戏 移动 服务: 人才 外包 培训
    图书品种:235680
       
热门搜索: ASP.NET Ajax Spring Hibernate Java

5.2  对象创建始末

本节将介绍以下内容:

— 对象的创建过程

— 内存分配分析

— 内存布局研究

5.2.1  引言

了解.NET的内存管理机制,首先应该从内存分配开始,也就是对象的创建环节。对象的创建,是个复杂的过程,主要包括内存分配和初始化两个环节。在本章开篇的示例中,对象的创建过程为:

FileStream fs = new FileStream(@"C:\temp.txt", FileMode.Create);

通过new关键字操作,即完成了对FileStream类型对象的创建过程,这一看似简单的操作背后,却经历着相当复杂的过程和波折。

本篇全文,正是对这一操作背后过程的详细讨论,从中了解.NET的内存分配是如何实现的。

5.2.2  内存分配

关于内存的分配,首先应该了解分配在哪里的问题。CLR管理内存的区域,主要有三块,分别为:

l   线程的堆栈,用于分配值类型实例。堆栈主要由操作系统管理,而不受垃圾收集器的控制,当值类型实例所在方法结束时,其存储单位自动释放。栈的执行效率高,但存储容量有限。

l  GC堆,用于分配小对象实例。如果引用类型对象的实例大小小于85000字节,实例将被分配在GC堆上,当有内存分配或者回收时,垃圾收集器可能会对GC堆进行压缩,详见后文讲述。

l  LOH(Large Object Heap)堆,用于分配大对象实例。如果引用类型对象的实例大小不小于85000字节时,该实例将被分配到LOH堆上,而LOH堆不会被压缩,而且只在完全GC回收时被回收。这种设计方案是对垃圾回收性能的优化考虑。

本节讨论的重点是.NET的内存分配机制,因此下文将不加说明的以GC堆上的分配为例来展开。关于值类型和引用类型的论述,请参见本书4.2节“品味类型——值类型与引用类型”。

了解了内存分配的区域,接着我们看看有哪些操作将导致对象创建和内存分配的发生,在本书3.4节“经典指令解析之实例创建”一节中,详细描述了关于实例创建的多个IL指令解析,主要包括:

l  newobj,用于创建引用类型对象。

l  ldstr,用于创建string类型对象。

l  newarr,用于分配新的数组对象。

l  box,在值类型转换为引用类型对象时,将值类型字段拷贝到托管堆上发生的内存分配。

在上述论述的基础上,我们将从堆栈的内存分配和托管堆的内存分配两个方面来分别论述.NET的内存分配机制。

1.堆栈的内存分配机制

对于值类型来说,一般创建在线程的堆栈上。但并非所有的值类型都创建在线程的堆栈上,例如作为类的字段时,值类型作为实例成员的一部分也被创建在托管堆上;装箱发生时,值类型字段也会拷贝在托管堆上。

对于分配在堆栈上的局部变量来说,操作系统维护着一个堆栈指针来指向下一个自由空间的地址,并且堆栈的内存地址是由高位到低位向下填充,也就表示入栈时栈顶向低地址扩展,出栈时,栈顶向高地址回退。以下例而言:

public void MyCall()

{

    int x = 100;

    char c = 'A';

}

当程序执行至MyCall方法时,假设此时线程栈的初始地址为50000,因此堆栈指针开始指向50000地址空间。方法调用时,首先入栈的是返回地址,也就是方法执行之后的下一条可执行语句的地址,用于方法返回之后程序继续执行,如图5-1所示。

图5-1  栈上的内存分配

然后是整型局部变量x,它将在栈上分配4Byte的内存空间,因此堆栈指针继续向下移动4个字节,并将值100保存在相应的地址空间,同时堆栈指针指向下一个自由空间,如图5-2所示。

图5-2  栈上的内存分配

接着是字符型变量c,在堆栈上分配2Byte的内存空间,因此堆栈指针向下移动2个字节,值‘A’会保存在新分配的栈上空间,内存的分配如图5-3所示。

图5-3  栈上的内存分配

最后,MyCall方法开始执行,直到方法体执行结束,执行结果被返回,栈上的存储单元也被自行释放。其释放过程和分配过程刚好相反:首先删除c的内存,堆栈指针向上递增2个字节,然后删除x的内存,堆栈指针继续向上递增4个字节,最终的内存状况如图5-4所示,程序又将回到栈上最初的方法调用地址,继续向下执行。

图5-4  栈上的内存分配

其实,实际的分配情况是个非常复杂的分配过程,同时还包括方法参数,堆引用等多种情形的发生,但是本例演示的简单过程基本阐释了栈上分配的操作方式和过程。通过内置于处理器的特殊指令,栈上的内存分配,效率较高,但是内存容量不大,同时栈上变量的生存周期由系统自行管理。

 注意                                                                    

上述执行过程,只是一个简单的模拟情况,实际上在方法调用时都会在栈中创建一个活动记录(包含参数、返回值地址和局部变量),并分配相应的内存空间,这种分配是一次性完成的。方法执行结束返回时,活动记录清空,内存被一次性解除。而数据的压栈和出栈是有顺序的,栈内是先进先出(FILO)的形式。具体而言:首先入栈的是返回地址;然后是参数,一般以由右向左的顺序入栈;最后是局部变量,依次入栈。方法执行之后,出栈的顺序正好相反,首先是局部变量,再是参数,最后是那个地址指针。

2.托管堆的内存分配机制

引用类型的实例分配于托管堆上,而线程栈却是对象生命周期开始的地方。对32位处理器来说,应用程序完成进程初始化后,CLR将在进程的可用地址空间上分配一块保留的地址空间,它是进程(每个进程可使用4GB)中可用地址空间上的一块内存区域,但并不对应于任何物理内存,这块地址空间即是托管堆。

托管堆又根据存储信息的不同划分为多个区域,其中最重要的是垃圾回收堆(GC Heap)和加载堆(Loader Heap),GC Heap用于存储对象实例,受GC管理;Loader Heap用于存储类型系统,又分为High-Frequency Heap、Low-Frequency Heap和Stub Heap,不同的堆上存储不同的信息。Loader Heap最重要的信息就是元数据相关的信息,也就是Type对象,每个Type在Loader Heap上体现为一个Method Table(方法表),而Method Table中则记录了存储的元数据信息,例如基类型、静态字段、实现的接口、所有的方法等等。Loader Heap不受GC控制,其生命周期为从创建到AppDomain卸载。

在进入实际的内存分配分析之前,有必要对几个基本概念做个交代,以便更好地在接下来的分析中展开讨论。

TypeHandle,类型句柄,指向对应实例的方法表,每个对象创建时都包含该附加成员,并且占用4个字节的内存空间。我们知道,每个类型都对应于一个方法表,方法表创建于编译时,主要包含了类型的特征信息、实现的接口数目、方法表的slot数目等。

SyncBlockIndex,用于线程同步,每个对象创建时也包含该附加成员,它指向一块被称为Synchronization Block的内存块,用于管理对象同步,同样占用4个字节的内存空间。

NextObjPtr,由托管堆维护的一个指针,用于标识下一个新建对象分配时在托管堆中所处的位置。CLR初始化时,NextObjPtr位于托管堆的基地址。

因此,我们对引用类型分配过程应该有个基本的了解,由于本篇示例中FileStream类型的继承关系相对复杂,在此本节实现一个相对简单的类型来做说明:

public class UserInfo

{

    private Int32 age = -1;

    private char level = 'A';

}

public class User

{

    private Int32 id;

    private UserInfo user;

}

public class VIPUser : User

{

    public bool isVip;

    public bool IsVipUser()

    {

        return isVip;

    }

    public static void Main()

    {

        VIPUser aUser;

        aUser = new VIPUser();

        aUser.isVip = true;

        Console.WriteLine(aUser.IsVipUser());

    }

}

将上述实例的执行过程,反编译为IL语言可知:new关键字被编译为newobj指令来完成对象创建工作,进而调用类型的构造器来完成其初始化操作,在此我们详细的描述其执行的具体过程。

首先,将声明一个引用类型变量aUser:

VIPUser aUser;

它仅是一个引用(指针),保存在线程的堆栈上,占用4Byte的内存空间,将用于保存VIPUser对象的有效地址,其执行过程正是上文描述的在线程栈上的分配过程。此时aUser未指向任何有效的实例,因此被自行初始化为null,试图对aUser的任何操作将抛出NullReferenceException异常。

接着,通过new操作执行对象创建:

aUser = new VIPUser();

如上文所言,该操作对应于执行newobj指令,其执行过程又可细分为以下几步:

(a)CLR按照其继承层次进行搜索,计算类型及其所有父类的字段,该搜索将一直递归到System.Object类型,并返回字节总数,以本例而言类型VIPUser需要的字节总数为15Byte,具体计算为:VIPUser类型本身字段isVip(bool型)为1Byte;父类User类型的字段id(Int32型)为4Byte,字段user保存了指向UserInfo型的引用,因此占4Byte,而同时还要为UserInfo分配6Byte字节的内存。

(b)实例对象所占的字节总数还要加上对象附加成员所需的字节总数,其中附加成员包括TypeHandle和SyncBlockIndex,共计8字节(在32位CPU平台下)。因此,需要在托管堆上分配的字节总数为23字节,而堆上的内存块总是按照4Byte的倍数进行分配,因此本例中将分配24字节的地址空间。

(c)CLR在当前AppDomain对应的托管堆上搜索,找到一个未使用的24字节的连续空间,并为其分配该内存地址。事实上,GC使用了非常高效的算法来满足该请求,NextObjPtr指针只需要向前推进24个字节,并清零原NextObjPtr指针和当前NextObjPtr指针之间的字节,然后返回原NextObjPtr指针地址即可,该地址正是新创建对象的托管堆地址,也就是aUser引用指向的实例地址。而此时的NextObjPtr仍指向下一个新建对象的位置。注意,栈的分配是向低地址扩展,而堆的分配是向高地址扩展。

另外,实例字段的存储是有顺序的,由上到下依次排列,父类在前子类在后,详细的分析请参见1.2节“什么是继承”。

在上述操作时,如果试图分配所需空间而发现内存不足时,GC将启动垃圾收集操作来回收垃圾对象所占的内存,我们将在下一节对此做详细的分析。

最后,调用对象构造器,进行对象初始化操作,完成创建过程。该构造过程,又可细分为以下几个环节:

(a)构造VIPUser类型的Type对象,主要包括静态字段、方法描述、实现的接口等,并将其分配在上文提到托管堆的Loader Heap上。

(b)初始化aUser的两个附加成员:TypeHandle和SyncBlockIndex。将TypeHandle指针指向Loader Heap上的MethodTable,CLR将根据TypeHandle来定位具体的Type;将SyncBlockIndex指针指向Synchronization Block的内存块,用于在多线程环境下对实例对象的同步操作。

(c)调用VIPUser的构造器,进行实例字段的初始化。实例初始化时,会首先向上递归执行父类初始化,直到完成System.Object类型的初始化,然后再返回执行子类的初始化,直到执行VIPUser类为止。以本例而言,初始化过程首先执行System.Object类,再执行User类,最后才是VIPUser类。最终,newobj分配的托管堆的内存地址,被传递给VIPUser的this参数,并将其引用传给栈上声明的aUser。

关于构造函数的执行顺序,本书在7.8节“动静之间:静态和非静态”一节有较为详细的论述。

上述过程,基本完成了一个引用类型创建、内存分配和初始化的整个流程,然而该过程只能看作是一个简化的描述,实际的执行过程更加复杂,涉及一系列细化的过程和操作。对象创建并初始化之后,内存的布局,可以表示为图5-5。

图5-5  堆上的内存分配

由上面的分析可知,在托管堆中增加新的实例对象,只是将NextObjPtr指针增加一定的数值,再次新增的对象将分配在当前NextObjPtr指向的内存空间,因此在托管堆栈中,连续分配的对象在内存中一定是连续的,这种分配机制非常高效。

3.必要的补充

有了对象创建的基本流程概念,下面的几个问题时常引起大家的思考,在此本节一并做以探索:

l  值类型中的引用类型字段和引用类型中的值类型字段,其分配情况又是如何?

这一思考其实是一个问题的两个方面:对于值类型嵌套引用类型的情况,引用类型变量作为值类型的成员变量,在堆栈上保存该成员的引用,而实际的引用类型仍然保存在GC堆上;对于引用类型嵌套值类型的情况,则该值类型字段将作为引用类型实例的一部分保存在GC堆上。本书在4.2节“品味类型——值类型与引用类型”一节对这种嵌套结构,有较详细的分析。

l  方法保存在Loader Heap的MethodTable中,那么方法调用时又是怎样的过程呢?

如上所言,MethodTable中包含了类型的元数据信息,类在加载时会在Loader Heap上创建这些信息,一个类型在内存中对应一份MethodTable,其中包含了所有的方法、静态字段和实现的接口信息等。对象实例的TypeHandle在实例创建时,将指向MethodTable开始位置的偏移处(默认偏移12Byte)。通过对象实例调用某个方法时,CLR根据TypeHandle可以找到对应的MethodTable,进而可以定位到具体的方法,再通过JIT Compiler将IL指令编译为本地CPU指令,该指令将保存在一个动态内存中,然后在该内存地址上执行该方法,同时该CPU指令被保存起来用于下一次的执行。

在MethodTable中,包含一个Method Slot Table,称为方法槽表,该表是一个基于方法实现的线性链表,并按照以下顺序排列:继承的虚方法、引入的虚方法、实例方法和静态方法。方法表在创建时,将按照继承层次向上搜索父类,直到System.Object类型,如果子类覆写了父类方法,则将会以子类方法覆盖父类虚方法。关于方法表的创建过程,可以参考2.2节“什么是继承”中的描述。

l  静态字段的内存分配和释放,又有何不同?

静态字段也保存在方法表中,位于方法表的槽数组后,其生命周期为从创建到AppDomain卸载。因此一个类型无论创建多少个对象,其静态字段在内存中也只有一份。静态字段只能由静态构造函数进行初始化,静态构造函数确保在任何对象创建前,或者在任何静态字段或方法被引用前执行,其详细的执行顺序在7.8节“动静之间:静态和非静态”有所讨论。

5.2.3  结论

对象创建过程的了解,是从底层接触CLR运行机制的入口,也是认识.NET自动内存管理的关键。通过本节的详细论述,关于对象的创建、内存分配、初始化过程和方法调用等技术都会建立一个相对全面的理解,同时也清楚地把握了线程栈和托管堆的执行机制。

对象总是有生有灭,本节简述其生,下一节讨论其亡。继续本章对自动内存管理技术的认识,下一个重要的内容就是:垃圾回收机制。

查看所有评论(0)条】

最近评论



正在载入评论列表...
热点评论