.NET中的类型分为值类型和引用类型,他们在内存布局,分配,相等性,赋值,存储以及一些其他的特性上有很多不同,这些不同将会直接影响到我们应用程序的效率。本文视图对.NET 基础类型中的值类型和引用类型在内存中的布局,方法的调用,值类型如何实现接口,以及其他一些细节问题进行一些简要的讨论,文章主要参考《Pro .NET Performance》 和 《Advanced .NET Debugging》 ,希望给大家一点儿帮助。

一 简单例子


    举一个简单的例子,我们有一个名为Point2D的对象,用来表示二维空间中的坐标,每一个坐标值x,y都用一个short类型表示,整个对象占4个字节。现在假设我们需要在内存中存储1000万个这样的坐标点集合对象。那么他们会占用多大内存呢?这个问题的答案其实在很大程度上依赖Point2D是值类型还是引用类型。如果他是引用类型,1000万个这样的点的集合实际上是保存的对这1000万个点的引用。在一个32位的系统上,光存储对这十万个点的引用就要占用将近40MB的内存。对象本身也要占用同样大小的内存。实际上,如果我们较真的话,每一个Point2D实例对象要占用12个字节(同步块索引,对象类型指针,实体),使得要存储10万个这样的对象需要将近160MB的内存。但是,如果Point2D是一个值类型,1000万个这样的点的集合就存储的是1000万个对象的实例,没有浪费一个字节的内存空间,总共只需要占用40MB的内存。这比使用引用类型将近少了四分之一,内存的密度在某些情况下使得我们偏爱使用值类型。

1

 

    存储实际的点数据值比存储引用还有一个好处,那就是如果你想遍历一个存储了很多该类型的对象,编译器和硬件能够很容易的遍历值类型对象,因为他们在内存中是连续分配的,而引用类型则不同,在托管堆(heap)上的对象不一定在内存中是连续分配的。对于值类型集合对象的话,CPU的缓存机制可以对连续对象进行更快的读取。

    所以理解值类型和引用类型在内存中的布局,以及他们的区别对于应用程序的性能至关重要。下面首先在语言特性的层面上看看值类型和引用类型的区别,然后我们再看看值类型和引用类型的内部细节。

二 值类型和引用类型在语义上的区别


    .NET 中,引用类型包括:类、接口、委托、以及集合类型。String类型在.Net中是一个很特殊的类型,他也是引用类型。值类型包括结构、枚举、以及一些基本类型,如int,float,decimal,我们可以使用struct结构来定义我们自己的值类型。

    在语言层面上,引用类型具有引用语义,就是我们首先考虑的是对象的唯一标识,而不是其包含的内容,而值类型则具有值语义,对象没有唯一标识,不通过引用访问对象,我们对对象的处理是通过其包含的内容实现的,这些不同影响体现在.NET语言的不同方面。

区别地方

引用类型

值类型

传参

传递引用,方法内对该对象的更改会更改所以的其他对象

对象的内容被拷贝为一个副本传递到方法内部,除非是有(ref或者out关键字),对该参数的更改不会影响到方法体外的改对象

复制

拷贝引用,两个对象保存了对同一个对象的引用

拷贝对象内容,两个对象具有相同的内容,两者之间没有关系

比较

对引用进行比较,如果引用相等,那么这两个对象相等

按存储的内容进行比较,如果两个对象的所有字段都相等,那么这两个对象相等

    这些语义上的不同直接影像到.NET中我们写代码的方式。但是这些不同仅仅是值类型和引用类型传达不同用途的表现。下面首先来看看他们在内存中的布局,分配和销毁。

