NET中的引用类型和值类型 zt

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

在平常的开发中,很多人一上来就用class,而很少去想到底该用class还是struct。本文详细介绍.NET中的值类型以及在使用中应该注 意的问题。在某些情况下,使用值类型较引用类型可以显著减少内存占用和GC压力,提高程序的执行效率。本文参考《Pro .NET Performance》 《CLR Via C#》和 《Advanced .NET Debugging》,希望对您有帮助。

值类型内部实现

    和引用类型相比,值类型具有相对简单的内存布局,但是这种简单的布局也引入了一些限制,尤其是在要将值类型“当做”引用类型使用的时候需要进行装箱操作。

    上篇文章提到,使用值类型最主要的原因是:值类型具有良好的内存分配密度以及没有一些复杂的结构。当创建自己的值类型时,每一个字节都能够实实在在的派上用处。

    为了讨论方便,下面以Point2D这个类型来说明:

public struct Point2D
{
    public int X;
    public int Y;
}

    当我们将该对象实例化为 X=5,Y=7的时候,他的内存布局如下,没有像引用类型那样的额外字段。

1

    在少数情况下,我们可能需要制定值类型字段在内存中的布局方式,最典型例子就的是在进行互操作的时候,字段需要保持编程人员定义的顺序原封不动的传递给非 托管代码。为了向CLR发出指令,我们可以使用 System.Runtime.InteropServices.StructLayoutAttribute属性来实现这一要求。 StructLayout属性可以用来让类型的字段在内存中的布局按照定义的方式进行,我们可以通过其构造函数传入LayoutKind.Auto,让 CLR自动排列字段、LayoutKind.Sequential让CLR保持我们的字段布局,或者是LayoutKind.Explicit结合 FieldOffset来自定义布局。如果不设定,CLR会选择它认为最好的布局方式,一般滴CLR会为引用类型默认选择 LayoutKind.Auto,为值类型选择LayoutKind.Sequential。显式通过FieldOffset属性来指定,这可以使得我们 可以类似创建C风格的“联合”类型,自定义偏移后的字段有可能会重叠(Overlap),下面的例子展示了使用结构类型将一个浮点型转换为四个字节的表 示。

[StructLayout(LayoutKind.Explicit)]
public struct FloatingPointExplorer
{
    [FieldOffset(0)]
    public float F;
    [FieldOffset(0)]
    public byte B1;
    [FieldOffset(1)]
    public byte B2;
    [FieldOffset(2)]
    public byte B3;
    [FieldOffset(3)]
    public byte B4;
}

    将一个浮点型赋给该对象的F字段时,他会同时修改B1-B4字段,反之亦然。F字段合B1-B4字段在内存中是重叠在一起的。

2

    因为值类型实例没有对象头字节,以及方法表指针,所以不能提供像引用类型那样丰富的语义。下面来看看这种简单的内存布局使得值类型存在的局限性以及如果试图像引用类型那样在某些地方使用值类型会发生什么情况。

值类型的局限

    首先,考虑对象头字节,如果程序试图使用值类型的实例来作同步,这通常是一种Bug,但是运行时应该认为这样是非法并抛出一个异常吗?下面的代码中,如果两个线程同时调用Counter实例的Increase方法会怎么样呢?

class Counter
{
    private int _i;
    public int Increment()
    {
        lock (_i)
        {
            return ++_i;
        }
    }
}

    在VS中这样做时,C#编译器不允许在值类型上使用lock关键字。但是,我们知道lock是C#语言提供的一种语法糖,他会转换为Monitor的方式,所以我们将上面的代码改写为:

class Counter
{
    private int _i;
    public int Increment()
    {
        bool acquired = false;
        try
        {
            Monitor.Enter(_i, ref acquired);
            return ++_i;
        }
        finally
        {
            if (acquired) Monitor.Exit(_i);
        }
    }
}

    这样,就能通过编译了。这样在程序中引入了一个Bug,其结果是,多个线程能够同时进入到锁中并修改_i变量,进一步Monitor.Exit调用会抛出 异常。问题在于,Monitor.Enter方法接受一个引用类型的,System.Object型的参数,而我们传进去的却是值类型。即使我们按要求传 引用类型进去,Monitor.Enter中的参数值和Monitor.Exit中的值也不相同,同样,在一个线程中传到Monitor.Enter中的 参数和另一个线程中的Monitor.Enter方法中的参数也不一样。如果我们传值类型进去,没有办法获得正确的锁定语义。

     值类型语义不适合作为对象引用的另外一个例子是在一个方法中返回值类型时。请看下面代码:

