值类型的装箱与拆箱浅析
- 前言
- 值类型的装箱
- 值类型的拆箱
- 装箱和拆箱实例
- 结束语
前言
在.Net 中值类型向引用类型的转换以及从引用类型到值类型的转换是需要装箱(boxing)和拆箱(unboxing)的,这是因为值类型是比引用类型更轻型的一种类型,因为他们不想对象那样在托管队中分配,不会被GC收集,而且不需要通过指针来引用。但是在许多情况下都需要获取对值类型的一个实例的引用。对于在值类型与引用类型之间的互相转换,我之前一直不慎理解,在看了一些书籍之后,在本文中记录一些心得,希望各位尽量拍砖,多多指出不正确的地方,大家共同交流,共同学习、进步。
值类型的装箱
首先来看一个例子:
struct Point
{
public int x,y;
}
public sealed class Program{
public static void Main(){
ArrayList a=new ArrayList();
Point p; //分配一个Point(值类型,在线程堆栈上分配)
for(int i=0;i<10;i++){
p.x=p.y=i; //初始化值类型中的成员
a.Add(p); //对值类型进行装箱,并将引用添加到ArrayList中
}
}
}
每次循环迭代时,都会初始化一个Point的值类型字段。然后这个Point会被添加到ArrayList中。如果知道大家熟悉ArrayList的话,一般都会清楚ArrayList 里面存储的是object类型,通过Reflector看一下ArrayList中Add方法所要求的参数:
public class ArrayList:IList,ICollection,IEnumerable,IConeable
{
…//省略其他代码
public virtual int Add(object value)
{
…
}
}
上面就是ArrayList 内Add方法的方法签名。由此可以知道Add方法是需要一个object类型的参数的。这表明Add接受对托管堆上的一个对象的引用来作为参数。但是在上面例子中,传递给Add方法的是一个Point类型,也就是一个值类型。很明显,照这样,是不会正确执行的,为使上述代码正确工作,Point值类型必须转换成一个真正的托管堆的对象,而且必须获取对这个转换后的对象的一个引用。为了将一个值类型转换成一个引用类型,可以使用一个名为“装箱”(boxing) 的机制。下面说一下对一个值类型的实例进行装箱操作时在内部发生的事情:
- 在托管堆中分配好内存。分配的内存变量是值类型的各个字段所需要的内存两加上托管对上的所有对象都有的两个额外成员(即类型对象指针和同步块索引) 所需要的内存量。(如果对类型对象指针和同步块索引不熟悉,可以看下我的另一篇文章:.Net运行时的相互关系)
- 值类型的字段复制到新分配的堆内存中。(原有的值类型字段不受影响,照样可以使用)
- 返回对象的地址,至此,这个地址是对刚才新建的对象的引用,值类型已经装箱为一个引用类型。
C#编译器会自动生成对一个值类型的实例进行装箱所需的IL代码,但理解内部发生的事情仍然很有必要,这对于代码的性能调优问题有很大帮助。
在上述代码中,C#编译器检测到是向一个需要引用类型的方法传递一个值类型,所以会自动生成代码来对对象进行装箱操作。这样一来,在运行时,当前存在于Point值类型实例中的字段会复制到新分配的Point对象中。已装箱的Point对象(变成引用类型了)的地址在返回之后,会传递给Add方法。Point对象会一直存在于堆中,直到被GC收集。Point值类型变量p可以重用,在这种情况下,已装箱值类型的生存期超过了未装箱的值类型的生存期。未装箱的值类型的生存期在所处代码段执行完毕之后,就会被删除,但是已装箱值类型还一直处于托管堆中。执行装箱操作如图1:
值类型的拆箱操作
在知道装箱操作具体如何进行之后,接下来谈谈“拆箱”(unboxing)操作。假定需要使用如下代码获取ArrayList的第一个元素:
Point p = (Point)a[0];
在上述代码中,获取ArrayList的第一个元素总包含的引用,并试图将其放到Point值类型的一个实例p中。为了能够正常工作,包含在已装箱的Point对象中的所有字段都必须复制到值类型变量p中,后者在线程的堆栈上。CLR按照两个步骤完成这个拆箱操作。
- 首先,获取已装箱的Point对象中各个Point字段(如本例的 x,y)的地址(在托管堆中的偏移地址)。
- 然后,这些字段包含的值从堆中复制到基于堆栈的值类型实例中。
拆箱并不是刚好将装箱操作逆转过来这么简单,相较于装箱操作,拆箱操作的代价要低得多,拆箱其实就是一个获取指针的过程。该指针指向包含在一个对象中的原始值类型(实例字段)。事实上,指针只想的是已装箱实例中的未装箱部分。所以,和装箱操作不同,拆箱不涉及任何字节在内存中的副本。在拆箱操作之后,通常会进行字段的复制操作。不过我们经常都要在一次拆箱之后,立即复制它的字段。图2:
很明显,装箱和拆箱操作会对应用程序的速度和内存消耗产生不利的影响,所以应该注意编译器在什么时候生成代码来自动这些操作,并尝试手工编写代码,尽可能减少自动生成的这种代码。
一个已装箱的值类型实例在拆箱时,应该注意以下几点:
- 包含对已装箱的值类型实例的引用如果为null,就会抛出一个NullReferenceException异常。
- 如果引用指向的对象不是所要求的值类型的一个已装箱实例,就会抛出一个InvalidCastException异常。
上述第二项意味着下面代码不会正常工作:
public static void Main()
{
int x=5;
Object o=x; //对x进行装箱,o引用已装箱的对象
Int16 y = (Int16)o; // 抛出一个InvalidCastException异常
}
从逻辑上来说,完全可以获取o所引用的一个已装箱的Int32,然后将其转型为一个Int6。然而,在对一个对象进行拆箱操作时,只能将其转型为未装箱时的值类型,本例即为Int32。下面是正确的写法:
public static void Main()
{
int x=5;
Object o=x; //对x进行装箱,o引用已装箱的对象
Int16 y = (Int16)(int)o; //先拆箱为正确的类型,再进行转型
}
前面说过,在一次拆箱操作之后,经常要紧接着执行一次字段复制。以下C#代码演示了拆箱和复制操作:
public static void Main()
{
Point p;
p.x=p.y=1;
object o=p; //对p进行装箱,o引用已装箱的实例
p = (Point)o; //对o进行拆箱,并将字段从已装箱的实例复制到堆栈变量中
}
最后一行,C#编译器会生成一条IL执行对o进行拆箱操作(获取已装箱实例中的各个字段的地址),并生成另一条IL指令将这些字段从对上复制到给予堆栈的变量中。
再来看看另一段代码:
public static void Main()
{
Point p;
p.x=p.y=1;
object o=p; //对p进行装箱,o引用已装箱的实例
p = (Point)o; //对o进行拆箱,并将字段从已装箱的实例复制到堆栈变量中
p.x=2; //更改堆栈变量的状态
o=p; //对p进行装箱;o引用一个新的已装箱实例
}
上述代码地步的一行唯一的目的就是将Point的x字段从1变成2。为此,首先要执行一次拆箱操作,然后执行一次字段赋值操作,接着更改堆栈上的字段x,最后执行一次装箱操作,(从而在托管对上创建一个全新的已装箱实例)。如图3:
装箱和拆箱实例
了解了装箱和拆箱的过程之后,再来看看几个装箱和拆箱的例子:
public static void Main()
{
int v=5; //创建一个未装箱的值类型变量
object o=v; //o引用一个已装箱的int,其中包含值5
v=123; //将未装箱的值改成123;
Console.WriteLint(v +”, ”+(int)o); //显示“123, 5”
}
大家知道,上述带按摩总共发生了多少次装箱操作,多少次拆箱操作码?
答案:装箱3次,拆箱1次。
对于这个答案是否觉得很意外呢? 下面我们仔细分析下代码,看看具体都发生了什么事情。用ILDasm工具看看所生成的IL代码是什么,就可以解释上面的答案了。IL代码如下:
.method private hidebysig static void Main(string[] args) cil managed
{
//入口点
.entrypoint
// 代码大小 53 (0x35)
.maxstack 3
//初始化两个变量,一个是int32类型,一个object类型的变量
.locals init ([0] int32 v,
[1] object o)
IL_0000: nop
//将5装入堆栈
IL_0001: ldc.i4.5
//把一个从堆栈中返回的值存入第0个变量(上面初始化的)
IL_0002: stloc.0
//把第0个变量转入堆栈
IL_0003: ldloc.0
//对v进行装箱操作
IL_0004: box [mscorlib]System.Int32
//保存装箱后的值,存入到第1个变量
IL_0009: stloc.1
//将123装入堆栈
IL_000a: ldc.i4.s 123
//将堆栈中的值存入第0个变量
IL_000c: stloc.0
//把第0个变量转入堆栈
IL_000d: ldloc.0
// 对于v进行装箱,并将指针保留在堆栈上已进行concat操作
IL_000e: box [mscorlib]System.Int32
//将字符串加载到堆栈上执行concat操作
IL_0013: ldstr ", "
//对o进行拆箱,获取一个指针,指向堆栈上的int字段
IL_0018: ldloc.1
IL_0019: unbox.any [mscorlib]System.Int32
//对int进行装箱,并将指针保留在堆栈以进行concat操作
IL_001e: box [mscorlib]System.Int32
//调用静态方法Concat
IL_0023: call string [mscorlib]System.String::Concat(object,
object,
object)
//将Concat连接好后的字符串传送给WriteLine方法
IL_0028: call void [mscorlib]System.Console::WriteLine(string)
IL_002d: nop
IL_002e: call int32 [mscorlib]System.Console::Read()
IL_0033: pop
//从Main返回,程序执行完毕
IL_0034: ret
} // end of method Program::Main
下面对以上代码进行说明:
首先在堆栈上创建一个Int32未装箱值类型的实例(v),并将其初始化为5。然后,创建一个Object类型的变量,o,并初始化它,让它指向v装箱后的值类型。C#编译器会自动生成正确的IL代码,如上面IL代码中的box操作。接着更改v的值,但这个操作不会影响已装箱的值类型,装箱后的值依然是5,而不会变成123。
接着要调用WriteLine方法,WriteLine 要求向其传递一个String对象。但是当前没有字符串对象。相反,当前有三个数据项可供使用:一个未装箱的Int32 值类型实例(v),一个String类型,以及一个已装箱Int32值类型实例的引用(o),他需要转型为一个未装箱的Int32。必须采取某种方式对这些数据项进行合并,以创建一个String。
为了创建一个String,C#编译器会生成代码来调用String对象的静态方法Concat。该方法有几个重载的版本,所有版本执行的操作都是一样的,唯一的区别在于参数数量。由于需要连接三个数据项来创建一个字符串,所以编译器选择的是Concat方法的下面这个版本:
public static String Concat(Object arg0, Object arg1, Object arg2);
参数一一对应:
arg0=v; //装箱v,并将地址给arg0;
arg1=”, ”; // “,” 本身就是一个String
arg2=(Int32)o; //先对o拆箱成Int32,再装箱成Object
Concat方法调用指定的每个对象的ToString方法,并连接每个对象的字符串表示。最后,传递给WriteLine方法,以显示最终的结果。
如果像下面这样写,会具有更快的执行效率
Console.WriteLint(v +”, ”+o); //显示“123, 5”
跟之前几乎一样,指示少了o的拆箱操作,以及拆箱完之后的装箱操作,这时,o已经是一个object引用类型,它的地址可以直接传给Concat方法。所以在移除了强制转型之后,就省掉了一次拆箱和一次装箱。
我们看下改写之后的IL:
.method private hidebysig static void Main(string[] args) cil managed
{
.entrypoint
// Code size 43 (0x2b)
.maxstack 3
.locals init ([0] int32 v,
[1] object o)
IL_0000: nop
IL_0001: ldc.i4.5
IL_0002: stloc.0
IL_0003: ldloc.0
IL_0004: box [mscorlib]System.Int32
IL_0009: stloc.1
IL_000a: ldc.i4.s 123
IL_000c: stloc.0
IL_000d: ldloc.0
IL_000e: box [mscorlib]System.Int32
IL_0013: ldstr ", "
IL_0018: ldloc.1
IL_0019: call string [mscorlib]System.String::Concat(object,
object,
object)
IL_001e: call void [mscorlib]System.Console::WriteLine(string)
IL_0023: nop
IL_0024: call int32 [mscorlib]System.Console::Read()
IL_0029: pop
IL_002a: ret
} // end of method Program::Main
对比下两段IL,效果很明显,代码大小从53减少到了43,少了一次拆箱和一次装箱,内存中还少了因为装箱而产生的一个对象。以上代码较少,运行起来不会明显感觉到变化,但是假如通过一个大得循环中,这样反复的操作,就会严重损耗性能。
还可以这样调用WriteLine,进一步提升代码的性能:
Console.WriteLint(v.ToString() +”, ”+o); //显示“123, 5”
先在v上调用ToString方法,它返回一个String,就可以直接传递给Concat,不需要任何装箱操作。
接下来再看看另一个例子:
public static void Main()
{
int v=5; //创建一个未装箱的值类型变量
object o=v; //o引用v的已装箱版本
v=123; //将未装箱的值类型改成123
Console.WriteLine(v); //显示“123”
v=(int)o; //拆箱并将o复制到v
Console.WriteLine(v); //显示”5”
}
同样的问题:上面的操作发生了多少次装箱,多少次拆箱呢?
答案: 1次装箱,1次拆箱
是不是又很意外呢?其实主要是因为WriteLine()有很多重载,如下:
public static void WriteLine(Boolean);
public static void WriteLine(Char);
public static void WriteLine(Char[]);
public static void WriteLine(Int32);
public static void WriteLine(UInt32);
public static void WriteLine(Int64);
public static void WriteLine(Single);
public static void WriteLine(Double);
public static void WriteLine(Decimal);
public static void WriteLine(Object);
public static void WriteLine(String);
在上述两个WriteLine调用中,变量是以传值方式传递给方法的。现在WriteLine方法也许会在内部对这个Int32进行装箱,但无法对此加以控制,但是,我们已经从自己的代码中消除饿了装箱操作。
在调用一个方法时,假如它没有为传给它的一种特定的值类型准备一个重载版本,那么最终肯定会调用一个接受object参数的重载版本,那么势必会产生装箱操作,从而影响性能。
关于装箱的最后一点注意事项:假如知道自己写的代码会造成编译器反复对一个值类型进行装箱,那么换用手动方式对值类型进行装箱,代码会变得更小、更快。如下例子:
public class Program
{
public static void Main(){
int v=5;
Console.WriteLine(“{0},{1},{2}”,v,v,v); //三次装箱操作
//Object o=v; //一次装箱
//Console.WriteLine(“{0},{1},{2}”,o,o,o);
}
}
未注释代码不止会装箱三次,而且会在堆上分配3个对象。如果执行注释行的代码,则只会产生一个对象,一次装箱。
只要想要对值类型的一个实例的引用,该实例就必须装箱。假如有一个值类型的实例,而且想把他传给需要引用类型的一个方法,就必须装箱。
值类型轻型的原因在于:
- 他们不在托管堆上分配
- 他们没有堆上的每个对象的额外成员