C# 值类型与引用类型 实现原理与差异

简介

  谈起值类型和引用类型的区别和用法,我们并不陌生。你首先想到的也许会是:

  • 值类型分配在栈上,引用类型分配在堆上。
  • 值类型在传递时是传递内容的副本,引用类型则传递的是对象的引用的副本。
  • 值类型包括int,float等基元类型,以及struct;引用类型则包括类,接口,数组,委托,以及我们常用的string等。

  不过,这些并不是全部。让我们从两种类型的存储结构,分配与回收,CLR实现细节,以及怎样避免值类型装箱带来的性能损失等方面来比较二者的差异。

堆和栈

  堆和栈其实都是虚拟内存上的一段地址范围,他们在本质上并没有什么区别,只是两者的维护方式不同。不过二者的访问速度常常会有差异,主要是受以下因素的影响:

  • 栈分配具有连续性的特点。一般时间上连续分配的对象在空间上也是连续的,并且在访问时也是一起被访问的。这样可以更好的利用CPU缓存和操作系统的分页系统,降低了缓存未命中的概率。
  • 栈上的内存密度比堆上的高。虽然堆分配也是由指针递增连续分配的,但由于引用对象需要对象头字节和方法表指针等额外开销,因此栈往往表现出更好的性能。
  • 线程栈往往很小。Windows的线程栈大多不超过1MB,因此,所有的线程栈都可以用于CPU缓存。

  栈分配也许是可以提高访问性能,但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不会立刻将其与同步块进行关联,而是使用一种称为瘦锁的机制,以此来节省内存和时间。

  1. 当对象第一次被锁定且不存在竞争时,CLR会将当前对象所在线程的ID写入头字节中。
  2. 当其他线程试图访问时,会等待瘦锁的释放。
  3. 如果一段时间后,瘦锁仍未释放,则会转化为一个同步块,并将同步块的索引保存在对象头字节中。这时,将会按照win32的同步机制来阻塞线程。

值类型揭秘

值类型的内存布局

  在语言互操作时,值类型往往需要原封不动的传递给非托管代码。我们可以通过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()方法,应满足以下几个条件:

  1. 如果两个对象(Equals)相等,那么他们的HashCode一定相等。
  2. 如果两个对象(Equals)不相等,他们的散列码有可能相等。
  3. HashCode的计算方法一定要快,并且保证得到的哈希值均匀的分布在int范围内。
  4. 一个对象的哈希值不应该发生变化。
  5. 该方法不应抛出任何异常。

  CLR默认提供了GetHashCode()的实现,在第一次访问对象的哈希值时,CLR会将其求得的哈希值内嵌在对象头字节中,当该对象被应用于同步时,对象头字节中的哈希值会存储到该对象所关联的同步块中去,而它的对象头字节则转而存储同步块的索引。

  目前对象头字节中存储的是同步块索引还是哈希值,可以从对象头字节的某一个标识位来判断。

你可能感兴趣的:(.NET,CLR,C#,值类型,引用类型,区别,本质)