一句话说清:装箱 = 把一个本来住在栈上的值,搬进堆上一个新建的"盒子"里。盒子是引用类型,所以它必须由 GC 回收。你没写
new,但堆上确实多了一个对象。
一、30 秒理解装箱
inthp=100;// 栈上 4 字节,函数返回自动消失,GC 完全不知道它存在objecto=hp;// ★ 装箱:堆上诞生一个 24 字节的对象,GC 从此开始盯着它intback=(int)o;// 拆箱:把值拷回栈上(不分配,但有类型检查)为什么必须搬家?因为object、IComparable、IEnumerator<T>这些引用类型/接口,存的是地址。而int是一段裸数据,没有类型指针、没有对象头,谁都不知道它是什么。要让它能被当成"对象"来用,就必须给它套一个标准对象的外壳。
比喻:栈上的
int是散装的螺丝,随手扔在工作台上,下班一扫就没了。装箱是"给这颗螺丝配一个带标签、带条码的收纳盒,登记入库"。盒子本身不贵,贵的是仓库要定期停工盘点——那就是 GC。
二、全过程七步分解
以object o = hp;为例,拆到指令和内存级别。
第 1 步:编译期 —— 编译器插入box指令
IL_0000: ldloc.0 // 把 hp 的值压栈 IL_0001: box [mscorlib]System.Int32 // ★ 就是这一条 IL_0006: stloc.1关键认知:装箱不是"运行时才决定的意外",它在编译期就被固化进 IL了。这意味着它是可静态检测的——后面第七节会用到这一点。
第 2 步:运行时调用装箱函数
box指令在 Unity 里落到:
- Mono:
mono_value_box(domain, klass, value) - IL2CPP:生成的 C++ 里是
Box(Int32_il2cpp_TypeInfo_var, &L_0),内部走il2cpp::vm::Object::Box
打开 iOS 构建产物Classes/Native/Assembly-CSharp0.cpp,搜Box(—— 你能逐条数出自己代码里有多少次装箱。这是最直观的自检方式。
第 3 步:向 GC 申请内存
装箱函数第一件事是Object::New(klass, size),向托管堆要一块内存:
- 在free list(空闲块链表)里找一块 ≥ size 的空间;
- 找到 → 切出来,返回指针;
- 找不到 →触发一次 GC;
- GC 后还是不够 → 向操作系统
mmap一段新的 heap section,堆水位线上升,且永不下降。
第 3 和第 4 步是整件事的核心风险点:任何一次装箱都可能是那根压垮骆驼的稻草。GC 不是"定时"发生的,是"分配失败时"发生的。所以你无法预测它,只能通过降低分配速率来推迟它。
第 4 步:写对象头
新分配的内存不是纯数据,前面有一段管理区:
| 区域 | x64 大小 | 内容 |
|---|---|---|
| 同步块索引 / monitor 指针 | 8 B | 支持lock、GetHashCode缓存 |
| 类型指针(MethodTable / Il2CppClass*) | 8 B | 告诉运行时"我是 Int32" |
| 数据区 | 4 B | 真正的100 |
| 对齐填充 | 4 B | 补到 8 字节边界 |
| 合计 | 24 B | 装 4 字节的数据,用了 24 字节 |
装一个int的空间放大倍数是 6 倍。这是装箱最反直觉的地方——不是"多用了一点",是"六倍"。
常见类型的装箱成本(x64,近似):
| 被装箱的值 | 数据大小 | 堆上占用 |
|---|---|---|
bool/byte/enum | 1~4 B | 24 B |
int/float | 4 B | 24 B |
long/double | 8 B | 24 B |
Vector3 | 12 B | 32 B |
Vector4/Quaternion | 16 B | 32 B |
HitInfo(40 B 自定义 struct) | 40 B | 56 B |
List<T>.Enumerator | 24 B | 40 B |
第 5 步:值拷贝
memcpy(boxed + headerSize, &value, size)。注意这是拷贝语义,由此产生一个经典陷阱:
inti=1;objecto=i;// o 里是 1 的一份拷贝i=99;Debug.Log(o);// 输出 1,不是 99装箱后的盒子和原变量从此毫无关系。这个坑在"把 struct 存进List<object>然后修改"的场景里会造成难以定位的逻辑 bug。
第 6 步:返回引用,值获得"身份"
栈上的o现在持有一个堆地址。从这一刻起:
- 它成为GC root可达图的一部分;
- 每次 GC 都必须扫描到它、判断它是否存活;
- 它参与堆的碎片化。
第 7 步:变成垃圾 → 被回收
o出作用域后,盒子不可达,但内存不会立刻释放。它要等到下一次 GC:
Unity 的 GC(Boehm-Demers-Weiser)工作流程:
① Stop The World —— 挂起所有托管线程(帧,停住) ② 扫描根集合 —— 线程栈、CPU 寄存器、静态字段、原生代码持有的句柄 ③ Mark —— 从根出发遍历整个引用图,标记所有可达对象 ④ Sweep —— 遍历整个堆,把未标记的块串回 free list ⑤ Resume —— 恢复执行三个致命特性,决定了 Unity 里装箱比 .NET 服务端更疼:
| 特性 | 后果 |
|---|---|
| 非分代(No generations) | 没有便宜的 Gen0。.NET CoreCLR 回收 Gen0 是微秒级;Boehm每次都要扫全堆。堆 200MB 就扫 200MB。 |
| 非压缩(Non-compacting) | 回收后不整理内存,只把空洞挂进 free list。碎片化会让后续分配越来越慢,并推高堆水位。 |
| 堆不归还 OS | 峰值决定终值。一次战斗把堆撑到 400MB,之后就一直是 400MB,GC 也就一直扫 400MB。 |
增量 GC(Incremental GC)能把一次 15ms 的停顿切成多帧的小片,但总工作量一分不少,而且写屏障本身有额外开销。它是止痛药,不是解药。
Unity 官方文档《Understanding the managed heap》和《Optimizing garbage collection》给出的第一原则始终是同一句:不要在每帧运行的代码里分配托管内存。
三、装箱的 12 个伪装身份
装箱最难防的地方是:绝大多数情况下你看不到(object)这个强转。它藏在语法糖和 API 签名里。
| # | 写法 | 为什么装箱 |
|---|---|---|
| 1 | object o = 1; | 显式,唯一一眼能看出来的 |
| 2 | string.Format("{0} dmg", 25) | 参数是object,25被装箱 |
| 3 | Debug.Log("hp:" + hp) | string + int→Concat(object, object),装箱 |
| 4 | $"hp:{hp}" | Unity 里通常仍降级为string.Format→ 装箱 |
| 5 | Foo(params object[] args) | 装箱 + 额外分配一个数组 |
| 6 | struct作Dictionarykey,且未实现IEquatable<T> | 回落到ValueType.Equals(object),每次查找装箱 2~3 次 |
| 7 | myEnum.HasFlag(Flags.X) | Unity Mono 下装箱两次 |
| 8 | IComparable c = myStruct; | struct → 接口 |
| 9 | foreach一个IEnumerable<T>声明的集合 | struct 枚举器被装箱(约 40 B) |
| 10 | yield return 0; | int→object,协程每次都装 |
| 11 | ArrayList/Hashtable | 非泛型集合,一切进出都装箱 |
| 12 | EventBus.Fire(id, object payload) | 自研事件系统最常见的漏点 |
特别提醒 #6,它是"CPU 和 GC 双杀":ValueType.Equals(object)在无法走快速路径时会用反射逐字段比较,比手写Equals慢一到两个数量级,同时还装箱。这是最值得优先修的一类。
四、射击游戏实战案例
案例 1:伤害数字 —— 一颗子弹 3 次装箱
背景:移动端 4v4 FPS。QA 报告"霰弹枪打人堆会顿"。
// DamageNumberUI.csvoidShow(floatdmg,boolcrit){_text.text=string.Format("{0}{1}",(int)dmg,crit?"!":"");// ★_shadow.text=_text.text;}账单拆解:
| 分配项 | 大小 |
|---|---|
(int)dmg装箱 | 24 B |
crit ? "!" : ""已是 string,不装箱 | 0 |
string.Format内部object[2]数组 | 40 B |
结果字符串"137!" | ~40 B |
StringBuilder内部缓冲 | ~80 B |
| 单次合计 | ~184 B |
放大链条——这是关键,单次 184 B 无人在意,是乘法要命:
霰弹枪 12 弹丸 × 射速 10 发/秒 × 场上 4 人开火 =480 次/秒
480 × 184 B ≈88 KB/秒
而实测 Profiler 显示~340 KB/秒,因为同样的string.Format模式还出现在:击杀播报、连杀提示、弹药计数(每帧刷新!)、护甲值、距离显示。
Boehm GC 在该项目 180MB 堆规模下,单次回收16~22ms。60fps 预算 16.6ms →必然掉帧,且精确掉在交火最激烈的那一秒。
这是 GC 问题最恶劣的性质:分配速率与战斗强度正相关,它专挑你最需要性能的时刻罢工。
修复(三档,按投入排序):
// 档 1:预生成字符串表 —— 伤害值是有限整数,直接查表,零分配staticreadonlystring[]Dmg=CreateTable(0,999);// 启动时生成一次_text.text=Dmg[Mathf.Clamp((int)dmg,0,999)];// 档 2:StringBuilder.Append(int) —— 直接写数字字符,不走 object_sb.Clear();_sb.Append((int)dmg);if(crit)_sb.Append('!');_text.SetText(_sb);// TMP 支持直接吃 StringBuilder,不产生 string// 档 3:引入 ZString 等零分配格式化库_text.SetText(ZString.Concat((int)dmg,crit?"!":""));另外一个常被忽略的点:弹药计数这类 UI,只在值变化时刷新,不要放在Update里无条件赋值。这一条往往比换库收益更大。
结果:该模块 GC Alloc 从 340 KB/s 降到 0,战斗期 GC 间隔从 2.4s 拉到 45s+,尖刺消失。
案例 2:空间哈希格的 struct key —— 每帧一百万字节
背景:PvE 模式,60 个 AI,每个 AI 每帧做视野查询。
publicstructCellCoord{publicintX,Y,Z;}// ★ 没实现 IEquatable<T>Dictionary<CellCoord,List<Actor>>_grid;// AI 每帧查询 3×3×3 邻域foreach(varcinNeighbors27(myCell))if(_grid.TryGetValue(c,outvarbucket)){...}为什么装箱:Dictionary<TKey,TValue>用EqualityComparer<CellCoord>.Default。由于CellCoord没实现IEquatable<CellCoord>,它回落到ObjectEqualityComparer<T>,内部调用ValueType.GetHashCode()和ValueType.Equals(object):
GetHashCode()→ 装箱 1 次(32 B)- 每次哈希冲突比较
Equals(object other)→参数本身就是 object,调用方必须装箱(32 B),且内部走反射逐字段比对
账单:60 AI × 27 格 × 30fps =48,600 次查询/秒
每次约 64 B(平均 2 次装箱)→≈ 3.1 MB/秒
比 GC 更糟的是 CPU:反射比较让TryGetValue从 ~20ns 变成 ~600ns,该系统 CPU 时间4.2ms/帧,吃掉 30fps 预算的 1/8。
修复(一个接口 + 一个 comparer):
publicstructCellCoord:IEquatable<CellCoord>{publicintX,Y,Z;publicboolEquals(CellCoordo)=>X==o.X&&Y==o.Y&&Z==o.Z;// 无装箱publicoverrideintGetHashCode()=>(X*73856093)^(Y*19349663)^(Z*83492791);publicoverrideboolEquals(objecto)=>oisCellCoordc&&Equals(c);// 兜底}更稳的做法:显式传入 comparer。因为在 IL2CPP/AOT 下,EqualityComparer<T>.Default的特化版本有时因泛型实例未被静态生成而回落到装箱版本:
sealedclassCellComparer:IEqualityComparer<CellCoord>{publicboolEquals(CellCoorda,CellCoordb)=>a.Equals(b);publicintGetHashCode(CellCoordc)=>c.GetHashCode();}_grid=newDictionary<CellCoord,List<Actor>>(1024,newCellComparer());终极方案:格子坐标打包成单个int(x | y<<10 | z<<20),用Dictionary<int, int>或直接开一维数组。零装箱、零哈希碰撞开销、内存连续。
结果:GC Alloc → 0,CPU 4.2ms → 0.35ms。注意:省下的 CPU 比省下的 GC 更多。
案例 3:协程里的yield return 0
IEnumeratorMuzzleFlash(){_flash.enabled=true;yieldreturn0;// ★ int → object,装箱 24 ByieldreturnnewWaitForSeconds(0.03f);// ★ 类实例,~40 B_flash.enabled=false;}voidFire(){StartCoroutine(MuzzleFlash());}// 每发子弹一次账单:枪口焰、命中闪光、弹壳抛出、后坐力恢复,四个协程 × 10 发/秒 × 4 人 = 160 次/秒
每次 ≈ 迭代器对象 64 B + 装箱 24 B +WaitForSeconds40 B ≈128 B
→20 KB/秒,量不大但持续且无必要。
修复:
// 1. yield return null 不装箱(null 本身就是引用)yieldreturnnull;// 2. 缓存 YieldInstruction,它们是无状态的,可全局复用staticreadonlyWaitForSecondsWait30ms=newWaitForSeconds(0.03f);yieldreturnWait30ms;// 3. 高频短逻辑不要用协程 —— 改成一个 struct 计时器数组,在系统里统一 tick通用原则:协程适合"低频、长流程"(关卡流程、剧情);不适合"高频、短特效"。后者用数据驱动的定时器池,零分配且可批量处理。
案例 4:服务端 ——objectpayload 的雪崩
背景:100 人大逃杀专用服务器,30Hz tick。
// 自研事件总线publicdelegatevoidHandler(objectpayload);EventBus.Fire(EventId.Damage,newDamageEvent{...});// ★ 48B struct → 64 B 装箱// 自研序列化器voidWrite(objectvalue){// ★ 每个字段都装箱switch(value){caseinti:...;casefloatf:...;}}foreach(varfieldinfields)Write(field);账单:100 玩家 × 30 tick × 平均 20 个字段/快照 =60,000 次装箱/秒
60,000 × 24 B ≈1.4 MB/秒/房间
一台机器 40 个房间 →57 MB/秒
服务端的痛点与客户端不同:它追求tick 时间的 p99。任何 GC 停顿都会表现为全房间玩家的回滚和瞬移。实测该服 p99 tick = 24ms(预算 33ms),GC 每 3.5 秒一次。
修复:
// 1. 事件总线泛型化 —— 每个事件类型一条独立的 Action<T> 链publicstaticclassEventBus<T>whereT:struct{staticAction<T>_handlers;publicstaticvoidFire(inTe)=>_handlers?.Invoke(e);// 零装箱}// 2. 序列化改为泛型 + 静态泛型缓存,或用 Source Generator 编译期生成 Write 代码staticvoidWrite<T>(refWriterw,inTv)whereT:struct,IPackable<T>=>v.Pack(refw);结果:p99 tick 24ms → 7ms,GC 从每 3.5 秒一次变为每 90 秒一次,单机承载房间数从 40 提到 65。
这个案例的通用意义:任何签名里出现
object的自研基础设施(事件、消息、序列化、日志、配置),都是装箱的批量生产车间。基础设施的一次装箱,会被业务代码放大成千上万倍。优化要从基础设施开始,不是从业务代码开始。
五、修复手册
| 手法 | 场景 | 说明 |
|---|---|---|
| 泛型替代 object | 事件/消息/序列化/容器 | 收益最大的一招,Action<T>/where T : struct |
实现IEquatable<T> | struct 作 key 或进List.Contains | 顺手 overrideGetHashCode |
显式传IEqualityComparer<T> | IL2CPP/AOT 平台 | 防止Default回落到装箱版本 |
in/ref传大 struct | 40 B 以上的值类型参数 | 避免拷贝,且不会诱发装箱 |
| 字符串:查表 / StringBuilder / ZString | UI、日志 | Append(int)直接写数字,不走object |
| 缓存装箱结果 | 必须调object老 API 时 | static readonly object True = true;、BoxedInts[0..255] |
位运算替代HasFlag | 标志枚举 | (f & X) != 0 |
| 具体类型替代接口声明 | 集合字段/属性/参数 | 顺带解决枚举器装箱 |
避免ArrayList/Hashtable | 老代码 | 全量替换为泛型集合 |
对象池 / 定长数组 /Span<T> | 高频临时数据 | 从"少分配"升级到"不分配" |
六、检测与固化
光靠 Review 一定会漏,因为装箱藏在语法糖里。必须工具化,三层设防:
1. 写代码时(最有效)
- Rider / ReSharper + Heap Allocations Viewer:会在
string.Format("{0}", hp)这一行直接标黄Boxing allocation: conversion from value type ‘int’ to reference type ‘object’
- Visual Studio + Clr Heap Allocation Analyzer:
HAA0601 Value type to reference type conversion causing boxing - 配合 Unity 官方
Microsoft.Unity.Analyzers,覆盖 Unity 特有规则
开发者在敲下那一行的瞬间就看到后果,比任何事后优化都便宜。
2. 定位问题时
- Unity Profiler:打开GC Alloc 列,Deep Profile,按分配量倒排。盯住"每帧非零"的行
- Memory Profiler:抓两个快照做 diff,看
System.Int32、System.Object之类的装箱产物类型实例数暴涨——这是装箱的指纹 - IL2CPP 产物:在
Classes/Native/*.cpp里grep -c "Box(",得到一个可对比的绝对数字
3. CI 卡回归(分水岭)
// Mono.Cecil 扫描 Assembly-CSharp.dll// 对标记了 [HotPath] 的方法,统计 OpCodes.Box 数量,>0 则构建失败配合Unity Performance Testing Package写分配断言:
[Test,Performance]publicvoidDamageNumber_ZeroAlloc(){Measure.Method(()=>_ui.Show(137f,true)).GC().MeasurementCount(200).Run();// CI 断言 GC.Alloc == 0}有了这一步,优化成果才不会在三个月后被一次"顺手加个日志"抹掉。
七、检查清单
理解
- 装箱是编译期就确定的
boxIL 指令 → 因此可被静态检测 - 空间放大约6 倍(4 B 的 int → 24 B 的堆对象)
- Unity 的 Boehm GC非分代、非压缩、堆不归还→ 每次回收扫全堆,峰值决定终值
- 代价是双份的:GC 停顿 + CPU(接口分派 / 反射比较)
排查
- 全局搜
string.Format、+字符串拼接、$"...",标记出每帧执行的 - 所有 struct 类型的
Dictionary/HashSetkey,检查是否实现IEquatable<T> - 所有签名含
object或params object[]的自研基础设施 - 所有
yield return 0/yield return 1 - 所有
HasFlag、ArrayList、Hashtable - 所有声明为
IEnumerable<T>/IList<T>的集合字段
修复优先级
- 基础设施泛型化(事件/序列化/日志)—— 一处修改,全局收益
- struct key 加
IEquatable<T>—— GC 与 CPU 双收 - UI 字符串零分配化—— 战斗期分配的大头
- 协程与临时对象池化
固化
- 团队全员开启分配分析器
- 关键系统写 GC 断言测试进 CI
- 编码规范明确:每帧代码 GC Alloc 必须为 0;例外需注释
// ALLOC-OK: 仅加载期
最后一句给做射击游戏的人:
FPS 的手感基础不是平均帧率,是帧时间的确定性。24 字节看起来微不可察,但它会被弹丸数 × 射速 × 玩家数 × 字段数层层相乘,最终在交火最激烈的那一秒,变成一次 20ms 的停顿,让玩家的爆头变成空枪。
装箱从来不是"某一行代码慢",而是"某一个 API 签名被调用了六万次"。修签名,比修代码有效一百倍。