三 值类型和引用类型的存储,分配和销毁


    引用类型在托管堆(manage heap)上分配,托管堆也是.NET垃圾回收器的工作区域。在托管堆上分配一个对象涉及到递增指针,这个操作很容易。在多处理器的操作系统上,如果多个处理器同时访问托管堆上的同一个对象,那么就需要一些同步机制,但即使这样,相对于在非托管的环境下比如使用malloc,在托管堆上分配一块内存还是非常廉价的。

    垃圾回收器以一种非确定性的方式进行垃圾回收,一次完整的垃圾回收代价非常高。但是垃圾回收的平均花费和同样的非托管环境下的内存管理相比,耗费还是很小的。

    准确来讲,有些引用类型也可以在线程堆栈(stack)上分配的。一些基础类型的集合类型,比如Int集合可以在unsafe环境下使用stackalloc关键字在线程堆栈上分配,或者使用一个自定义的struct类型,在里面使用fixed关键字嵌入一个固定长度的集合。使用stackalloc和fixed关键字创建的集合类型并不是真正意义上的数组类型。他和在托管堆上分配的标准的集合类型在内存布局上是有差别的。

    单纯的值类型通常在当前执行线程的线程堆栈(stack)上分配的。但是值类型通常可以嵌入到引用类型中,在这种情况下,值类型在托管堆上分配,他能够进行装箱,将其存储的值转移到托管堆上。在线程堆栈上分配一个值类型也是一个非常容易的操作,只需要修改一下栈指针寄存器(x86 ESP),并且在同时一次性分配多个对象时也有很大优势。实际上,方法的”开场白”代码一般会使用一条CPU指令来为方法的所有的局部变量在栈上分配存储空间。

    回收栈上的内存空间也非常高效,只需要修改一下栈指针的寄存器即可。由于方法编译为机器码的方式不同,通常编译器不需要最终统计方法中本地变量所占的大小,而是直接删除整个栈帧,这是通过标准的一系列三个指令来完成的,通常称之为 “收场白”代码。

    在C#或者其他托管类型语言中,new关键字不仅用在在托管堆上创建对象。也可以使用new关键字在栈上为值类型分配空间。比如下面DateTime newYear=new DateTime(2011,12,31)。就是使用new关键字为值类型在栈上分配空间。

托管堆和线程堆栈的区别

    和一般大家认为的不同,.NET线程中的线程堆栈和托管堆并没有太大的区别。栈和托管堆都是在虚拟内存中的一系列地址空间而已。某一个线程上的堆栈的地址空间并不一定比托管堆上的更有优势。访问托管堆上的内存地址也不一定比访问栈上的地址空间慢或者快。在一些特定情况下,考虑到下面几种情况,访问栈上的地址空间总体来说比访问托管堆上的地址空间要快。

  • 在堆栈上,地址具有时间局部性,也就是说,同一次分配的对象在地址空间上很可能是连续分布的,也就是说有空间的局部性。同样,时间分配的局部性意味着时间访问也具有局部性,就是说同一时间分配的对象可能同一时间会被访问到。连续的堆栈存储能够充分利用CPU缓存和操作系统的分页系统从而具有更好的性能。
  • 因为引用类型具有额外的一些存储比如类型对象指针,同步块索引等,所以值类型在堆栈上的内存分配密度可能会比托管堆上的要大。更高的内存分配密度意味着更高的性能,比如更多的对象能够适应CPU缓存的大小。
  • 线程堆栈可能相当小,Windows上的默认最大线程堆栈的大小为1MB,大多数的线程通常只用了一点线程堆栈。在现代操作系统上,应用程序线程的堆栈能够适应CPU缓存的大小,使得对堆栈上对象的访问非常快。相反托管堆上的对象通常很少能够适应CPU缓存的大小。

但是并不意味这我们应该将所有的对象分配都放到线程堆栈上。Windows上的线程堆栈是有限制的,通常一些不正确的递归或者比较大的堆栈分配操作就会耗尽线程堆栈空间。

    在简单的讨论了值类型和引用类型之后,我们再来看看他们的实现细节,这些细节也解释了值类型和引用类型在内存中的分配密度的极大不同。

 

