问C#面试题,装箱拆箱这个点几乎没缺席过。面试者一般都能说出“值类型转引用类型是装箱,反过来是拆箱”,但一追问“为什么影响性能、影响多大、怎么避免”,很多人就开始绕圈子了。其实面试官真正想听的不是定义,而是你能不能把这背后的内存分配过程、GC压力和实际编码场景串起来。这篇文章按我平时面试别人和写代码时沉淀的思路,把装箱拆箱从头到尾拆一遍,顺带把那些隐蔽的性能坑也翻出来,希望能帮到准备面试的人,也帮到正在写上位机、数据采集、日志这类高频率小对象场景的.NET开发者。
1. 先搞清楚装箱到底“装”了什么
1.1 值类型和引用类型,不是“栈和堆”一句话能概括的
要说装箱,第一步得先理解:int、double、struct这类值类型,和object、string、数组这类引用类型,在内存模型里的本质差异是什么。
很多人喜欢背“值类型在栈上、引用类型在堆上”,这是一句非常粗糙的口诀。准确点说,值类型变量保存的是“实际的数据本身”,而引用类型变量保存的是“指向堆上对象的地址”。值类型并不是永远都在栈上,比如一个class里有个int字段,这个int就随着class对象一起躺在GC堆里。但不管它存在哪里,int这块内存里存的就是4字节的数字,没有额外的对象头,也没有方法表指针。
而object变量代表的是一个“托管对象引用”,它要求被引用的对象必须是一个完整的、运行时可识别的对象。普通值类型变量本身不具备对象的形态,你不能指着栈上的4个字节说这是一个object。这就是装箱出现的原因:当CLR需要把一个值类型当成对象来用时,它得先造出一个真正的对象出来,把值“装”进去。
装箱发生在两种情况:
- 值类型被隐式或显式转换成object。
- 值类型被转换成了它所实现的某个接口类型,因为接口引用本质上也是引用类型。
csharp复制int number = 42;
object boxed = number; // 装箱
IComparable comparable = number; // 也装箱
第二行代码是很多人忽略的。int实现了IComparable,但你在代码里把int赋给一个IComparable变量,会对那个int做一次装箱,因为IComparable变量最终指向的必须是一个托管对象。这个C#编译器和JIT怎么处理,后面的章节会细说。
1.2 box指令背后,藏着多少次内存动作
我们可以从IL层面看装箱到底发生了什么。下面这段C#:
csharp复制int number = 42;
object boxed = number;
int restored = (int)boxed;
大概会编译成这样的IL,我简化过:
il复制ldc.i4.s 42
stloc.0
ldloc.0
box [System.Runtime]System.Int32
stloc.1
ldloc.1
unbox.any [System.Runtime]System.Int32
stloc.2
中间关键指令就是box。这条指令做的事情,表面上只是一行类型转换,实际上至少包含这么几步:
- 从GC堆上分配一块内存。
- 这块内存不仅仅要容纳int的4个字节,还要带对象头,包括同步块索引(sync block index)和方法表指针,最后还要做对齐。
- 把栈上的number值拷贝到堆上新分配的对象字段里。
- 返回这个对象的引用。
如果你去
