博客园  :: 首页  :: 新随笔  :: 联系 :: 订阅 订阅  :: 管理

.NET 值类型的性能

Posted on 2012-11-29 17:23  淡如水wp  阅读(715)  评论(0编辑  收藏  举报

深入理解值类型:

值类型的内存布局更简单,但是它引入一些限制和装箱,装箱是一个代价高昂的过程。用值类型的主要原因是其内存密度高而且开销少。下面还是以简单的Point2D来讨论:

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

Point2D实例以X=5, Y=7初始化的内存布局就像下图一样,没有额外的开销:

少数罕见的情况下,需要自定义值类型的内存布局,一个例子是为了达到互操作的目的,当值类型的实例原样传到非托管代码时。可以通过两个属性来做这种自定义,StructLayout 和 FieldOffset。StructLayout可以用来指定对象的字段根据类型的定义顺序来布局,或按照FieldOffset属性的说明来做。它允许建立C风格的联合(字段可能重叠)。下面的例子,可以将一个浮点数转换成一个4字节的:

[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的内存重叠了。

因为值类型实例没有对象头字节和函数表指针,所以没有引用类型那么丰富的语义,下面看看值类型的限制

值类型的限制:

首先看看对象头字节,如果程序试图用一个值类型实例来做同步,常常会出现Bug,但是在运行时会抛出异常么?下面的代码中如果同一个Counter类实例的Increment函数被两个不同的线程执行会发生什么?

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

当我们试图确认什么会发生时,意想不到的事发生了:C#编译器不允许在lock中用值类型,那就换一种解决吧:

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,多个线程能同时进入lock中修改_i的值,而且Monitor.Exit会招聘异常。问题在于Monitor.Enter函数接收System.Object(引用类型)的参数,而我们传了值类型。
另一个例子是当函数返回一个值类型时,为什么值类型和对象引用不匹配:

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

GetInt函数返回值类型,但是调用者期望返回一个object引用。函数能在执行时返回一个直接指向 i 存储空间的栈的指针。但是,它将会是一个无效的引用地址,因为栈桢在返回前已经被清理了。这说明了函数需要返回object引用时,值类型不适合。

值类型的虚函数

我们还没有讨论函数指针表,现在看看虚函数和接口实现。CLR禁止从值类型继承,使得无法在值类型里定义新的虚函数。这样其实很好,因为如果可以定义新的虚函数,那么调用这些函数则需要一个函数表指针,而这个函数表指针又不在值类型的实例里。这倒不是一个很大的限制,因为按值拷贝的引用类型使它们不适合多态,因为需要object引用。
不管怎么样,值类型还是有从System.Object继承几个虚函数:Equals,GetHashCode,ToString,Finalize。这里讨论前两个:

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

这些虚函数被每个.NET类型都实现了,包括值类型。这意味着值类型的实例也能成功调用虚函数,甚至在没有函数表指针的情况下。


装箱:

当编译器检测到需要将值类型按引用类型处理时,则触发装箱IL指令。JIT编译器解释该指令然后调用一个函数分配堆空间,拷贝值类型实例的内容到堆上,然后用对象头字节和函数表指针封装值类型的头部。这就是当object引用需要时装箱所要做的。

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

装箱操作的代价很高,涉及到内存分配,内存拷贝,以及GC也面临回收临时箱子的压力。.NET2.0中的泛型说明了,除反射和一些特殊的场景之外基本不需要装箱。装箱有很严重的性能问题。
撇开性能问题,装箱为我们遇到的问题提供了一个补救措施。在前面的GetInt函数中返回一个堆中箱子的引用,这个box只要被引用着就一直存在,而且不影响函数栈上本地变量的生命周期。类似地,当Monitor.Enter函数期望一个object引用时,它接收到一个堆上的箱子的引用,然后用这个box同步。不幸地是,从代码里不同时创建的相同的值的箱子并不视为是相同的,所以传给Monitor.Exit的并不是另一个线程传给Monitor.Enter的那个箱子。这就是说任何用值类型做同步对象都是不对的.
问题的关键仍然是从System.Object继承的虚函数,事实证明,值类型不直接从System.Object派生,而是从System.ValueType派生。
注意:System.ValueType是引用类型,CLR告诉值类型和引用类型的区分是:值类型是从System.ValueType派生的.

System.ValueType重写了从System.Object派生的Equals 和 GetHashCode 虚函数,因为值类型和引用类型的相等在语义是不同的。
不管System.ValueType怎么重写这些虚函数,考虑以下场景,你将100万个Point2D对象存在一个List<Point2D>中,然后用Contains找一个Point2D对象,Contains除了线性搜索没有更好的方法了

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);

遍历一个1000万的点list集合然后挨个比较还是比较快的,8字节每个点的话,需要8000万次,比较操作是比较快的,遗憾的是,比较两个Point2D对象需要调用一个虚函数Equals:
Traversing a list of ten million points and comparing them one-by-one to another point takes a while, but

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

这里有两个利害攸关的问题,首先,Equals需要一个引用类型的参数(System.ValueType也一样), 这就需要装箱。另外,调用虚函数Equals需要获取函数表指针。

注意:JIT编译器有一个短路的行为,允许一个函数直接调用Equals,因为值类型是sealed,而虚函数调用是运行时决定的。尽管如此,System.ValueType是引用类型,Equals函数也直接将参数隐式地以引用类型对待,当用值类型实例调用Equeals时,需要装箱。

总结来说:在Point2D调用Equals时我们有两个装箱操作。1000万个Equals有2000万个装箱操作,每次分配(32位系统上)16个字节,共分配3.2亿个字节,以及拷贝1600万个字节到内存中。

避免在Equals时装箱:

怎么摆脱装箱操作,有一个主意是重写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,因为函数只接收引用类型的参数,但是不需要对a装箱。避免了1次装箱。
为了避免第2次装箱,有下面的重写:

public struct Point2D
{
public int X;
public int Y;
public override bool Equals(object obj) ... //as before
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>调用Equals时还是引起了装箱。
Point2D需要实现IEquatable<Point2D>,它允许智能地通过接口调用Equals函数。结果是执行时间上10倍的改进 

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

这里可以反思值类型的接口实现,我们看到,典型的接口函数调用需要一个对象的函数表指针,确实发生了从值类型转换成接口类型,因为接口引用可以看到object引用:

Point2D point = ...;
IEquatable<Point2D> equatable = point; //boxing occurs here

但是接口调用静态的值类型时,不会发生装箱

Point2D point = ..., anotherPoint = ...;
point.Equals(anotherPoint); //no boxing occurs here, Point2D.Equals(Point2D) is invoked

如果值类型是可变的(mutable),用接口调用值类型会有潜在的问题,
如果修改Point2D的箱子,不会影响到Point2D的原始数据,但是会有意料不到的行为:

Point2D point = new Point2D { X = 5, Y = 7 };
Point2D anotherPoint = new Point2D { X = 6, Y = 7 };
IEquatable<Point2D> equatable = point; //boxing occurs here
equatable.Equals(anotherPoint); //returns false
point.X = 6;
point.Equals(anotherPoint); //returns true
equatable.Equals(anotherPoint); //returns false, the box was not modified!

这里建议使值类型不可变,且仅允许修改其复本(参考System.DateTime API就是一个不可变的值类型)。

比较任意两个值类型是不麻烦的:

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将负责检查可比较性。否则,函数进入基于反射的循环,不用说,基于反射的循环效率很差。反射是代价极其高的机制。
CanCompareBits和FastEqualsCheck被推迟到CLR,他们是内部调用,不是IL实现的,所以我们不能简单地反汇编他们。

FastEqualsCheck函数也很相似,但是在memcpm操作按位比较值类型时时更高效,可惜这些函数都保留了一个内部实现的细节,依靠他们来高效地比较你的值类型是不靠谱的。

GetHashCode

这个函数仍然需要重载,在合适的实现之前,先普及一下它是干嘛的。哈希码常和哈希表一起出现,是一种在常数时间内允许任意数据的插入,查询和删除操作的数据结构。常用的哈希表类在.NET里有<TKey,TValue>,Hashtable和HashSet<T>。典型的哈希表实现一个动态增长的桶,每个桶包括一个链表项,要定位哈希表中项需要先计算一个数字值(用GetHashCode函数),然后用一个哈希函数指定映射到哪一个桶中。这个项就被插入到它的桶的链表里了。

哈希表的效率取决于哈希函数的实现,但也需要一些GetHashCode的规范:
1,如果两个对象相等,那么哈希码相等。
2,如果两个对象不等,哈希码也不可能相等。
3,GetHashCode应该很快。
4,对象的哈希码不应该改变。

第2条意思是如果对象足够多的话,可能两个不相等对象的哈希码可能相等。
从形式上看,第2条可以按如下要求均匀分布散列码:设置对象A,让S(A)的集合的所有对象B,使得B不等于A,B的哈希码与A的哈希码相同。
第2条需要S(A)的体积大概等于每个对象A。(这里假设每个对象都相同,不需要实际类型上真正相同)
第1条和第2条强调对象间的相等关系和哈希码的相等关系。如果我们遇到了重载和重写Equals函数的麻烦,这里没有办法保证GetHashcode的实现与其对齐.
这样会看起来典型的GetHashCode的实现依赖某些对象里的字段。
举个例子,一个很好int的GetHashCode的实现是简单地返回这个整形的值,对于Point2D对象,我们可以考虑用两个坐标的线性组合,或者组合第一个坐标的前几个bit和第二个坐标的后几个bit。设计一个好的哈希码是很难的。

对于条件4,假如你有一个点point(5,5)嵌在一个哈希表里,假设哈希码是10,如果你修改这个点成point(6,6),他的哈希码也变成12,那么你就不能找到这个点了。对于值类型这不是一个问题,因为你不能修改你插入哈希表的对象,哈希表保存的仅仅是其复本而已。那引用类型呢?基于内容的比较是有问题的,看看下面的代码:

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

 看起来是个好主意,哈希码基于对象的内容,而且我们使用String.GetHashCode,那我们就不需要实现strings的哈希函数了。但是,还是要考虑当我们在一个Employee对象插入哈希表后后改变它的名称:

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

对象的哈希码已经改变了,因为内容变了,这样我们就找不到这个对象了。

CLR为引用类型提供了一个默认的GetHashCode的实现,如果两个对象的引用是相同的,它就会将哈希码存在对象本身里,CLR可以将哈希码嵌入对象的对象头字节(只有哈希码被第一次存取的时候这么做,因为大多数对象从来不被用作哈希key),计算哈希码,并不需要依赖一个随机数生成的算法或者考虑对象的内容,简单的计数器就行了。

注意:哈希码是怎么与同步索引块在对象头字节里和平共处的呢? 大多数对象从来不使用他们的对象头字节去存储一个同步索引块的,因为他们不需要同步。极少数情况下一个对象通过对象头字节存储它的牵引链接到一个同步块,哈希码复制到同步块里直到同步块从对象里分享。有一个bit在字段里决定了当前哈希码还是同步块索引是否存在对象头字节里。

引用类型使用使用默认的Equals和GetHashCode实现,不必关心上面提到的4个条件。但是,如果你的引用类型应该选择重写默认的比较行为(这就是System.String做的),然后你如果想用它当哈希表的key还要考虑让你的引用类型不可变

使用值类型的最佳实践

下面是一些正确使用值类型的方式:
如果你的对象很小并且你想创建大量这个对象的话,用值类型。
如果你需要高密度的内存集合,用值类型。
在你的值类型上重写Equals,重载Equals,实现IEquatable<T>,重载==,重载!=。
在你的值类型上重写GetHashCode。
考虑让你的值类型不可变。