四 引用类型的内部实现


    我们先从引用类型开始,引用类型的类存布局比较复杂,其布局在很大程度上会影响运行时效率。为了方便讨论,先建一个简单的Employee引用类型,他有几个字段,以及一些方法。

public class Employee
{
    private int id;
    private string name;
    private static CompanyPolicy policy;

    public virtual void Work()
    {
        Console.WriteLine("Zzzz...");
    }
    public void TakeVacation(int days)
    {
        if (policy.CanTakeVacation(this))
            Console.WriteLine("Zzzz...");
    }
    public static void SetCompanyPolicy(CompanyPolicy newPolicy)
    {
        policy = newPolicy;
    }
}

    现在,我们在托管堆上创建了一个该对象的实例。下面描述了在32位.NET进程中该实例对象的布局。

    2

    该对象的两个字段_id和_name在内存中布局的前后顺序是不确定的(虽然这个可以使用StructLayout属性来进行控制)。对象在内存中的存储的开始的4个字节的称之为对象头字节(object header word)也叫同步块索引 (sync block index),紧接着是另外的称之为方法表指针(method table pointer也叫类型对象指针)的四个字节。这些字段我们在.NET中是不可以直接访问的,它是为JIT和CLR本身服务的。对象的引用,在对象内部其实就是一个内存地址,该地址指向的是方法表指针的开始处,因此对象头字节是从对象地址向前偏移了四个字节处开始的。

    在32位机器上,托管堆上的对象在内存中是对齐到最近的4个字节的. 这就意味着一个对象中即使只有一个字节的byte类型的字段,由于内存对齐,仍然在托管堆上会占用12个字节,即使该类没有任何实例字段,在实例化时仍然会占用12个字节。但是在64为的系统上,情况则有所不同。首先,对象的方法表指针字段在内存中会占用8个字节,对象头字节会占用8个字节。对象在托管堆上会对齐到最近的8个字节。

方法表

    方法表指针指向CLR内部的一个名为方法表(MT)的数据结构,该指针最终指向另外一个称之为EEClass(Execution Engine执行引擎)的内部结构。方法表和EEClass包含了为调用虚方法,接口方法,访问静态变量,确定运行时对象的类型以及有效访问基类中对应方法,以及其他目的提供了一些有用的信息。方法表包含最频繁访问的一些信息,这些信息在一些关键的机制中如虚方法的调用中至关重要。而EEClass则包含一些较少访问的信息,但是在一些运行机制如反射中会用到。这些数据结构的内容我们可以使用SOS命令行中的!DumpMT以及DumpClass获取。需要注意的是,我们下面讨论的可能在不同的CLR版本中有所不同。

    对象的静态字段的位置信息是包含在EEClass中的。一些基础类型(primitive field)字段动态的在线程的启动堆上存储和分配,而用户自定义的值类型以及引用类型通过间接引用堆上的位置(通过AppDomain全局对象数组)来存储。要访问静态字段,我们不需要访问方法表或者EEClass,JIT编译器会将这些静态字段的地址硬编码到产生的机器码中。对静态字段的数组引用的地址也是固定下来的,因此,他们的地址在垃圾回收的整个过程中不会发生变化。这些基础静态字段驻留在方法表中的,垃圾回收器接触不到。这就保证了,可以通过硬编码的地址直接访问这些字段。

    方法表中,最明显的就是他包含一个地址数组,每一个地址对应一个类型方法,包括任何一些从基类继承的虚方法、例如,下面展示了Employee类方法表中可能的布局,假定这些方法是直接继承自System.Object对象。

    3

    和C++中虚函数指针表不同,CLR的方法表包含包括非虚方法的所有方法的代码地址。方法表中方法的顺序并没有规定。一般滴,排列顺序依次是,继承的虚方法(包括重写的虚方法),新引入的虚方法,非虚实例方法,以及静态方法。

     一个真正的方法表包含了包括前面讨论到的更多的信息。理解这些额外的字段对于理解方法调用的细节直观很重要。这就是为什么我们花了很长时间来查看Employee实例的方法表结构。这里我们假定Employee类实现了三个接口:IComparable,IDisposable和ICloneable接口。

