装箱与拆箱
本教程共 100 篇 · 第 48 篇 · 更新于 2026-07-31 · 约 11 分钟阅读
48. 装箱与拆箱
本节目标:理解值类型转引用类型的开销,看清
object装箱的代价与替代方案。
什么是装箱与拆箱
值类型(如 int)存在栈上,引用类型存在堆上。object 是引用类型。当你把 int 塞进 object,运行时会把值复制到堆上、包成一个对象,这叫装箱(boxing)。反向取出来叫拆箱(unboxing)。
namespace CSharpDemo;
int number = 42;
object boxed = number; // 装箱:值被复制到堆
int unboxed = (int)boxed; // 拆箱:从堆取回值
Console.WriteLine(unboxed);
所有类型都源自 object(第 47 章),所以值类型也能赋值给 object 变量——代价就是这次装箱。理解它,是写出高性能 C# 的基础。
值类型在内存里的样子
值类型变量直接”装着”数据本身。赋值 int a = 1; int b = a; 会复制一份,a 和 b 互不影响。而装箱把这份数据”拎”到堆上,再让 object 变量指向它。一次装箱 = 一次堆分配 + 一次内存拷贝。
这也解释了为什么装箱后有”两份数据”:原栈上的副本和堆上的盒子。改其一,另一不变。
装箱的代价在哪
装箱不是”免费”的。它要分配堆内存、拷贝数据,之后还要等垃圾回收(GC)清理。在循环里大量装箱,会让性能明显下滑,也会给 GC 增加压力。
更隐蔽的坑是:装箱后改原值,盒里的副本不变。
namespace CSharpDemo;
int x = 1;
object o = x;
x = 2;
Console.WriteLine(o); // 仍是 1,盒中副本未变
Note拆箱必须显式转换,且类型必须精确匹配,否则会抛
InvalidCastException。把装箱的int强转成long都会失败。
Warning在性能敏感的热路径(如游戏每帧、接口每请求)里反复装箱,积少成多会拖垮吞吐。这种开销单看一次微不足道,放进百万次循环就暴露了。
什么时候会隐式装箱
不只是手动赋值。把值类型当 object 参数传入、放进非泛型集合、或拼接字符串时,都可能触发装箱。下面这行就暗藏一次装箱:
namespace CSharpDemo;
object o = 3.14; // double 被装箱
Console.WriteLine("值:" + o); // 又借 ToString 间接使用
很多”看起来无害”的写法,背后都有装箱。性能敏感处要留意隐式转换。
还有一类常见陷阱:params object[] 的方法,传入值类型时会整体装箱成对象数组。
namespace CSharpDemo;
void Log(params object[] items) => Console.WriteLine(string.Join(",", items));
Log(1, 2, 3); // 三个 int 都被装箱进 object[]
ArrayList 的反面教材
早期 ArrayList 存的是 object,往里加 int 就会逐个装箱。既慢又丢失了类型安全。
namespace CSharpDemo;
System.Collections.ArrayList list = new();
list.Add(1); // 每次 Add 都装箱
list.Add(2);
int first = (int)list[0]; // 拆箱
如今应使用泛型 List<int>,它在编译期锁定类型,完全避免装箱。
namespace CSharpDemo;
List<int> numbers = [1, 2, 3]; // 不装箱,类型安全
foreach (var n in numbers)
Console.WriteLine(n);
Tip经验法则:能用泛型集合(
List<T>、Dictionary<TKey,TValue>)就别用ArrayList、Hashtable。它们是为装箱时代留下的旧工具。
泛型如何彻底避开装箱
泛型(List<T>、Dictionary<K,V>)在编译期为每个具体类型生成专用版本。对 List<int>,内部直接存 int 数组,没有 object 中间层,自然不装箱。这是”泛型 vs 非泛型集合”性能差距的根本来源。
甚至方法也能泛型化:写过 T Max<T>(T a, T b) 后,值类型出入都不装箱。
还有 Span 这条捷径
若是处理一段内存里的数据,可用 Span<T> / ReadOnlySpan<T>。它们是栈上结构体,零拷贝地”看”向现有内存,既不涉及装箱,也不额外分配。在解析字符串、处理缓冲区时尤为高效。
装箱、拆箱与泛型的对照
| 场景 | 是否装箱 | 建议 |
|---|---|---|
object x = 1; | 是 | 避免,改用具体类型 |
List<int> = [1]; | 否 | 首选泛型集合 |
Log(1, 2) 接 params object[] | 是 | 高频处改泛型重载 |
Span<int> 看数组 | 否 | 内存操作首选 |
怎么发现隐藏的装箱
肉眼难辨的装箱,靠工具更靠谱。在 Visual Studio 里开启性能探查或留意编译器对 object/ArrayList 的诊断提示,往往能揪出藏得很深的装箱点。日常写代码时也有个简单心法:凡看到 object 类型、非泛型集合、params object[],就停下来问一句”这里是不是绕开了具体类型”。多数情况下,换用泛型或具体类型就能让它消失。
Tip一个零成本的实用习惯:把热点路径里的
ArrayList、Hashtable全部替换成List<T>、Dictionary<K,V>。这一改既消装箱又补回类型安全,几乎不费力气,是性价比最高的优化之一。
Warning别走向另一个极端:为”避免装箱”而滥用
struct。struct 越大,赋值时的整块拷贝成本越高;该用引用类型的地方强行用 struct,可能比一次装箱还慢。权衡”数据大小”与”拷贝开销”,才是正解,不要见装箱就躲。
新手常见坑
第一,误以为 object 万能就大量用。能不装箱就别装箱,泛型是更优解。
第二,拆箱类型写错。把装箱的 int 强转成 long 会直接抛异常,类型必须一致。
第三,在热路径(如每帧、每请求)里反复装箱,积少成多拖垮性能。用 Span<T> 或泛型避开。
第四,认为”struct 一定比 class 快”。struct 避免装箱的同时也可能因复制变大对象,需权衡,并非处处更快。
可空值类型与装箱
int? 这类可空值类型(nullable value type)同样会装箱。规则有点贴心:为 null 时装箱结果是 null,否则才把里面的 int 装箱。
namespace CSharpDemo;
int? maybe = null;
object boxed = maybe; // 装箱为 null
Console.WriteLine(boxed == null); // True
maybe = 5;
boxed = maybe; // 这次才真正装箱 int
Console.WriteLine((int)(int?)boxed); // 拆箱回 int? 再转 int:5
把 boxed 拆箱回 int? 时,null 也能安全还原,比裸 int 更稳。但装箱动作本身依旧发生,性能账照算。
记住一个判断口诀:看见 object、看见非泛型集合、看见 params object[],就要警觉背后可能在装箱。养成”能用具体类型/泛型就别用 object”的习惯,性能与类型安全双收。装箱本身不可怕,可怕的是在热路径里不知不觉地反复装箱,单看一次微不足道,放进循环就放大成瓶颈。
常见疑问解答
问:值类型一定能避免装箱吗?
不。值类型赋值给 object、传给 object 参数、进非泛型集合时仍会装箱。只有留在具体类型或泛型里才不装箱。
问:可空值类型 int? 装箱后是什么?
int? 装箱时,若为 null 得到 null,否则把里面的 int 装箱。拆箱回 int? 时,null 也能还原,这点比裸 int 安全。
问:拆箱失败会怎样?
拆箱要求类型精确匹配,否则抛 InvalidCastException。把装箱的 int 强转 long 会直接报错,类型必须一致。
问:为什么 ArrayList 是反面教材?
它存 object,加 int 每次都装箱,既慢又丢类型安全。现代代码一律用 List<T> 替代。
问:Spanobject 或跨方法以 object 传递,仍躲不开装箱。它是性能利器,不是万能钥匙。
小结
装箱拆箱是”值类型与引用类型鸿沟”的代价:一次堆分配、一次拷贝、一次 GC。第 49 章的 record 则提供了一种更现代的值语义方案,许多场景能顺带避开装箱烦恼。