zoukankan      html  css  js  c++  java
  • [你必须知道的.NET]第十九回:对象创建始末(下)

    本文将介绍以下内容:

    • 对象的创建过程
    • 内存分配分析
    • 内存布局研究

     

    接上回[第十八回:对象创建始末(上)],继续对对象创建话题的讨论>>>

    2.2 托管堆的内存分配机制

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

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

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

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

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

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

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

    //@ 2007 Anytao.com 
    //http://www.anytao.com
        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指令,其执行过程又可细分为以下几步:

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

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

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

    另外,实例字段的存储是有顺序的,由上到下依次排列,父类在前子类在后,详细的分析请参见[第十五回:继承本质论]

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

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

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

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

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

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

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

    2.3 必要的补充

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

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

        这一思考其实是一个问题的两个方面:对于值类型嵌套引用类型的情况,引用类型变量作为值类型的成员变量,在堆栈上保存该成员的引用,而实际的引用类型仍然保存在GC堆上;对于引用类型嵌套值类型的情况,则该值类型字段将作为引用类型实例的一部分保存在GC堆上。在[ 第八回:品味类型---值类型与引用类型(上)-内存有理]一文对这种嵌套结构,有较详细的分析。对于值类型,你只要记着它总是分配在声明它的地方。

    ·       方法保存在Loader HeapMethodTable中,那么方法调用时又是怎么样的过程?

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

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

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

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

    3. 结论

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

    对象总是有生有灭,本文简述其生,这是个伟大的开始。 

    [祝福] 一个值得纪念的日子,一切快乐、平安、健康,这次专注,2008会更好。

    参考文献

    (USA)Joe Duffy, Professinal .NET Framework 2.0 
    (USA)Don Box, Essiential .NET 
    (MSDN)Hanu Kommalapati and Tom Christian, Drill Into .NET Framework Internals to See How the CLR Creates Runtime Objects, http://msdn.microsoft.com/msdnmag/issues/05/05/JITCompiler/default.aspx

  • 相关阅读:
    iOS 苹果开发证书失效的解决方案(Failed to locate or generate matching signing assets)
    iOS NSArray数组过滤
    App Store2016年最新审核规则
    iOS 根据字符串数目,自定义Label等控件的高度
    iOS 证书Bug The identity used to sign the executable is no longer valid 解决方案
    Entity FrameWork 增删查改的本质
    EF容器---代理类对象
    Entity FrameWork 延迟加载本质(二)
    Entity FrameWork 延迟加载的本质(一)
    Entity FrameWork 增删查改
  • 原文地址:https://www.cnblogs.com/ShaYeBlog/p/3200959.html
Copyright © 2011-2022 走看看