1.装箱
为了将一个值类型转换成一个引用类型,要使用一个名为装箱(Boxing)的机制。
1.在托管堆中分配好内存。分配的内存量是值类型的各个字段需要的内存量加上托管堆的所有对象都有的两个额外成员(类型对象指针和同步块索引)需要的内存量。
2.值类型的字段复制到新分配的堆内存。
3.返回对象的地址。现在,这个地址是对一个对象的引用,值类型现在是一个引用类型。
2.拆箱
包含在已装箱对象中的所有字段都必须复制到值类型变量中,后者在线程栈上。CLR分两步完成这个复制操作。
第一步是获取已装箱的对象中的各个字段的地址。这个过程称为拆箱(Unboxing)。
第二步是将这些字段包含的值从堆中复制到基于栈的值类型实例中。
拆箱不是直接将装箱过程倒过来。拆箱的代价比装箱低得多。拆箱其实就是获取一个指针的过程,该指针指向包含在一个对象中的原始值类型(数据字段)。事实上,指针指向的是已装箱实例中的未装箱部分。所以,和装箱不同,拆箱不要求在内存中复制任何字节。知道这个重要的区别之后,还应知道的一个重点在于,问问会紧接着拆箱操作发生一次字段的复制操作。
显然,装箱和拆箱/复制操作会对应用程序的速度和内存消耗产生不利影响,所以应该注意编译器在什么时候生产代码来自动这些操作,并尝试手动编写代码,尽量避免自动生成代码的情况。
疑问:
大家如果装Resharper,每当我们把一个值类型和一个字符串拼接的话,他都会提示我们,无需把值类型ToString,如下图:
Resharper推荐使用第一种方法,那到底会不会有问题呢?
举个例子:
public static void Main()
{
Int32 v = ; //创建一个未装箱的值类型变量
Object o = v; //o引用一个已装箱的、包含值5的Int32
v = ; //将未装箱的值修改成123 Console.WriteLine(v + ", " + (Int32) o); //显示"123, 5"
}
大家猜猜这段代码发生了多少次装箱操作?如果说是3次,会不会觉得意外?
让我们仔细分析一下代码,理解具体发生的事情。
第一次装箱操作是第四行,把未装箱值类型实例(v)复给引用类型。
第二次和第三次其实就是字符串的拼接,WriteLine要求获取一个String对象,所以就必须采取某种方式对这些数据项进行合并,以创建一个String。
为了创建一个String,C#编译器生成代码来调用String对象的静态方法Concat。该方法有几个重载的版本,所有版本执行的操作都是一样的,唯一区别是参数数量,此次编译器选择的是Concat方法的下面这个版本:
public static String Concat(Object arg0, Object arg1, Object arg2);
也就是说第二次装箱是将未装箱值类型v变成Object类型,第三次是o强转成Int32,拆箱后再次装箱,并将新的已装箱实例的内存地址传递给Concat的arg2参数。Concat方法调用指定的每个对象的ToString方法, 并连接每个对象的字符串表示。从Concat返回的String对象随即传给WriteLine方法,以显示最终的结果。
应该指出的是,如果想下面这样写对WriteLine的调用,生成的IL代码将具有更高的执行效率:
Console.WriteLine(v + ", " + o); //显示“123, 5”
这和前面的版本几乎完全一致,只是移除了变量o之前的(Int32)强制转型,避免了一次拆箱和一次装箱,减少了额外对象的产生和相应的垃圾回收,将具有更高的效率。
解答:
通过ILdasm工具,我们来看一下,到底发生了什么:
这下大家明白了吧,它在IL处理的时候,还是偷偷进行装箱,所以事实证明,这个ToString方法不能省!所以有些时候也不能完全按照第三方插件的提示。
推荐的方法:
Console.WriteLine(v.ToString() + ", " + o); //显示“123, 5”
现在会为未装箱的值类型实例v调用ToString方法,它返回一个String。String对象已经是引用类型,所以能直接传给Concat方法,不需要任何装箱操作。
PS:
当然Console.WriteLine方法默认有多个针对值类型的重载,目的是为了减少装箱的次数。
但是这些方法不可能为所有种情况都考虑到,就如自定义的值类型或者如上面的例子等。建议大家在自己写代码是手动进行值类型的装箱操作,显示指明类型,避免CLR生成低效率的IL代码。