object GetInt()
{
    int i = 42;
    return i;
}
object obj = GetInt();

    GetInt方法返回值类型。但是方法的返回类型希望是一个Object类型的引用。方法可以直接返回线程堆栈中存储i 的值的位置的引用。不幸的是,这样会产生一个对内存地址的非法引用,因为方法的栈帧在值返回时就被回收了。这说明拷贝值语义,在需要对象引用时并不适合使 用值类型。

值类型的虚方法

    到目前为止,我们没有考虑到值类型的方法表指针,然而在我们将值类型作为一等公民时仍有很多不容易克服的问题。现在我们来看看值类型如何实现虚方法和接口 方法。CLR禁止值类型之间继承,这使得我们不可能在值类型上定义新的虚方法。这很幸运,因为如果在值类型中能够定义新的虚方法,那么调用这些虚方法需要 方法表指针,而值类型是没有这部分。这不是一个重大限制,因为引用类型的值拷贝语义使得他们比较适合用来做多态,因为这需要对象引用。

    但是,值类型继承有来自System.Object类型的虚方法。这些方法有Equals,GetHashCode,ToString和Finalize,我们先讨论前面两个,后面几个虚方法也会讨论到。下面来看他们的签名:

public class Object
{
    public virtual bool Equals(object obj) ...
    public virtual int GetHashCode() ...
}

    .NET中的每一个类型都实现了这些虚方法,当然包括值类型。这表示,给定一个值类型的实例,我们能够成功的调用它的虚方法,即使他们并没有方法表指针。

    第三个例子展示了,值类型的空间布局是如何影响对值类型的一些简单的操作,诸如将值类型转换为一些能够提供更多功能的真正意义的对象上的能力。

值类型的装箱

    当语言编译器检测到需要将值类型作为引用类型处理时,就会产生装箱的IL指令。然后,JIT编译器解释这些指令,调用方法在托管堆上分配空间,然后将值类 型实例的内容拷贝到堆上,然后为值类型包装上对象头(对象头指针和方法表指针)。在任何需要将值类型当做引用类型使用的地方都会产生装箱操作。需要注意的 是,装箱后的对象和原来的值类型实例是没有关系的,改变其中一个对另外一个没有影响。

4

.method private hidebysig static object GetInt() cil managed
{
    .maxstack 8
    L_0000: ldc.i4.s 0x2a
    L_0002: box int32
    L_0007: ret
}

   装箱是一种很昂贵的操作,它涉及到内存的分布,拷贝,并且由于需要收回临时创建的装箱对象,会对GC会产生压力。在CLR 2.0中引入的泛型除了反射和其他一些极少情况,可以有效地避免装箱操作。不论怎样,装箱在很多应用程序中会产生明显的性能问题,在后面“如何正确使用值 类型”中我们会看到,如果不完全理解值类型中的方法调用操作,将很难避免各种装箱操作。

    先不考虑性能问题,装箱为我们之前遇到的一些问题提供了一种解决方案。比如GetInt方法返回一个对42值类型的装箱的引用。这个装箱的对象只要存在引 用会一直存在,他不会被方法调用堆栈的本地变量的生命周期所影响。同样,当Monitor.Enter方法需要引用类型时,他会在运行时对值类型进行装 箱,然后使用装箱后的对象来进行同步操作。不幸的是,一些值类型实例对象装箱产生的引用对象在代码的不同地方可能会不同,因此,Monitor.Exit 中传入的值类型进行装箱后的引用类型和Monitor.Enter中的值类型装箱后的引用类型并不相同。一个线程中的Monitor.Enter中传入的 值类型进行装箱后的引用类型和另一个线程中的同样方法的同样的值类型装箱后的对象也不同。这就意味着,使用值类型作为基于monitor机制的同步策略在 本质上是错误的,而不论是否值类型被装箱成了引用类型。

    还有一个遗留的关键问题是继承自System.Object的虚方法。实际上,值类型并没有直接继承自System.Object类型,相反,所有的值类型都间接的继承自System.ValueType。

  System.ValueType覆写了继承自System.Object类型的Equals和GetHashCode两个虚方法,这样做是有道理的。值 类型的相等性和引用类型的相等性具有不同的语义,值类型的这种不同的语义需要在某个地方实现。比如覆写System.ValueType中的Equals 方法可以保证值类型之间可以根据其包含的内容来相互比较,而在System.Object类型中的Equal方法却是比较对象的引用是否相同。

    不论System.ValueType如何覆写了这些虚方法,考虑下面的场景。你在List<Point2D>中存储了1千万个 Point2D对象,然后再这个集合中使用Contain方法查找是否存在某个特定的Point2D对象。然而,Contains只能从这1千万个数据上 执行线性的查找,然后逐个和提供的对象进行比较。

