上一篇文章中简单讲了.NET中值类型和引用类型的区别,并分析了引用类型的内存布局和实现方式,并在开始的例子中简单分析了值类型相较于引用类型的若干优点。在平常的开发中,很多人一上来就用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为我们提供了这两种不同类型来让我们在需要的时候提高应用程序的性能,但是仍然需要我们通过分析,来正确的实现值类型。