C#值类型装箱与拆箱

C#值类型装箱与拆箱

概述

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

装箱与拆箱的性能损耗

装箱流程:

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

拆箱流程:

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

装箱的产生

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

1. 值类型转换为object类型

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

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

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

1
2
3
4
5
int number = 10;

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
//随意定义
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方法,就不会发生装箱,下面通过代码来详细看下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
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,则不会造成装箱

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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进行查看。