值类型的拆箱与装箱

值类型的装箱与拆箱介绍

概述

在之前文章中提到了,值类型具有两种表现形式:已装箱和未装箱,这两种状态的转换过程称之为装箱和拆箱。从内存分配的角度来说,装箱就是将值类型经过处理从线程栈复制到托管堆;拆箱则是将已装箱的值类型实例从托管堆复制到线程栈。

装箱与拆箱的性能损耗

装箱流程:

  1. 在托管堆中分配内存,内存大小 = 值类型大小 + 对象指针 + 同步块索引。
  2. 逐字段将值类型复制到新分配的内存。
  3. 返回对象指针,指针指向新分配的内存,至此,值类型转换成了引用类型。

拆箱流程:

  1. 获取已装箱对象中各个字段的地址,这个过程称之为拆箱,在这个过程开始时会对已装箱对象进行检查,首先检查是否为null,如果为null,抛出NullReferenceException;然后检查已装箱对象是否为所转值类型,如果不是则抛出InvalidCastException。至此拆箱操作已经完成了,拆箱其实就是获取字段指针的过程,但是一般紧接着都会发生一次字段复制,所以也将字段复制考虑到拆箱性能损耗。
  2. 将字段逐一从托管堆复制到线程栈中。

装箱的产生

为了避免装箱和拆箱产生性能损耗,首先我们需要知道什么时候我们写的代码会发生装箱操作,下面主要列举四种情形。

1. 值类型转换为object类型

最容易发现的一种情况是显示转换为object类型

//值类型强转object类型时,发生装箱
int number = 10;
object boxedNumber = number;

还有一种比较容易忽略的情况,值类型作为object类型实参,所以很多方法重载多个版本来减少值类型的装箱和拆箱

int number = 10;

//调用Console.WriteLine(string,object)函数时,因为第二个参数需要Object类型,
//所以需要将number进行装箱
Console.WriteLine("Box number:",number);

2. 将值类型转换为Interface类型

这是因为接口变量必须包含对堆对象的引用。
同样也包括两种情况,一种是显示转换,第二种是作为实参进行传递。

//随意定义
public interface IDoSomething{}

//自定义结构,实现接口
public struct Vector3 : IDoSomething
{
}

public class Test
{

    public void TestBox()
    {
        //生成值类型实例
        Vector3 v = new Vector3();

        //将值类型转换为接口类型,此时发生装箱
        IDoSomething ido = v;
    }
}

3. 调用基类方法

在调用值类型实例的基类方法(GetType、MemberwiseClone、ToString、GetHashCode、Equals)时,会造成装箱,原因是在访问基类方法时,需要基类方法由System.Object类型定义,要求this实参是指向托管堆的指针,但是有一种特殊的情况不会造成装箱,那就是,在调用基类virtual方法时,如果override方法没有调用base方法,就不会发生装箱,下面通过代码来详细看下。

public struct Vector2
{
    private readonly int _x;
    private readonly int _y;

    public Vector2(int x,int y)
    {
        _x = x;
        _y = y;
    }
}

//显式调用基类方法
public void TestBaseCall()
{
    Vector2 v = new Vector2(1,1);

    //由于调用基类的ToString方法,所以会发生装箱
    Console.WriteLine(v.ToString());
}

如果定义的类型重写了基类的virtual方法,并且override方法中没有调用base,则不会造成装箱

public struct Vector2
{
    private readonly int _x;
    private readonly int _y;

    public Vector2(int x,int y)
    {
        _x = x;
        _y = y;
    }

    //重写基类的ToString方法,并且不能调用base.ToString
    public override string ToString()
    {
        return string.Format("({0},{1})",_x.ToString(),_y.ToString());
    }
}

public void TestVirtualCall()
{
    Vector2 v = new Vector2(1,1);

    //调用v的ToString方法,由于v重写了ToString方法
    //并且没有调用基类的ToString方法,所以不会造成装箱
    Console.WriteLine(v.ToString());
}

除了以上这些显示的调用,在一些类的实现当中,也会涉及到基类方法的调用,比如Dictionary,HashTable需要调用对象的GetHashCode计算哈希码,如果没有重写GetHashCode就会造成装箱,解决办法是重写GetHashCode方法和Equals方法。

public struct Vector2
{
    private readonly int _x;
    private readonly int _y;

    public Vector2(int x,int y)
    {
        _x = x;
        _y = y;
    }

    //重写基类的ToString方法,并且不能调用base.ToString
    public override string ToString()
    {
        return string.Format("({0},{1})",_x.ToString(),_y.ToString());
    }
}

public void TestBaseCall()
{
    Dictionary<Vector2,object> testMap = new Dictionary<Vector2,object>();

    //由于向Dictionary添加元素时需要调用GetHashCode,而且Vector2类型没有重写GetHashCode
    //所以会造成装箱。
    testMap.Add(new Vector2(1,1),new object());
}

至此,已将将常见的装箱情况总结完了,在平时编码过程中,如果对一些代码是否会发生装箱、拆箱不太确定,可以使用反编译工具,查看IL编码来查看是否包含box语句来判断,这里推荐使用开源工具ILSpy进行查看。

猜你喜欢

转载自blog.csdn.net/salvare/article/details/79935578