谈起值类型和引用类型的区别和用法,我们并不陌生。你首先想到的也许会是:
不过,这些并不是全部。让我们从两种类型的存储结构,分配与回收,CLR实现细节,以及怎样避免值类型装箱带来的性能损失等方面来比较二者的差异。
堆和栈其实都是虚拟内存上的一段地址范围,他们在本质上并没有什么区别,只是两者的维护方式不同。不过二者的访问速度常常会有差异,主要是受以下因素的影响:
栈分配也许是可以提高访问性能,但Windows线程栈资源是有限的,所以不能将所有的分配都用于栈上。
回收栈上的内存十分高效,值类型分配在栈上, 当某方法返回时, 栈会恢复到该方法调用前的状态, 该方法在栈上分配的值类型的内存自然就释放了。
CLR内部使用GC(垃圾回收器)来负责引用对象的回收与堆的维护。与JVM类似,他们都使用追踪垃圾回收机制。
当程序运行到某些条件下触发垃圾回收机制时,GC会先从一系列起点(称为根)出发,通过严格的计算和追踪,遍历对象图中所有被程序引用的对象并进行标记。这是一个复杂且繁琐的过程,还好垃圾回收器会帮我们考虑各种可能。
标记过程过后便是无用对象的清理和内存压缩阶段。压缩是为了保证内存空间的连续性而进行的对象复制迁移。对于超过85KB的大对象,则会将其分配到专门的大对象堆进行维护。而且GC会按对象存活时间将它们分为0,1,2代,他们的维护频率是不同的。
需要说明的是,GC只是维护托管资源,对于文件流等非托管资源的释放,应由程序员自己进行。
一个位于托管堆上的对象,它不仅存储自身包含的字段的值,还会存储一些其他的信息。通常,对象存储的前8个字节(64位系统)被称为对象头字节,又称同步块索引。紧接着是8个字节的方法表指针,其后才是该对象的各个字段。 对象头字节和方法表指针是服务于JIT和CLR的,通过.NET语言无法访问。
在64位系统中,堆上的对象会自动对齐成8字节。事实上,即使一个类不包含任何实例字段,它在实例化时仍会占用24个字节。这也很好的说明了引用类型的存储密度开销。
对象的引用指的是什么?对象引用实质上就是一个指向方法表指针开头的内存地址。
对象头字节则有着广泛的用途。例如用来存储散列码,同步信息等。
CLR监测机制会使用对象头字节来进行同步。我们通常使用lock关键字进行同步操作,而它其实是语法糖。是对Monitor.Enter和Monitor.Exit方法的包装。
object _syncObject=new object();
bool acquired=false;
try
{
Monitor.Enter(_syncObject,rf acquired);
i++;
}
finally
{
if(acquired) Monitor.Exit(_syncObject);
}
当需要时,CLR会从一个全局数组同步块表中分配一个叫做同步块的结构。同步块包含标记位,win32事件实现的同步机制,hashcode,以及自身的引用(弱)等。该同步块的引用会存储到加锁对象的对象头字节中。如果以后将该对象用于同步时,就可以识别出已同步的索引块,并进行监视。
从CLR2.0开始,如果对象是第一次用于加锁操作且尚不存在竞争时,CLR不会立刻将其与同步块进行关联,而是使用一种称为瘦锁的机制,以此来节省内存和时间。
在语言互操作时,值类型往往需要原封不动的传递给非托管代码。我们可以通过StructLayout和FieldOffset两个特性来定义布局。
StructLayout默认指定对象字段的布局顺序与定义顺序一致,而FieldOffset则定义了字段的偏移量。这样字段可能会重叠,类似于c语言中的联合体。比如:
[StructLayout(LayoutKind.Explicit)]
public struct Example
{
[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;
}
因为float是四个字节,而F和B1偏移量都是从0开始,所以F和B1B4在内存中的位置是重叠的,即修改F的值,B1B4也会发生变化。
首先,lock方法本身就不接受值类型作为参数,它属于语法错误。我们了解到lock实际上是Monitor类Enter和Exit方法的语法糖,而这两个方法实际上是可以将值类型作为参数的。
但是如果我们将值类型作为加锁对象的话,按照值类型传参的特性,每一个Monitor.Enter()方法获得的参数都是该值类型对象装箱后返回的副本,他们将具有不同的标识,起不到加锁的作用,因此值类型不能用于加锁操作。
装箱就是将值类型转化成引用类型。值类型从ValueType类继承了一些虚方法,如:ToString,Equals等。而我们需要有对值类型具备分发这些虚方法的能力,因此产生了装箱这种技术,用于将值类型转化成引用类型。
当编译器检测到值类型当作引用类型时,会生成IL指令box,然后JIT编译器会解释该指令,分配堆存储空间,将值类型的值复制到堆上,并用对象头包装起来,这就是装箱。不过这被认为是一种昂贵的操作,它会带来内存分配,复制与回收的压力。
例如:假设Point2D是一个包含两个整数的结构体,List< Point2D > polygon中包含了1000万个Point2D对象,我们想知道某一个Point2D point对象是否包含在这个集合中,通常我们会调用集合的Contains方法来判断。
bool contains=polygon.Contains(point);
这本应该是一个很快速的过程,每一个Point2D对象都有8个字节,1000万个对象也只是8000万个字节,并且比较操作是很快速的。但遗憾的是,Contains方法实际上是调用了Point2D对象从ValueType类继承的Equals方法来判断两个对象是否相等的:a.Equals(b);
Equals方法接收的是一个object类型的引用,因此b会被装箱;而给a分发方法又会导致a被装箱。那么这个简单的比较操作就变成了:2000万次装箱,每次分配24个字节,共48000万个字节的内存被分配,16000万字节的内存被复制。这将远远的超出比较两个点是否相等所用的时间。
其实,上面提到的Point2D调用的Equals方法属于从ValueType类继承而来的虚方法,虚方法的分发需要借助方法表,而非虚方法则不同。如果我们在Point2D中重写了Equals方法,那么Point2D将会采用非虚方法的分发策略,即JIT在编译方法的分发时,被调方法的代码地址已经是确定的了。
对于方法分发策略,我没有太多理解,不知道上面说的对不对。但是,想要避免Point2D的装箱操作,我们可以在其中改写Equals方法:
public struct Point2D
{
public int X;
public int Y;
public bool Equals(Point2D other)
{
return X= other.X && Y= other.Y;
}
}
另外,我们还可以重载==和!=操作符。这样一来,就可以避免无谓的装箱操作了。
最后一个问题是重写GetHashCode()方法,应满足以下几个条件:
CLR默认提供了GetHashCode()的实现,在第一次访问对象的哈希值时,CLR会将其求得的哈希值内嵌在对象头字节中,当该对象被应用于同步时,对象头字节中的哈希值会存储到该对象所关联的同步块中去,而它的对象头字节则转而存储同步块的索引。
目前对象头字节中存储的是同步块索引还是哈希值,可以从对象头字节的某一个标识位来判断。