下图中,在我们前面理解的方法表布局中又多了一些其他的内容。首先,在方法表的头部包含了一些杂项(Miscellanence),比如虚方法的个数,类型实现的接口的个数等等,其次,方法表包含一个指向其父类型方法表的指针,一个指向其模块的指针以及一个指向其EEClass的指针(他包含了一个对方法表的后向引用),再次,类型自身的方法位于一系列类型实现的接口方法表之前。这就是为什么在方法表中有一个指向方法列表的指针,该指针针位于方法表开始位置的偏移40个字节的地方。

    4

   获取类型方法的在方法表中的地址可能还需要其他一些额外的步骤,因为类型的方法表和对象的方法表可能分开存储在不同的内存地址中。比如,如果你查看System.Object的方法表,你会发现,方法的代码地址是存储在另外一个地方的。更进一步,有许多虚方法的类将会有很多第一级别的表指针,使得在派生类中可以复用一部分方法表。

调用引用类型实例对象的方法

    很明显,方法表可以用来调用实例对象的方法。假设在内存栈(stack)的EBP-64位置包含Employee对象的地址,该对象的方法表布局和前面的图类似。可以使用下面的指令序列来调用名为Word的虚方法。

mov ecx, dword ptr [ebp-64]
mov eax, dword ptr [ecx] ; 方法表指针
mov eax, dword ptr [eax+40] ; 方法表中,该方法的实际位置
call dword ptr [eax+16] ; 

    第一条指令将栈上的引用拷贝到ECX寄存器中,第二条指令使用eax来保存对象的方法表指针。第三条指令获取方法表起始地址(有一个40字节的偏移),第四条指令获取内部方法表在起始地址处的16个字节出的偏移,然后获取到了Work方法的地址,并调用Work方法。为了理解为什么调用虚方法需要借助方法表,我们需要了解运行时的方法绑定是如何工作的。比如说,多态是如何通过虚方法来实现的。

    假设我们有一个继承自Employee的名为Manager的类,然后该类实现了另一个名为ISerializable的接口:

public class Manager : Employee, ISerializable
{
    private List<Employee> _reports;
    public override void Work() ...
    //...implementation of ISerializable omitted for brevity
}

    编译器可能需要通过对Employee静态类型的引用,调用Manager.Work方法,如下:

Manager employee = new Manager(...);
employee.Work();

    在这种特殊的情况下,编译器可能需要使用静态流分析(static flow analysis)方法来推断需要调用Manager的Work方法(但是在当前C#和CLR还不会调用Manager的Work方法)。在一般情况下,当使用Employee静态类型引用时,编译器需要在运行时绑定。事实上,唯一的能正确绑定方法的办法是,在运行时,判断employee对象实际引用的类型,然后基于类型信息来调用虚方法。这就是方法表协助JIT编译器所做的工作。

如下图所示,Manager对象的方法表布局中的Work方法槽中覆写了一个和Employee不同的代码地址,方法的调用顺序仍然保持一致。注意到被覆写的槽距离方法表开始的偏移和之前的不同,但是,方法表的指针字段的偏移仍然是相同的。

5

 

调用非虚方法

   我们也可以使用相似的调用顺序类调用非虚方法。但是,对于非虚方法,我们并不需要使用方法表来进行调用:需要调用的方法的代码地址在JIT编译该方法时已经确定下来了。

  实体对象在调用非虚方法时,会对自身是否为空进行检验。如果查看Employee的Work方法调用,可以看到

mov edx, 5 ; parameter passing through register – custom calling convention
mov ecx, dword ptr [ebp-64] ; still required because ECX contains ‘this’ by convention
cmp ecx, dword ptr [ecx]
call dword ptr [0x004a1260]

 

    使用CMP指令来用第一个操作数减去第二个操作数,并且将计算结果设置为CPU的标识位。上面的代码并没有使用比较两者的结果,并将其存储在CPU标识位中。因此,如何使用CMP指令来帮助我们避免调用null对象的实例方法呢? CMP指令会试图访问ECX寄存器上的内存地址,上面保存有对象的引用,如果对象的引用为null,那么这种访问就会产生非法访问,因为方位内存地址为0的地方在Windows线程中总是非法的。在CLR中,这种非法访问通常被转换为在调用点上抛出NullReferenceException类型异常;这种方式比在方法调用时,在方法体中产生检查是否为null指令要好。更进一步,CMP指令在内存中只占用2个字节,它能够检查无效访问,而不仅仅是检查是否为null。

    Note在调用虚方法是,就不必要产生类似的CMP指令了。非空检查已经被隐式执行了,因为标准的虚方法调用流程会访问方法表指针,这就保证了该方法表指针是有效的。即使是在虚方法调用时,也不总是能够看到编译器生成的CMP指令。在最近版本的CLR中,JIT编译器足够聪明来避免不必要的重复的检查。比如,如果程序流从虚方法调用中返回一个对象,那么就已经包含了非空检查,所以JIT编译器就不需要生成CMP指令了。

    之所以如此关注非虚方法和虚方法的调用细节不仅是因为额外的内存访问或者是额外的指令生成。虚方法的最大的问题在于它会阻止编译器对方法进行内联优化,方法内联在现代高性能应用程序中至关重要。方法内联是一个相对简单的编译技巧,他牺牲代码的大小来提高执行速度,对于比较小的方法,会在调用的地方直接放置方法体。例如,下面代码中,内联调用会,直接调用一个Add操作指令。

int Add(int a, int b)
{
  return a + b;
}

int c = Add(10, 12);

 

    在没有优化的指令中,上面的调用至少需要10条指令:三条指令用来设置参数和调用方法,两个指令用来设置方法框架,一个指令用来将两个整数加到一起,两个指令用来销毁方法,一个指令用来保存方法的返回值。采用内联优化过的指令则只有一个操作指令。这个指令就是ADD指令,然而在一些编译器中,常数展开技术可以在编译时计算一些操作指令的结果,然后将常量C设置为22。

    使用内联优化,和非内联优化的代码的执行效率会有很大差别,尤其是像上面这种比较简单的方法体。例如,属性,也非常适合进行内联优化,对于编译时自动产生的属性尤其如此,因为他们不需要包含一些处理逻辑,而是简单的访问字段。但是,虚方法的调用会组织编译器的内联,因为内联操作只有生在编译时编译器知道所有对象的执行行为时才能产生(而虚方法需要在运行时才能判断对象实际引用的类型)。在运行时,确定了所需的类型信息及所需要调用的方法之后,将相关信息嵌入到对象中,这就导致了编译器没有办法为虚方法调用生成正确的内联代码。如果所有的方法和属性默认都是虚方法,那么调用这些虚方法由于无法进行内联优化,将会产生很大的性能损失。

调用静态方法和接口方法

为了讨论的完整性,还有额外两种类型的方法:静态方法和接口方法。调用静态方法相对简单,编译器并不需要加载对象的引用,直接调用方法(预编译块pre-JIT STUB)就可以。因为对静态方法的调用并不需要通过方法表,JIT编译器为调用非虚的实例方法而采用了一些编译技巧:通过一个特殊的内存地址,在JIT编译完成之后会更新该地址,来实现方法的间接调用。

但是对于接口方法,有一套完全不同的机制,看起来,调用接口方法和调用虚方法所有不同。实际上,接口方法和经典的虚方法一样,他能够实现某种形式的多态。不幸的是,对于多个实现了相同接口的类,其在方法表中,并不能保证接口方法处于相同的槽中。看看下面的代码,这两个类都实现了IComparable接口。

class Manager : Employee, IComparable {
    public override void Work() ...
    public void TakeVacation(int days) ...
    public static void SetCompanyPolicy(...) ...
    public int CompareTo(object other) ...
}
class BigNumber : IComparable {
    public long Part1, Part2;
    public int CompareTo(object other) ...
}

 

    很显然,上面两个对象的方法表会有很大差别,CompareTo在方法表中的槽数也不相同。一些复杂的对象继承和对接口实现会使得编译器会产生额外的调用步骤来确定方法表中接口方法所在的位置。

    在早期的CLR版本中,这些信息在接口被首次加载时,将该接口的ID存放在一个全局(AppDomain)的表中。方法表有一个特殊的入口(在方法表起始偏移量为12个字节处),它指向全局接口表的合适位置,然后全局接口表的所有入口返回给方法表,然后对应的接口指向其接口方法指针的存储位置。接口方法的调用需要多个步骤来实现,如下:

mov ecx, dword ptr [ebp-64] ; 引用对象
mov eax, dword ptr [ecx] ; 方法表指针
mov eax, dword ptr [eax+12] ; 接口表指针
mov eax, dword ptr [eax+48] ; 接口表指针中的具体方法,偏移
call dword ptr [eax] ;第一个方法在EAX, 第二个方法在 EAX+4, 等等.

    调用接口方法很复杂也很昂贵。以上代码需要四次内存访问来获取接口方法的代码地址并执行。对于一些接口,这种访问频率可能太高。然而JIT使用了一些技巧来有效地对接口方法进行了内联。

热径分析 (hot-path analysis)当JIT探测到一些接口实现经常被调用时,他会使用优化好了代码来替换特殊的调用地址,这样能够在接口实现中进行内联。

频率分析 (Frequency analysis) 当JIT探测到对一些调用上对热径的选择不再准确时,他会使用新的热径来替换之前的猜测到的热径,然后再每次猜测错误时进行替换。

同步块索引和lock关键字

    所有引用类型实例对象的头文件中的第二个字段就是对象头指针,或者称之为同步块索引。和方法表指针不同,对象头字节有很多用处,包括同步、GC 、对象哈希码存储等。对象头字节的最复杂的一个应用是同步,是用CLR的监视机制,通过lock关键字来实现的。常见情景如下:几个线程相同时进入一个被lock关键字包围的代码,但是只有一个线程能够进入代码内,达到互斥的目的。

public class Counter
{
    private int _i;
    private object _syncObject = new object();
    public int Increment()
    {
        lock (_syncObject)
        {
            return ++_i;
        }
    }
}

    为了保证互斥,同步机制可以与每个对象相关联。因为为所有的对象都创建同步机制的话,太昂贵。这种绑定机制发生在需要的时候,当对象在第一次需要同步的时候绑定。当需要同步时,CLR会从同步块索引表的全局数组中分配一个称之为同步块索引的结构。同步块索引包含一个拥有它的对象的后向引用(虽然这种引用时一个弱引用,不能阻止对象被GC掉),在这些机制中,同步机制又称之为监视机制,在内部使用Win32事件实现。大量分配的同步块索引被存储到对象的头字节中。进而使用这个对象来同步识别出存在的同步块索引以及使用与之关联的监视对象来实现同步。

    6

    对象的同步块索引字段仅仅存储同步块表中的索引,使得允许CLR在内存中改变和移动同步块表而不用修改同步块索引。当同步块索引长时间不用时,垃圾回收器将会对其进行回收,然后解除对象对其的引用,将对象的同步块索引值赋予一个非法的索引。在回收之后,同步块可以和其他对象进行结合,这样就节省了大量的操作系统资源来实现同步机制。

五 结语


    本文简要分析了.NET中的引用类型的内存布局,方法调用,同步块等的内部实现,相较于值类型,这是由于引用类型的这些复杂的结构赋予了其更多的用处和功能,希望本篇文章对您理解引用类型有所帮助。