List<Point2D> polygon = new List<Point2D>();
//insert ten million points into the list
Point2D point = new Point2D { X = 5, Y = 7 };
bool contains = polygon.Contains(point);

   遍历1千万个对象然后逐个比较可能需要花点儿时间,不过这仍是一种相对较快的操作。访问的字节数大约会有8千万个(每一个Point2D对象占8个字节),然后执行比较操作也很快。但是遗憾的是,比较两个Point2D对象需要调用Equals虚方法:

Point2D a = ..., b = ...;
a.Equals(b);

    这儿产生了两个问题。首先即使从System.ValueType继承过来的Equals虚方法,他也是接受一个System.Object的引用类型的 参数。而将Point2D对象作为引用类型则需要进行装箱操作。因此b需要进行装箱,更进一步,调用对象上的Equals虚方法需要对a进行装箱以获取其 方法表的头指针。

    NoteJIT编译器实际上会产生直接调用Equals的代码,因为值类型是密封的,并且不论 Point2D是否覆写了Equals方法,在编译的时候调用哪个对象的那个方法是确定下来了的。但不论如何,因为System.ValueType是引 用类型,Equals方法在内部接受的第一个this参数,也就是对自己是一个引用类型,所以在值类型a上调用Equals方法,仍旧需要对b进行一次装 箱。

    简言之,如果不考虑JIT编译器的优化,每调用一个Point2D实例对象上的Equals方法需要进行两次装箱。上面的1千万次比较会产生2千万次的装 箱操作,在32为机器上每一次操作需要在分配16个字节的空间,总共需要分配320,000,000个字节,并且160,000,000要拷贝到托管堆 上。这些分配操作所化的时间远远超过了简单的对Point2D的两个字段的比较。

避免调用值类型Equal方法产生的装箱

    那么怎样才能彻底消除这种装箱操作呢?一种方法是覆写System.Value中继承来的Equals方法,并且提供为我们自己的值类型提供的相等逻辑。

public struct Point2D
{
    public int X;
    public int Y;
    public override bool Equals(object obj)
    {
        if (!(obj is Point2D)) return false;
        Point2D other = (Point2D)obj;
        return X == other.X && Y == other.Y;
    }
}

    即使考虑了JIT的优化,a.Equals(b)方法仍旧需要对b进行装箱,因为继承得来的方法接受一个System.Object类型的引用类型的参 数,但是不需要对a进行装箱了。为了移除第二个装箱操作,我们需要从装箱操作之外来思考,提供一个Equals方法的重载方法:

public struct Point2D
{
    public int X;
    public int Y;
    public override bool Equals(object obj) ... //同上
    public bool Equals(Point2D other)
    { 
        return X == other.X && Y == other.Y;
    }
}

    这样当编译器遇到a.Equals(b)时,他会优先选择第二个,因为他的参数类型更具体。想到这里,我们还有几个方法需要重载-通常,我们使用==和!=符号来进行类型比较,所以需要重载这两个操作符。

public struct Point2D
{
    public int X;
    public int Y;
    public override bool Equals(object obj) ... // as before
    public bool Equals(Point2D other) ... //as before
    public static bool operator==(Point2D a, Point2D b)
    {
        return a.Equals(b);
    }
    public static bool operator!= (Point2D a, Point2D b)
    {
        return !(a == b);
    }
}

    这基本上已经完成了。有一个极端情况是CLR在实现泛型的时候,调用List<Point2D>中的Point2D对象的Equals方法时 仍具需要装箱,因为Point2D是作为泛型类型参数(T)的一种实现。所以在这里Point2D对象还需要实现 IEquatable<Point2D>接口,这样List<T>和EqualityComparer<T>对象就 能正确的通过接口调用重载的Equals方法了(唯一有点儿遗憾的是需要花费一点儿虚方法调用的性能来调用 EqualityComparer<T>.Equal抽象方法)。这样执行速度较之前会快10倍,并且完全消除了在1000000个 Point2D对象中查找某个特定对象由于装箱而引入的内存分配。

public struct Point2D : IEquatable<Point2D>
{
    public int X;
    public int Y;
    public bool Equals(Point2D other) ... //as before
}

   现在我们可以开始思考值类型的接口实现了。在前文中我们已经看到,一个典型的接口方法调用需要对象的方法表指针,这对于值类型来说需要进行装箱。实际上,从值类型实例转换为接口类型变量就需要装箱,因为接口是被作为引用类型和目的来使用的。

