我是靠谱客的博主 唠叨狗,最近开发中收集的这篇文章主要介绍.NET下的内存分配机制,觉得挺不错的,现在分享给大家,希望可以做个参考。

概述

    对于任何对象的产生,都伴随着内存空间的分配,那么本文将初步介绍一下.NET下,是如何进行内存分配的。

  这里部分引用了Anytao《你必须知道的.NET》和happyhippy在《类型实例的创建位置、托管对象在托管堆上的结构》的相关内容。

1.概述

 CLR支持两种基本类型:值类型引用类型。因此,还是把MSDN这张经典视图拿出来做个铺垫。

 

关于具体每种分类都有哪些类型,这里不做介绍。

 

2.内存分配原则

 

  在.NET Framework中,内存中的资源(即所有二进制信息的集合)分为"托管资源"和"非托管资源".托管资源必须接受.NET Framework的CLR(通用语言运行时)的管理(诸如内存类型安全性检查),而非托管资源则不必接受.NET Framework的CLR管理. (了解更多区别请参阅.NET Framework或C#的高级编程资料)
  托管资源在.NET Framework中又分别存放在两种地方: "堆栈"和"托管堆"(以下简称"堆")。本文只讨论托管资源的内存分配。

 那么.NET的托管资源内存分配机制如何呢?

  实际上,数据在内存中的分配位置,取决于该变量的数据类型。

  对于内存分配的更详细位置,可以描述如下:

 

 • 对于值类型的实例,CLR在运行时有两种分配方式:(1) 如果该值类型的实例作为类型中的方法(Method)中的局部变量,则该实例被创建在线程栈上;(2) 如果该值类型的实例作为类型的成员,则该实例作为引用类型(引用类型在GC堆或者LOH上创建)的实例的一部分,被创建在GC堆上。下面这段代码演示了这两种情况:

  • 对于引用类型的实例,CLR在运行时也有两种分配方式:(1) 如果该引用类型的实例的Size<85000Byte,则该实例被创建在GC(Garbage Collection)堆(当CLR在分配和回收对象时,GC可能会对GC堆进行压缩);(2) 如果该引用类型的实例的Size>=85000byte,则该实例被创建在LOH(Large Object Heap)上(LOH不会被压缩)。面这段代码演示了这两种情况:

   这里要注意的是,对于引用对象,他包括了引用和对象实例两部分,实例需要通过对其存储位置的引用来访问,对于private Object o = new Object(),其实可以分解为两句话:

   private Object o;

   o = new Object();

  其中private Object o是定义了对象的引用,也就是记录对象实例的指针,而不是对象本身。这个引用存储于堆栈中,占用4个字节;当没有使用o = new Object()时,引用本身的值为null,也就是不指向任何有效位置;

  当o = new Object()后,才真正根据对象的大小,在托管堆中分配空间给对象实例,然后将实例的指针位置赋值给前面的引用。这才完成一个对象的实例化。

 

    在例如,现在有MyStruct和MyClass分别代表一个结构体和一个类,如下: 

 

  在上述的过程中,我们分别定义了值类型变量myStruct和引用类型变量myClass,并使用new操作符完成内存分配和初始化操作。而我们在此强调的是myStruct和myClass两个变量在内存分配方面的区别,还是以一个简明的图来展示一下:

  我们知道,每个变量或者程序都有其堆栈,不同的变量不能共有同一个堆栈地址,因此myStruct和myStruct2在堆栈中一定占用了不同的堆栈地址,尽管经过了变量的传递,实际的内存还是分配在不同的地址上,如果我们再对myStruct2变量改变时,显然不会影响到myStruct的数据。从图中我们还可以显而易见的看出,myStruct在堆栈中包含其实例数据,而myClass在堆栈中只是保存了其实例数据的引用地址,实际的数据保存在托管堆中。因此,就有可能不同的变量保存了同一地址的数据引用,当数据从一个引用类型变量传递到另一个相同类型的引用类型变量时,传递的是其引用地址而不是实际的数据,因此一个变量的改变会影响另一个变量的值。从上面的分析就可以明白的知道这样一个简单的道理:值类型和引用类型在内存中的分配区别是决定其应用不同的根本原因,由此我们就可以很容易的解释为什么参数传递时,按值传递不会改变形参值,而按址传递会改变行参的值,道理正在于此。 

 更详细的分析,强烈推荐《类型实例的创建位置、托管对象在托管堆上的结构》。

 

3. 嵌套结构

    上面讲解了类型在内存中分配的基本原则,那么这里继续介绍各种类型相互嵌套时候,这些类型是如何分配的。其实还是完全依据上面的基本分配规则的。

 所谓的嵌套结构就是在值类型中嵌套定义了引用类型,或者在引用类型变量中嵌套定义了值类型。

引用类型嵌套值类型 

  值类型如果嵌套在引用类型时,也就是值类型在内联的结构中时,其内存分配是什么样子呢? 其实很简单,例如类的私有字段如果为值类型,那它作为引用类型实例的一部分,也分配在托管堆上。例如:

  

其内存分配图可以表示为:

 

  • 值类型嵌套引用类型
  引用类型嵌套在值类型时,内存的分配情况为:该引用类型将作为值类型的成员变量,堆栈上将保存该成员的引用,而成员的实际数据还是保存在托管堆中。例如:

  其内存分配图可以表示为:

4. 一个简单的讨论

  通过上面的分析,如果我们现在有如下的执行时:

  AType[] myType = new AType[10];

  试问:如果AType是值类型,则分配了多少内存;而如果AType是引用类型时,又分配了多少内存?

  我们的分析如下:根据CRL的内存机制,我们知道如果ATpye为Int32类型,则表示其元素是值类型,而数组本身为引用类型,myType将保存指向托管堆中的一块大小为4×10byte的内存地址,并且将所有的元素赋值为0;而如果AType为自定义的引用类型,则会只做一次内存分配,在线程的堆栈创建了一个指向托管堆的引用,而所有的元素被设置为null值,表示为空。  

 5.分配的内存大小计算  

 对于分配与堆栈中的值类型,有个函数,可以计算这些类型占用的内存大小:

sizeof
sizeof 运算符用于获得值类型的大小(以字节为单位)。
       sizeof(type)
sizeof 运算符仅适用于值类型,而不适用于引用类型。
sizeof 运算符仅可用于 unsafe 模式。
示例:
       unsafe
       {
              Console.WriteLine(“{0}”,sizeof(int));
       }

对于复杂的引用类型,没有可直接利用的方法来计算,目前我找到的关于这方面的文章有:

《如何计算托管对象的大小》

 http://www.cnblogs.com/juqiang/archive/2005/08/17/217141.html

《托管堆上对象的大小(Size)和Layout》

http://www.cnblogs.com/happyhippy/archive/2007/04/17/717028.html

 6.分配具体过程

 上面已经了解了当一个对象产生时,究竟分配到什么地方。这里仔细讲解一下究竟是如何分配的,涉及到哪些细节。在执行到以下语句后:

Matrix m = new Matrix(100, 100);
 其幕后执行是:

(1)计算所需空间大小

  这一步既针对堆栈也针对托管堆。CLR内存管理器收到分配请求,它会计算存储该对象包括头部和类变量所需的内存数量。然后内存管理器检查堆中可用空闲空间,以确认是否有足够空间供这次分配。计算方法如上节的介绍。如果有,对象所需空间会被成功分配并且对其存储地址的引用也会被返回。如果没有足够空间存储对象,垃圾收集器将被启动去释放一些空间并进行堆紧缩操作,这涉及到垃圾回收,这个话题请参考另外一篇文章《.NET垃圾回收机制 》。
(2)写屏障
  这一步只针对托管堆,因为托管堆涉及到后面的垃圾回收,这一步是必须的。为了保持后续的垃圾收集操作,内存管理器将对象写入内存前还必须采取另一个重要步骤。这一步骤涉及产生一块称作写屏障(write barrier)的代码(垃圾收集器的实现细节超出本文范围,写屏障是高级的垃圾收集算法所必需的)。相反地,每当有对象被写入内存或者当对象在内存中产生对另一个对象的引用(例如原先存在对象指向新创建对象),运行时(CLR)便生成写屏障。垃圾收集器功能实现的许多复杂性之一是要记住这些对象的存在,因而在收集过程中它们不会被误收集,虽然它们是被毫不相关的另一个对象所指向的对象。正如你可能会猜测,这些写屏障招致小的运行时开销,所以对于科学计算应用来说,在运行过程中创建数百万对象不是理想场景。

(3)开始实际分配

   这一步当然也既针对堆栈也针对托管堆,分开来说。

1)堆栈分配

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

  假设线程栈的初始化地址为50000,因此堆栈指针首先指向50000地址空间。代码由入口函数Main开始执行,首先进入作用域的是整型局部变量x,它将在栈上分配4Byte(整型需要四个字节)的内存空间,因此堆栈指针向下移动4个字节,则值100将保存在49997~50000单位,而堆栈指针移动到下一个自由空间地址为49996,如图所示:


  接着进入下一行代码,将为字符型变量c分配2Byte的内存空间,堆栈指针向下移动2个字节至49994单位,值’A’会保存在49995~49996单位,地址的分配如图:


  最后,执行到Main方法的右括号,方法体执行结束,变量x和c的作用域也随之结束,CLR需要立刻删除变量x和c在堆栈内存中的值,其释放过程和分配过程刚好相反:首先删除c的内存,堆栈指针向上递增2个字节,然后删除x的内存,堆栈指针继续向上递增4个字节,程序执行结束,此时的内存状况为:


  看到了吧,堆栈中的空间,只要出了作用域,立刻就被释放了,也就意味着栈上的内存分配,效率较高,但是内存容量不大,同时变量的生存周期随着方法的结束而消亡。因此,堆栈无需涉及到复杂的垃圾回收机制,我博客中另一篇文章《.NET垃圾回收机制 》自然就只针对托管堆中的对象的回收喽。

  多说几句,前面说了,引用对象的引用部分(存储实例的指针),也是保存在堆栈中的,因此也不例外,只要出了对象的作用域,那么引用部分占用的空间就被立刻释放了,而指向的托管堆中的实际数据(实例部分),是不会立刻释放的,要交给CLR在适当时候进行释放,否则由于引用对象之间相互引用关系那么复杂,且一般都比较大,每次都立刻释放是不现实而消耗大量系统资源的。

  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位于托管堆的基地址。

  因此,我们对引用类型分配过程应该有个基本的了解,在此本文实现一个相对简单的类型来做说明:


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

  另外,实例字段的存储是有顺序的,由上到下依次排列,父类在前子类在后,详细的分析请参见anytao在《你必须知道的.NET》之继承本质论中的论述。   

  在上述操作时,如果试图分配所需空间而发现内存不足时,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。

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

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

7.其它相关问题

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

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

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

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

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

8.尚未解决的问题

 (1)对于写屏障到底做什么,还是不太清楚。

最后

以上就是唠叨狗为你收集整理的.NET下的内存分配机制的全部内容,希望文章能够帮你解决.NET下的内存分配机制所遇到的程序开发问题。

如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(54)

评论列表共有 0 条评论

立即
投稿
返回
顶部