装箱与反射的 GC 开销
篇章:08-核心技术-性能优化篇 |阅读时间:约 35 分钟 |前置知识:了解值类型与引用类型
一、引言
装箱和反射是 C# 中两类"隐形"的 GC 分配来源:开发者常常意识不到它们的存在,因为它们不显式使用 new 关键字。装箱在值类型转换为 object 时隐式发生,反射在运行时创建类型元数据时产生分配。这两件事的共同点是"代码里看着什么都没干,堆上却在持续出对象",也因此它们比字符串和 LINQ 更容易逃过代码评审。
在 Unity 项目里,这两类分配的杀伤力取决于位置。低频路径(启动初始化、配置读取、编辑器工具)里,装箱与反射的开销可以忽略不计,为了可读性牺牲一点性能完全划算;可一旦它们出现在 Update、碰撞回调、技能结算这类每秒执行几十次的代码里,几十字节一次的装箱乘以调用频率,再加上反射调用本身的参数数组与元数据开销,就是一条稳定的垃圾生产线。Boehm 不分代也不压缩(见 03 篇),这些小对象只会把堆越堆越碎。
本文第二节拆装箱的内存布局与完整成本,第三节给出隐式装箱的触发点清单,第四节逐个定位反射链路上的分配源,第六节给缓存与绑定方案,第七节专门算"装箱加反射"的叠加账,第八节是量级估算表。
二、装箱原理
2.1 对象布局:对象头加值,再对齐
装箱的定义只有一句话:在堆上分配一块"对象头 + 对齐后的值类型大小"的内存,把值复制进去,再把引用交给调用方。堆在 .NET 里只存引用类型,值类型要么活在栈上,要么活在别的对象的字段里,唯独不能独立存在于堆。所以当值类型被塞进一个只认引用的位置时,运行时唯一的办法就是给它临时造一个壳。
int value = 42; object obj = value; // 装箱! // 实际上在堆上分配了一个对象(12-20B),将 value 复制进去 // 常见的隐式装箱 string msg = string.Format("Value: {0}, Flag: {1}", 42, true); // string.Format 参数是 object[],42 和 true 都装箱 int? nullable = 42; if (nullable.HasValue) { /* Nullable<T> 本身是 struct,不装箱 */ } Enum flag = MyEnum.Value; // 枚举与 int 比较时不会装箱,但 Enum.HasFlag 会(内部装箱)逐段解读这段示意。第一段是显式装箱的最小例子:value是栈上 4 字节的 int,赋给object obj触发 box 指令,堆上多出一个对象,栈上多出一个引用。第二段是隐式装箱的典型现场:string.Format的第二个参数签名是object[],于是42和true各自被装进两个对象,再连同参数一起构成数组,一笔调用三次分配。第三段澄清Nullable<T>的边界:int?自身是结构体,HasChecked那条检查路径全程不装箱,只有把nullable赋给object、或传进 object 参数时才装箱,而且装箱后拿到的是"装了值的 int"或 null 两个分支之一,读取时还要拆箱。第四段是枚举:普通比较走值比较零分配,而HasFlag的公开签名按Enum(引用类型)接收两个参数,调用瞬间两边都装箱,细节见第三节第 6 条。
装箱的内存开销:值类型大小 + 对象头(8-16B)。一个 int 装箱需要 12B(32 位)或 20B(64 位)。补一句对齐口径:这两个数是"对象头加值"的逻辑大小,实际分配还要按分配粒度向上取整,64 位下 int 的装箱块常见落在 24B 量级(典型估算,Mono 与 IL2CPP 的分配粒度略有差异)。对象布局画成图:
未装箱(栈上 / 作为字段内联存储) 装箱后(堆上,一次完整分配) ┌───────────────────────┐ ┌──────────────────────────────────┐ │ int value = 42 │ │ 对象头:方法表指针 + 同步块索引 │ 16B(64位估算) │ 4 字节,随栈帧生灭 │ box ├──────────────────────────────────┤ └───────────────────────┘ ────► │ int 字段 = 42 │ 4B ├──────────────────────────────────┤ 拆箱(unbox)= 从该对象里把值拷回栈 │ 对齐填充 │ ~4B(凑到分配粒度) 值不匹配(如拆 double 装的箱) ├──────────────────────────────────┤ → InvalidCastException │ 合计 │ ~20~24B(典型估算) └──────────────────────────────────┘ 每装一次箱,就这么一小块垃圾(对象头按 64 位两字结构估算;32 位下头 8B,int 箱合计 12B 量级。全部为典型估算,非实测常数。)
2.2 装箱不只是"多一次分配"
把装箱成本拆开看,GC 分配只是账单第一行,后面还有三行。
第一行是分配本身:几十字节的短命对象,进老堆的时间取决于 GC 策略,Unity 的 Boehm 不分代,这些小对象和大对象一起躺在同一个堆里等待全量收集。因为 Boehm 不移动对象,装箱产生的碎片只能靠空闲链表复用,反复装箱反复回收会让堆里的小洞越来越多,小对象仍能塞进去,但堆的总体占用降不下来(碎片层面的推断,量级视堆规模而定)。
第二行是复制:box 时把值从栈拷进堆,unbox 时再拷回来。几字节的拷贝单次可忽略,但它意味着同一个值从此有了两份,读到哪份、哪份才是权威,全看调用链走没走完,这也是装箱代码难调试的根源之一。
第三行是调用路径变慢:值类型一旦进了 object 位置,后续的Equals、GetHashCode、ToString以及接口方法调用都要走运行时的分派逻辑。这行账主要是 CPU 而非 GC,但它与分配同源,修一个通常两个一起修。
2.3 与 Boehm 的交互
有一点必须写明,它决定了装箱在 Unity 里"贵"的形态:Boehm 不移动对象。分代压缩式 GC 里,大量短命小对象会被新生代快速收割,成本接近免费;Boehm 每一轮都是对整堆的标记清扫,装箱对象死得快不代表回收得便宜,它们只是在两次收集之间持续膨胀堆占用。换句话说,装箱在 Unity 里的代价比在服务端 .NET 里更偏"堆压力"一侧,这也解释了为什么本专栏把消除装箱列进核心优化篇而不是可选优化篇。
三、装箱的隐式触发
按"现象 → 分配在哪产生 → 量级 → 改法"四段式逐条过。除非特别注明,量级均为 64 位典型估算。
// 场景1: 值类型作为 object 参数 void Log(object msg) { Console.WriteLine(msg); } Log(42); // 装箱! 每次调用分配 12-20 bytes // 场景2: 值类型赋值给接口变量 int x = 5; IComparable c = x; // 装箱! // 场景3: enum 调用非泛型 Equals MyEnum flag = MyEnum.ValueA; bool eq = flag.Equals(MyEnum.ValueB); // 装箱! // 正确的做法: bool eq2 = flag == MyEnum.ValueB; // 不装箱 // 场景4: string.Format / Console.WriteLine 的 params object[] string msg = string.Format("Value: {0}", 42); // 42 装箱!3.1 场景 1:值类型进 object 参数
现象是调用点只传了个 int,看上去人畜无害。分配产生在参数绑定这一步:重载决议选中了Log(object),编译期插入 box 指令,堆上多一个对象。量级是每次调用一次装箱(int 约 20~24B,大结构体按"头加值"直接放大)。改法按优先级排:把 API 改成泛型void Log<T>(T msg),调用方传 int 时 T 就是 int,全程贴着值走;泛型改不动就提供void Log(int)之类的值类型重载,让重载决议不再落到 object;两者都改不动,才在调用侧想办法。Unity 项目里这条最常见的变体是Debug.Log(42),Debug.Log的签名是 object,每调一次装一次箱,热路径里要么提前拼成字符串,要么用条件编译把日志整个摘掉。
3.2 场景 2:值类型赋给接口变量
IComparable c = x;的装箱发生在赋值时,之后每次通过c调接口方法都是在已装箱的对象上分派。量级同样是一次装箱,之后每次调用还要付分派成本。改法是避免"把值类型放进接口变量"这个中间形态:直接调值类型上实现的方法(编译期绑定),或者用泛型约束,T : IComparable<T>配合 constrained 调用,JIT 与 IL2CPP 都能生成不装箱的直接路径。这条有个容易混淆的点:where T : IComparable的泛型方法接收 int 时不装箱,前提是方法体里对c的调用走的是受约束调用,这是泛型与接口配合的正确姿势,和场景 2 的"先赋值再调用"是两回事。
3.3 场景 3:值类型的非泛型 Equals 与 GetHashCode
flag.Equals(flag2)找到的是Enum继承来的object.Equals(object),参数是引用类型,于是两个枚举值在调用前各装一次箱。量级每次比较两笔装箱,比较器里循环调用就是乘上 N。改法分三档:枚举直接用==,一比值不装箱;自定义结构体实现IEquatable<T>并重写Equals(object)与GetHashCode,让泛型调用走强类型路径;容器与查找场景交给EqualityComparer<T>.Default,它对实现IEquatable<T>的类型走泛型不装箱的快速路径(这是本专栏的既定事实锚点,详细机制见 08-05 值类型篇)。第三档最值得强调:List<T>.Contains、Dictionary<TKey,TValue>的查找底层都经由EqualityComparer<T>.Default,结构体 key 实现没实现IEquatable<T>,直接决定查找热路径是零装箱还是每比一次装两次箱。
3.4 场景 4:Format、插值与 params object[]
现象是拼字符串顺手用了string.Format或$""插值。分配有三层:值类型装进 object[](每个值一次装箱),object[] 本身一次,结果字符串一次。量级按参数个数线性涨,一个{0}{1}的短格式化就是两次装箱加数组加字符串。改法:非热路径随便用;热路径用值类型专用 API(int.ToString的数字格式化路径、StringBuilder.Append(int)这类不经 object 的重载),或者上Span/缓冲区手写格式化,那套零分配方向在 08-07。插值字符串在只拼接对象时编译器会退化为string.Concat,省掉数组,但值类型参数落到 object 重载上仍然装箱,省数组不省箱。
3.5 场景 5:非泛型集合
现象是ArrayList、Hashtable、非泛型List、以及Dictionary<int, object>的 value 位装了个结构体。分配产生在 Add/索引赋值的那一刻,容器每收一个值类型就装一次箱,取出来还要拆。量级是 N 个元素 N 次装箱,容器遍历再 N 次拆箱。改法只有一条主线:换成泛型容器List<T>、Dictionary<TKey,TValue>,数据贴着值走;与 08-01 呼应的一点是,这些临时集合若还伴随 LINQ 终结操作,分配会再叠一层迭代器与结果容器,账在那边。
3.6 场景 6:Enum.HasFlag
[Flags] enum Permissions { Read = 1, Write = 2, Execute = 4 } Permissions p = Permissions.Read | Permissions.Write; // .NET Framework 中, HasFlag 内部装箱! p.HasFlag(Permissions.Read); // 装箱! Permissions -> Enum -> object // .NET Core 中已优化, 但 Unity 的 Mono 仍有此问题 // 替代方案: (p & Permissions.Read) == Permissions.Read; // 不装箱, 位运算逐段解读。HasFlag的公开签名是bool HasFlag(Enum flag),两个参数的静态类型都是引用类型,调用瞬间当前枚举值与参数各装一次箱;.NET Framework 与旧 Mono 的实现里这次调用还要进反射式的类型比较路径,CPU 与 GC 双重付账。注释里"Unity 的 Mono 仍有此问题"要说清楚适用范围:.NET Core 2.0 之后的运行时把 HasFlag 优化成了无装箱路径,但 Unity 自带的 Mono 与 IL2CPP 运行时并不等于那套实现,按"会装箱"来防御是工程上稳妥的默认值(若你的 Unity 版本实测无分配,以真机 Profiler 为准降级这条防御)。替代方案就是最后一段:位运算加比较,编译期就把语义定死为值操作,任何运行时都没有插手余地。顺带提醒,p.HasFlag出现在每帧判断里时,改与不改的差距是每帧两笔装箱对零,一行等价变换,收益直接。
3.7 表格速查
| 触发点 | 装箱时机 | 单次量级(估算) | 改法 |
|---|---|---|---|
| object 参数 / Debug.Log(值) | 参数绑定时 | 20~24B(int) | 泛型重载、值类型重载 |
| 值类型赋给接口变量 | 赋值时 | 20B 起,按结构体放大 | 泛型约束 + 受约束调用 |
| 非泛型 Equals(object) | 比较时,双边各一次 | 2 次装箱/比较 | ==、IEquatable<T> |
| GetHashCode 落到 ValueType | 字典查找时 | 每次哈希 1 次起 | 重写 GetHashCode + IEquatable |
| Format / 插值 / params | 参数组装时 | 每值 1 次 + 数组 | 值类型专用 Append、缓冲区格式化 |
| 非泛型集合存取 | 入库与出库各一次 | 每元素 1 进 1 出 | 换泛型容器 |
| Enum.HasFlag | 调用时双边 | 2 次装箱/次 | 位运算比较 |
| Nullable 赋给 object | 赋值时 | 1 次装箱 | 用 HasValue 判断,直接传T |
四、反射的分配
// typeof 和 GetType:不产生新分配(返回缓存的 Type 实例) Type t1 = typeof(int); Type t2 = 42.GetType(); // 以下操作产生分配: MethodInfo method = typeof(MyClass).GetMethod("DoSomething"); // MethodInfo 实例 PropertyInfo prop = typeof(MyClass).GetProperty("Name"); // PropertyInfo 实例 // Invoke 是分配重灾区: method.Invoke(obj, new object[] { arg1, arg2 }); // object[] + 参数装箱逐段解读这段代码,含两处需要补充的口径。前两行讲 Type 对象的来源:typeof(int)是编译期常量,运行时返回的是缓存好的 Type 实例,本身不再分配;42.GetType()返回的同样是缓存的运行时类型对象。但这里有个注释没展开的细节:对值类型调用GetType()意味着先要有一个能调 object 方法的引用,运行时会先对这个值装箱,再从装好的箱上取类型。所以准确说法是"Type 实例复用缓存",而"取类型这个动作对值类型是免费的"并不成立,一次调用仍然是一笔装箱。
4.1 成员获取:GetMethod 与 GetProperty
第三、四行是第一次真金白银的分配:按名字在类型上查找方法或属性,运行时要匹配名字、签名与 BindingFlags,返回的MethodInfo、PropertyInfo包装对象在多数实现里首取创建、之后有内部复用,但查找过程中的参数类型数组、匹配临时结构照常产生。量级上,把 GetMethod 放进每帧循环,就是每帧一次字符串查找加若干临时对象;低频路径则只有首次的一次性成本。改法从根上讲只有一句话:反射成员只获取一次,存进static readonly字段。C# 还有更进一步的技巧,利用泛型参数的静态字段按类型缓存,形态是static class Cache<T> { public static readonly MethodInfo M = typeof(T).GetMethod(...); },每个类型参数 T 独享一份静态字段,查找只在该类型首次触碰时发生一次,之后全走缓存。
4.2 Invoke:参数数组、装箱与异常包装
最后一行是反射调用的分配重灾区,账要一笔笔数:调用方先构造object[](一次数组分配),每个实参若是值类型各装一次箱;运行时进入调用路径后还要做参数类型检查与转换,转换不匹配再产生中间对象;返回值若是值类型,装箱后才交回给调用方;一旦目标方法抛异常,运行时会把它包成TargetInvocationException(一次异常对象分配,还遮住了原始堆栈,要靠内层 Exception 还原)。量级上,单次 Invoke 是"数组 + N 个箱子 + 可能的返回装箱",几十字节起步,大参数直接翻倍。改法的核心是把"每次调用都查一遍"变成"查一次绑定成能直接调的东西",也就是下一节的委托方案。
4.3 Attribute 获取与 Activator.CreateInstance
Attribute 是同一个病的另一处发作:GetCustomAttributes、GetCustomAttribute<T>每次都走元数据查找,返回的数组与每个 attribute 实例都是分配(attribute 对象还常带字符串字段,等于连带把字符串也造了)。属性可被反射的位置往往是 UI 数据绑定、序列化器、技能描述这类批量场景,一次遍历几十个成员,每个成员一次获取,量级就从"可忽略"变成"看得见"。改法一致:以类型为键缓存结果,启动期一次性收集,运行期只读缓存;或者用启动期扫描把 attribute 内容转成静态数据表。
Activator.CreateInstance(type)的账更直白:它本职就要创建目标对象(这笔无法消除,是业务分配),额外的是反射派发路径本身的开销与可能的参数包装。热路径需要"按类型造对象"时,正确姿势是缓存工厂委托,比如Func<T> factory = () => new T()或由Delegate.CreateDelegate绑定到构造路径,创建走直接调用,只保留对象本身那一笔分配,把反射的开销从每次调用挪到启动期一次。
4.4 分配源一览
| 反射操作 | 分配来源 | 出现频率敏感度 | 处理方式 |
|---|---|---|---|
typeof(T) | 无(常量 + 缓存实例) | 不敏感 | 直接用 |
值类型x.GetType() | 先装箱一次再取缓存 Type | 热路径敏感 | 改typeof(T)或泛型 |
Type.GetType("...")字符串查找 | 查找过程的临时对象 | 首次也应控制 | 启动期查一次并缓存 |
GetMethod/GetProperty | 成员包装与查找临时对象 | 极敏感 | static readonly缓存 |
method.Invoke(...) | 参数数组 + 值参数装箱 + 返回装箱 + 异常包装 | 极敏感 | 绑定为委托 |
GetCustomAttributes | 数组 + attribute 实例 + 字符串字段 | 批量时敏感 | 按类型缓存,启动期收集 |
Activator.CreateInstance | 目标对象(本职)+ 反射派发 | 中等 | 缓存工厂委托 |
五、热路径对照:坏代码 vs 好代码
// 1. 泛型替代 object void Log<T>(T value) where T : struct { // T 是值类型时不装箱 } Log(42); // 不装箱 // 2. 缓存反射成员 private static readonly MethodInfo _cachedMethod = typeof(MyClass).GetMethod("DoSomething", BindingFlags.Static | BindingFlags.Public); // 3. 委托替代 Invoke private static readonly Func<int, string> _formatter = FormatValue; // 委托调用比 Invoke 快 100 倍 // 4. 避免 enum 的 HasFlag // ❌ flag.HasFlag(MyEnum.Value) → 装箱 // ✅ (flag & MyEnum.Value) == MyEnum.Value → 不装箱逐段解读这四条。第一条把参数类型从 object 换成泛型T,调用点传 int 时装箱指令根本不会生成,约束struct同时挡住了"有人传引用类型进来"的退化路径;边界条件是方法体内不能把 value 再往 object 位置塞,否则前面省的又赔回去。第二条把成员获取提到字段初始化时机,static readonly保证只查一次,之后所有调用读的是同一个字段;注意BindingFlags要写全,不然运行期按不同标志再查一次会命中不了缓存,等于白缓存。第三条是把 Invoke 换成已绑定的委托:委托在字段初始化时绑定一次方法,调用时直接走委托路径,参数数组、类型检查、返回装箱全部消失,只剩业务本身的分配;委托调用相对 Invoke 的量级差异见第六节的口径说明。第四条是把 HasFlag 换成位运算,前面 3.6 已经拆过账。
再看一组完整方法级对照,覆盖"每帧反射 + 装箱"的真实形态:
// ❌ 每帧:字符串查找 + Invoke + 值参数装箱 + 参数数组 void Update() { var m = typeof(PlayerState).GetMethod("ApplyBuff"); m.Invoke(state, new object[] { buffId, 1.5f }); } // 分配账:GetMethod 临时对象、object[] 数组、buffId 装箱、1.5f 装箱、返回值若为值类型再装箱 // 每帧 60 次 ≈ 每秒上千字节垃圾(估算),叠加毫秒级反射开销 // ✅ 启动期绑定一次,热路径直接调用 static readonly Action<PlayerState, int, float> _apply = (Action<PlayerState, int, float>)Delegate.CreateDelegate( typeof(Action<PlayerState, int, float>), null, typeof(PlayerState).GetMethod("ApplyBuff", BindingFlags.Public | BindingFlags.Instance)); void Update() { _apply(state, buffId, 1.5f); } // 分配账:启动期一次性(MethodInfo 查找 + 委托构造),运行期零分配逐段解读坏代码。第一行的GetMethod每帧按字符串查一次方法,查不到还要拼签名,临时对象先记一笔;new object[]是每次调用一个新的数组;buffId(int)与1.5f(float)两个值类型参数落进 object 位各装一次箱;如果ApplyBuff返回值类型,Invoke 还要装一次才交出来。四笔分配乘以每秒 60 次,这条 Update 单独就能养活一条 GC 曲线,更别提反射分派本身还要吃 CPU。注释里的"上千字节/秒"是按每次 40~80B 量级的典型估算,不是实测值。
逐段解读好代码。Delegate.CreateDelegate在静态字段初始化时执行一次:查 MethodInfo(一次性)、构造委托(一次性)、两者此后常驻。热路径里_apply(state, buffId, 1.5f)是一次委托调用,参数按强类型传值,没有数组、没有装箱、没有类型检查,运行期分配严格为零。边界条件有两个:目标方法签名必须与委托类型完全匹配,不匹配在启动期就抛异常,这是好事,错误被赶到初始化阶段而不是战斗中;CreateDelegate传 null 作为 target 表示绑定静态方法,绑定实例方法时要传实例或用返回实例委托的重载,写错同样在启动期暴露。若项目连启动时间都敏感,可以把这段绑定放进加载流程的空闲段,逻辑不变。
六、缓存与绑定:把反射成本挪到启动期
先保留本章的方案对照表,再补口径:
| 方案 | GC | 速度 | 说明 |
|---|---|---|---|
| MethodInfo.Invoke | 高 | 慢(100x) | 不推荐在热路径 |
| Delegate.CreateDelegate | 低 | 快(5x) | 一次缓存反复使用 |
| 泛型/接口 | 零 | 最快 | 编译期确定 |
| Expression Tree 编译 | 一次 | 快(2x) | 构建时分配运行时零 |
口径说明:表里的 100x、5x、2x 是量级估算与常见经验区间,用于表达方案之间的相对次序,不是本专栏任何环境下的实测常数;实际比值随参数个数、运行时实现、AOT 编译差异浮动,方向不会变。"Invoke 高"的 GC 一列,指的是每次调用的数组与装箱开销,参数全为引用类型且数组复用时会显著下降,但类型检查与分派的 CPU 成本仍在。
方案选型按三步走。第一步,能不用反射就不用:业务逻辑直接方法调用,这是"泛型/接口 零"那行的含义,编译期把一切定死,运行时无事发生。第二步,非用反射不可时把"查找"变成"绑定":Delegate.CreateDelegate把 MethodInfo 变成可直接调用的委托,存进static readonly字段,查找与绑定各发生一次;进一步地,用泛型静态类按类型参数缓存,多个类型共享一套缓存代码却不共享数据,形态与 4.1 里 MethodInfo 的泛型缓存完全一样。第三步,绑定也满足不了时才上表达式树:启动期用Expression拼装、编译成委托,构建阶段的节点分配与编译开销是一次性的,运行期回到委托调用的零分配,这正是表里"构建时分配运行时零"的意思。
再往上还有一个方向:source generator(源生成器)。它把"运行时查元数据"提前到"编译期读语法树",直接生成绑定代码,运行时既没有 GetMethod 也没有 Invoke,AOT 场景下还顺带绕开了 IL2CPP 对动态代码的限制。它需要在工程里配置生成器与约定,属于项目级决策,本篇只标注方向:凡是每帧都要走的反射逻辑,长期方案都是生成期解决,缓存只是过渡。三种时机的对比:
反射直调: 每次调用都执行 [查找][参数数组][装箱][分派][返回装箱] ← 分配与耗时随次数线性涨 委托绑定: 启动期一次 [查找][构造委托] 运行期每帧 [直接调用] ← 运行期零分配 源码生成: 编译期一次 [生成绑定代码] 运行期每帧 [普通方法调用] ← 连元数据查找都不存在(图中每格为成本类别示意,不代表具体毫秒数。)
七、装箱与反射叠加的放大效应
单看装箱是几十字节,单看反射是几次查找,两个叠在一起不是相加而是相乘。原因在于反射 API 的参数位几乎都是 object,值类型一进反射调用就被迫装箱;而反射调用又天然出现在"批量处理"场景里,一次遍历调用 N 个成员,装箱次数按 N 与参数个数双重放大。
反射遍历 1000 个 struct 元素,逐个写入属性(示意流程): foreach (var item in items) // items: MyStruct[1000] prop.SetValue(item, getter.Invoke(...)); │ │ ▼ ▼ 每轮:SetValue 的 object 参数位装箱 1 次 getter.Invoke:object[] + 返回值装箱 + 内部参数检查开销 + 数组复用则只剩装箱 单轮估算 40~80B × 1000 轮 ≈ 40~80KB 垃圾(示例量级,视结构体大小浮动) 叠加:反射分派本身让这 1000 轮比直接循环慢一个量级(量级估算)这张图想说明的乘法关系有三层。第一层,装箱次数由反射 API 签名决定,SetValue(object, object)这种签名保证了每个值参数至少一箱,参数个数翻倍箱子翻倍。第二层,批量场景把单次开销乘以 N,1000 个元素的批量反射在加载期还能忍,在每帧结算里就是每帧几十 KB 的垃圾。第三层,反射的 CPU 分派与装箱的内存分配同源同路径,优化时往往是绑定成委托后两项一起消失,这就是为什么第六节的方案表把"绑定"当作主线。
对应的改造顺序也很固定:先判断这段逻辑是不是每帧执行;不是就留在原地别动,可读性优先;是就先做委托绑定把反射拆掉,装箱随参数变强类型一起消失;还嫌不够再回到泛型与手写循环,这一步已经接近 08-05 值类型篇的地盘。别跳步,低频路径上为零分配而重构,属于拿可读性换不存在的收益。
八、量级估算表
全部数字为典型估算或示例量级,用途是给"值不值得治"排序,任何一条都不要当作实测结论引用。对象头按 64 位两字结构 16B 计,分配粒度按 8B 对齐。
| 分配来源 | 单次量级(典型估算) | 常见频率 | 处理后 |
|---|---|---|---|
| int 装箱 | 2024B | 每次 object 参数调用 | 泛型化后 0 |
| double / decimal 装箱 | 2432B | 同上 | 泛型化后 0 |
| 小结构体装箱(16~32B 值) | 4048B | 同上 | 泛型化后 0 |
| params object[] 数组 | ~40B 起 + 每值一箱 | 每次 Format/插值 | 专用 Append 后 0 |
GetMethod一次性查找 | 首次若干临时对象 | 仅未缓存时反复发生 | static readonly后一次 |
Invoke参数数组 | ~40B 起 | 每次反射调用 | 绑定委托后 0 |
| Invoke 值参数/返回装箱 | 每参数 20B 起 | 每次反射调用 | 委托强类型后 0 |
| Attribute 批量获取 | 数组 + 每实例几十 B | 批量遍历成员时 | 按类型缓存后一次 |
Activator.CreateInstance | 目标对象 + 派发开销 | 按需创建 | 工厂委托后仅剩对象本身 |
| HasFlag | 2 次装箱/次 | 每次标志判断 | 位运算后 0 |
读表的三个要点。第一,"单次量级"列全部几十字节以内,真正决定要不要治的是"常见频率"列:每帧执行的行,单次 24B 就是每秒 1.4KB 起步,一分钟接近 90KB 的纯垃圾(按 60fps 估算);每关卡执行一次的行,同样 24B 完全无所谓。第二,反射类条目的关键在"未缓存时反复发生"这个措辞:同一类型反复 GetMethod 的开销本可摊到一次,写进循环等于把启动期该做的事按帧付费。第三,最后一列说明所有方案的终点一致:让热路径的运行期分配归零,把成本一次性搬到编译期或启动期。
自查工具链给两条。其一,Profiler 里开 GC Alloc,按采样树找box、Invoke、GetMethods这类帧名,命中即源头;其二,工程侧可以直接搜代码,把Invoke(、GetMethod(、.HasFlag(、string.Format(四个模式列成评审清单,逐个问"这行每秒跑几次",答案决定改法优先级。
九、常见误区与反模式
误区一:"我的结构体才 8 字节,装箱无所谓。" 错在哪:8 字节的值装进堆要带 16B 对象头,实际落块 24B 上下,膨胀三倍;而真正的大头从来不是单次字节,是频率乘法,每帧十次就是每秒几 KB 的稳态垃圾,在不分代的 Boehm 堆上只进不出地累积。正确做法:结构体是否装箱与它的大小无关,只与调用位置有关,热路径一律按"会装箱"防御,用泛型与值类型重载堵住。
误区二:"typeof 和 GetMethod 都会返回缓存对象,所以反射查询是免费的。" 错在哪:Type 实例复用不等于查询过程免费,按名字匹配、签名比较、包装对象的内部维护都在花钱,更不用说值类型取 GetType 还要先装箱;缓存省下的是"重复创建元数据对象",省不下"每次都查一遍"。正确做法:查询只做一次,结果存字段,运行期只读字段;判断标准是"这段代码会不会在 Update 里跑"。
误区三:"用了泛型就一定不装箱。" 错在哪:泛型只在调用链全程贴着类型实参走时才不装箱,一旦中间出现 object 参数、接口变量位、或者Debug.Log这类 object 重载,箱照装不误;EqualityComparer<T>.Default也是同理,实现IEquatable<T>的类型走泛型不装箱,没实现的会退化到比较慢的对象比较路径。正确做法:泛型是手段不是护身符,顺着调用链看每个参数落进什么位置,落进 object 就找装箱点。
误区四:"HasFlag 在新 .NET 已经优化了,不用管。" 错在哪:优化属于 .NET Core 那套运行时,Unity 的 Mono 与 IL2CPP 不共享那条实现,照抄结论就会在真机上漏防。正确做法:Unity 项目默认按"会装箱"处理 HasFlag,位运算替代的成本是可读性微降,换来的是一条在任何运行时都成立的保证;真要确认,连真机 Profiler 看那一帧的 GC Alloc 即可。
十、与相关章节的对比
| 章节 | 负责的分配来源 | 与本文的界线 |
|---|---|---|
| 08-01 字符串 | 格式化产生的字符串本体 | Format 场景里"字符串"那笔归那边,"装箱"那几笔归本文 |
| 08-03 闭包与委托 | 闭包显示类、委托对象 | lambda 参数走 object 重载时的装箱归本文,闭包本体归那边 |
| 08-05 值类型 struct | struct 设计与容器存储 | 本文讲怎么不装箱,那边讲怎么设计得根本不需要装箱 |
| 08-07 Span 零分配 | 缓冲区与格式化 | 第 3.4 节的格式化终局方案在那边 |
| 08-08 Unity API 陷阱清单 | 引擎 API 的返回与参数分配 | Debug.Log(object)的装箱在本文 3.1 点名,清单归那边 |
本文在全专栏中的位置一句话:它接管"值类型与元数据在 API 边界上的隐式分配",向上接字符串与闭包两大来源,向下通向值类型设计与零分配格式化。
本章总结
- 装箱 = 在堆上分配"对象头 + 对齐后的值类型大小"并复制值;一个 int 装箱逻辑上 12B(32 位)或 20B(64 位),对齐后按 24B 量级估算。
- 隐式装箱的高发点:object 参数与
Debug.Log、接口变量位、非泛型Equals/GetHashCode、Format/插值/params object[]、非泛型集合、Enum.HasFlag、Nullable 赋给 object。 - 反射的分配不在
typeof,在按名查找、Invoke 的参数数组与装箱、Attribute 批量获取、异常包装;值类型调GetType还要先补一笔装箱。 - 处理主线一句话:查询一次、绑定成委托或泛型、缓存进静态字段,把成本从每帧挪到启动期一次;源码生成是更彻底的终局。
- 装箱与反射叠加是乘法:反射 API 的 object 位保证每个值参数一箱,批量场景再乘以 N,热路径上必须一起治。
一句话记忆点:热路径里凡是"看着没 new 却在涨 GC Alloc"的地方,先查装箱,再查反射,两条线顺着本文的表逐格对。
装箱和反射的 GC 成本在低频调用中可忽略,但在 Update 和热路径中必须消除。泛型替代装箱、缓存替代反射、位运算替代 Enum.HasFlag 是标准方案。