Point2D point = ...;
IEquatable<Point2D> equatable = point; //需要装箱

    但是,当通过静态的值类型变量调用接口方法时,并不需要进行装箱,和前面讨论的一样,这是JIT编译帮我们做的一点儿小优化。

Point2D point = ..., anotherPoint = ...;
point.Equals(anotherPoint); //并不需要装箱,调用 Point2D.Equals(Point2D) 方法。

    通过接口使用值类型,在值类型可变的情况下,可能会引发一些潜在的问题,比如Point2D对象。修改装箱后了的值类型并不会影响原始的值类型,这样就会引发一些不可预料的行为。

Point2D point = new Point2D { X = 5, Y = 7 };
Point2D anotherPoint = new Point2D { X = 6, Y = 7 };
IEquatable<Point2D> equatable = point; //装箱
equatable.Equals(anotherPoint); //false
point.X = 6;
point.Equals(anotherPoint); //true
equatable.Equals(anotherPoint); // false, 装箱后的值没有发生变化

    关于这点,强烈建议设置值类型设为不可变类型,然后需要改变时创建新的拷贝,System.DateTime 就是不变值类型的一个典型的例子。

    最后一个问题是ValueType.Equals的实际执行方法。通过值类型包含的内容来对两个值类型进行相等性比较是比较麻烦的。下面是使用Reflector查看系统ValueType的Equals方法的实现:

public override bool Equals(object obj)
{
    if (obj == null) return false;
    RuntimeType type = (RuntimeType) base.GetType();
    RuntimeType type2 = (RuntimeType) obj.GetType();
    if (type2 != type) return false;
    object a = this;
    if (CanCompareBits(this))
    {
        return FastEqualsCheck(a, obj);
    }
    FieldInfo[] fields = type.GetFields(BindingFlags.NonPublic |
    BindingFlags.Public | BindingFlags.Instance);
    for (int i = 0; i < fields.Length; i++)
    {
        object obj3 = ((RtFieldInfo) fields[i]).InternalGetValue(a, false);
        object obj4 = ((RtFieldInfo) fields[i]).InternalGetValue(obj, false);
        if (obj3 == null && obj4 != null)
            return false;
        else if (!obj3.Equals(obj4))
            return false;
    }
    return true;
}

    简单分析一下,如果CanCompareBits方法返回true,那么执行FastEqualsCheck方法来进行相等性比较。否则,方法使用反射, 查找所有的字段,然后逐个递归调用Equals方法。毋庸置疑,基于反射的循环操作是性能瓶颈。反射是一种极其昂贵的操作。CanCompareBits 和FastEqualsCheck是CLR的内部实现调用,不是通过IL调用的,所以我们不能够轻易看到,但是我们可以分析得到,如果值类型结构比较紧 凑,且不好含对其他对象的引用,CanCompareBits就会返回true

    FastEqualsCheck方法看起来很神奇,但是它实际上是执行的memcmp操作,比较按字节比较值类型实例在内存中的存储。这两个方法都是内部实现的细节,要满足以上苛刻条件来使用这种比较方法不是一个好的办法。

GetHashCode方法

    最后一个需要覆写的重要方法是GetHashCode方法。在我们覆写一个合适的实现之前,简要讨论一下这东西有什么用。哈希码用的最多的就是和哈希表一 起使用,哈希表是一种可以在常数时间内实现插入,查找,删除操作的数据结构。.NET框架中最常见的哈希表类有 Dictionary<TKey,TValue>,Hashtable和HashSet<T>。一个典型的哈希实现由一组动态长 度的buckets数组组成,每一个bucket都包含一个链表。往哈希表中放数据的时候,他首先调用GetHashCode来计算数值,然后通过哈希函 数计算该映射到那一个buckets,然后将这个元素插入到该buckets的链表中。

5

    哈希表的性能严重依赖于哈希表实现时选用的哈希函数,哈希函数应该满足一下几点

  1. 如果两个对象相等,那么他们的哈希值要相等。
  2. 如果两个对象不相等,那么他们的哈希值应该尽可能的不相等。
  3. GetHashCode方法必须快,虽然经常是对象的线性大小。
  4. 对象的哈希值应该是不变的。

    GetHashCode的一个典型的实现就是依赖对象的字段。例如,对于int类型的GetHashCode的比较好的实现就是直接返回这个int值。对 于Point2D对象,我们可以考虑对两个坐标做线性组合,或者对两个坐标分别取出某些位,然后组合。定义一个普遍的好的哈希值算法比较困难,在这里不便 讨论。

    哈希值应该是不变的。假设有一个point(5,5)的点,将它存放在一个哈希表中,进一步假设他的哈希值为10。如果将这个点修改为 point(6,6),那么他的哈希值就变为了12 。现在,你就没有办法找到之前的插入的那个点了,因为哈希值被改变了。但是在值类型中这却不是个问题,因为我们不能修改已经插入到哈希表中的对象了。哈希 表存储了一份拷贝,我们的代码访问不到。

    那么引用类型是如何实现了的,对于引用类型,通常基于内容的相等性,考虑到下面类型的GetHashCode方法的实现:

public class Employee
{
    public string Name { get; set; }
    public override int GetHashCode()
    {
        return Name.GetHashCode();
    }
}

    这看起来是一个好主意,哈希值基于对象的类容,并且我们使用了String.GetHashCode,因此我们不需要去为Strings来实现一个好的生成哈希值函数,但是考虑到当我们将该类型插入到哈希表后,我们改变了该字段之后,会发生什么情况:

HashSet<Employee> employees = new HashSet<Employee>();
Employee kate = new Employee { Name = “Kate Jones” };
employees.Add(kate);
kate.Name = “Kate Jones-Smith”;
employees.Contains(kate); //false!

    对象的哈希值发生了改变,因为他的内容变化了,我们不在能在哈希表中找到该对象了。这也是我们预料的,或许我们根本就不能从哈希表中移除Kate这个对象了,虽然我们仍访问的是原始的对象。

    CLR为引用类型提供了一个默认的GetHashCode实现,它基于对象在比较相等性时的依据原则。如果两个对象的引用相等,仅且仅当引用的是同一个对 象时,可以将哈希值存储到对象本身,这样他就不会被修改并且容易访问。实际上当一个引用类型的实例被创建时,CLR会将该对象的哈希值存放到对象的头字节 中(为了优化,一般是在第一次访问哈希值时生成,毕竟大多数对象从来都不会使用到哈希表的键)。要计算哈希值,并不需要生成随机数其或者对象的内容,一个 简单的计数器就可以。

     Note: 对象的哈希值如何与同步块所以在对象的头字节中共存?上文中可以看到,大多数对象都不会用到头字节来存放同步块所以,因为他们都不会被用来进行同步。在一 些极少数情况下,对象会被用作 同步而需要在头字节中存储同步块碎银,哈希值被拷贝到同步块索引上,一直到同步块索引从对象头字节上移除。要确定对象头字节中当前存储的是哈希值还是同步 块索引,有一个标志位可以用来进行判断。

    引用类型使用默认的Equals和GetHashCoe实现,而不需要考虑上面提到的四个属性,他们都已经实现好了。但是,如果引用类型需要覆写默认的相等性行为,如果需要将引用类型作为哈希表的键,那么应该保证他的不变性。

 

使用值类型应该注意的问题

    经过上面的一些讨论,对于值类型,CLR Via C#中建议,如果达到下面所有要求,就应该考虑使用值类型:

  • 类型具有基元类型的行为,就是类型比较简单,没有成员回去修改类型的实例字段。没有提供修改字段的方法,类型不可变。
  • 类型不需要从其他类型继承并不会派生自其它类型。

    除此之外,考虑导致类型的拷贝复制,满足上面两点之后,还需要要满足下面之一

  • 类型的实例较小(16字节或更小)
  • 类型的实例较大(大于16字节),但不作为方法参数传递,也不作为方法的返回类型使用。

    当然,通过本文的分析,当遇到下面情况时,也可以考虑使用值类型。

  • 如果对象比较少,并且数量比较多,应该使用值类型
  • 如果需要高密度的内存集合分配,应该使用值类型

    如果使用值类型,需要注意下面几点:

  • 自定义值类型需要覆写Equals方法,重载Equals方法,实现IEquatable<T>接口,重载==和!=操作符
  • 自定义的值类型应该覆写GetHashCode方法
  • 值类型应该保持”不可变(immutable)”,改变应该重新创建新的对象的拷贝

结语

    我们分析了值类型和引用类型的内存布局,以及这些细节是如何影响程序性能。值类型具有较好的内存分配密度,这使得在创建大数据量的集合是具有比较好的优 势,但是他缺少引用类型的多态和同步支持。CLR为我们提供了这两种不同类型来让我们在需要的时候提高应用程序的性能,但是仍然需要我们通过分析,来正确 的实现值类型。

转载于:https://www.cnblogs.com/zeroone/p/3288347.html

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值