Chapter 6:内存分配(Memory Allocation)

对齐原书第 6 章:沿对象大小计算、SOH 分配上下文、LOH/POH 路由与慢路径,分析对象/数组、装箱、闭包、委托、async/iterator 和字符串产生的显式与隐式分配。

学习目标

  • 能描述普通小对象从大小计算、区域路由、分配上下文检查、指针推进、清零到构造函数的快/慢路径
  • 能分析对象、数组、装箱、闭包、委托、async/iterator 与字符串语法的候选分配,并用 CIL 和分配调用栈验证
  • 能根据分配频率、单次字节和存活率设计优化,比较消除、复用、池化、Span/stackalloc 与 API 改造的收益和所有权风险

为什么“分配很快”不等于“分配免费”

.NET 的普通小对象分配常比通用本机分配器简单:线程在自己的可用范围检查剩余空间,推进一个指针,初始化对象并返回引用。但这条快路径仍会写对象头与字段、占用缓存和内存带宽,并把未来扫描、复制、晋升或清扫工作交给 GC。每次很便宜的操作乘以每秒百万次,仍会成为系统预算。

分配成本要分成两部分:当前线程创建对象的即时成本,以及对象在后续生命周期中给 GC 带来的延迟成本。短命对象主要增加分配流量和年轻代频率;高存活对象还增加晋升、老年代扫描与完整回收规模。

newobj 先取得存储,再执行构造函数

JIT 已知类型的方法表和实例大小后,可以选择专用 helper 或内联快路径。若对象适合 SOH 且当前 context 有空间,运行时保留字节、推进指针、确保字段初始为零并写入类型身份;随后才调用实例构造函数。构造函数负责业务初始化,不负责决定对象存储来自哪个 heap。

IL_0000: ldc.i4.7
IL_0001: newobj instance void Order::.ctor(int32)
IL_0006: stloc.0

对象太大、要求创建时固定、context 空间不足或分配预算到达时,会进入较慢路径:路由 LOH/POH、补充 context、扩展/提交 segment,必要时触发 GC 协调,再重试。快慢路径和 helper 名称属于运行时实现细节,应用应依靠分配事件而不是硬编码反汇编偏移。

数组分配同时计算长度、负载和区域

数组大小不是常量类型大小:运行时要校验长度和乘法溢出,计算 header + length × elementSize + alignment,再根据总大小路由。引用数组还需要让每个元素初始为 null;数值数组通常也保证零初始化。大数组直接进入 LOH,创建时 pinned 的数组可进入 POH。

Order[] references = new Order[10_000]; // 先得到 10,000 个 null 槽,不创建 Order
int[] values = new int[10_000];         // 元素零初始化
byte[] large = new byte[100_000];       // 总大小通常路由 LOH
byte[] pinned = GC.AllocateArray<byte>(4096, pinned: true); // POH

new Order[10000] 只创建一个引用数组,不会自动构造 10,000 个 Order。分析分配时必须区分数组对象、元素是否内联以及后续填充产生的实例。大数组被释放也不表示进程工作集立刻下降;segment 与空洞可能留给未来托管分配复用。

隐式分配来自 lowering,不只来自 new

值传给 object、非泛型 API 或某些接口调用时可能装箱。泛型受约束调用可以让 JIT 直接调用值类型实现,避免复制到对象。仅凭源码接口类型不能确定每条路径,检查 box/constrained. CIL 与实际分配栈更可靠。

捕获 lambda 常生成 display class 和委托;在循环里每次建立捕获可能导致高频分配。非捕获 lambda 或方法组可能被编译器缓存,但缓存策略随编译器版本和上下文变化。static lambda 能从语言层禁止意外捕获,是比“希望编译器缓存”更强的契约。

int limit = 10;
Predicate<int> captures = value => value > limit; // 捕获环境 + 委托候选
Predicate<int> staticRule = static value => value > 10; // 禁止捕获
 
object boxed = 42; // box Int32
int unboxed = (int)boxed;

async 与 iterator 也通过状态机 lowering。同步完成的 await 可能避开部分堆承载,真实挂起要让状态机、awaiter 和跨 await 局部继续存活;迭代器通常创建可枚举/枚举状态。优化必须按同步/挂起、枚举次数和具体返回 API 分开测。

先预测每个语法可能生成哪些对象,再切换图示。结论应写成“候选 + 证据”:例如捕获闭包通常产生环境对象,但若委托只建立一次并复用,热点未必来自它;普通 new 若每小时一次,也不值得复杂池化。

字符串成本来自不可变结果与构造过程

字符串不可变,修改语义通常需要新结果。循环中反复 result += part 可能多次复制已有内容,成本接近累计长度的重复搬运;StringBuilder 适合未知次数的渐进构造,string.Create 适合能预先计算最终长度并直接填充,Span 适合在已有缓冲区切片/解析。

插值并不总产生相同临时对象。现代 C# 可使用 interpolated string handler,让日志 API 在级别关闭时避免构造消息,或直接写入目标缓冲。是否生效取决于被调用 API 的签名,不能看到 $"..." 就统一宣布一次或多次分配。

static string JoinIds(ReadOnlySpan<int> ids)
{
    var builder = new StringBuilder(capacity: ids.Length * 8);
    for (int i = 0; i < ids.Length; i++)
    {
        if (i > 0) builder.Append(',');
        builder.Append(ids[i]);
    }
    return builder.ToString(); // 最终 String 仍是必要结果分配
}

这里 StringBuilder 减少中间 String,却不消除 builder 缓冲和最终结果。若调用者只需要写网络流,可以直接格式化到可复用缓冲;若必须返回 string,最终对象就是 API 契约的一部分,目标应是减少中间浪费而非追求虚假的零分配。

用“频率 × 字节 × 生命周期”排优化顺序

单次对象大小不能决定优先级。每秒 10 万个 48B 对象约等于 4.6 MB/s,而每分钟一个 10 MB 对象可能几乎不影响年轻代;但后者若长期保留,会扩大完整回收和工作集。优化前应同时按累计字节、对象数、分配调用栈与存活路径排序。

图中提高请求速率或单请求字节会直接放大 churn,提高存活率则把同样分配流量转成晋升与长期扫描成本。先预测哪个变量是主导项,再调整;不要只根据 new 数量决定改哪里。

池化不是免费回收:池本身会保留对象,提高工作集;租用方若忘记归还、重复归还、泄漏旧数据或跨异步边界错误共享,会引入正确性和安全问题。ArrayPool<T> 适合高频临时缓冲,但敏感内容可能需要清理,返回数组长度也可能大于请求。对轻量短命普通对象,GC 往往比复杂池更合适。

stackalloc 与 Span 能让小型、短寿命缓冲不进入托管堆,但栈空间有限,大小必须受控,且 ref-like 值不能任意跨 async/yield 或逃逸。把用户控制长度直接 stackalloc 会造成栈溢出风险。优化工具必须服从生命周期契约。

三步完成分配优化闭环

分步1 / 3

第一步:画出真实分配路径

从 newobj/newarr/box、编译器生成闭包与状态机到字符串 API,列出候选对象;再用分配事件按累计字节和调用栈确认热点。

小结

  • 小对象快路径通常依靠分配上下文和指针碰撞,但仍有清零、初始化、内存带宽与未来 GC 成本
  • newobj 先取得并初始化对象存储,再执行构造函数;context 耗尽、特殊路由或预算触发会进入慢路径
  • 数组总大小包含头部、元素和对齐;引用数组只创建 null 槽,不自动创建元素对象
  • 装箱、闭包、委托、async/iterator 与字符串都可能隐式分配,结论必须由 CIL 和分配调用栈验证
  • 优化优先级来自频率、字节与生命周期;池化、Span 和 stackalloc 都会引入新的所有权或容量约束

练习

  1. 问题 1:解释空构造函数。 new Empty() 的构造函数没有任何语句,为什么仍不能说它零成本?
  1. 问题 2:查闭包与装箱。 热循环同时包含捕获 lambda 和把 int 传给 object API,怎样确认各自产生多少分配并改造?
  1. 问题 3:评估池化。 ArrayPool<byte> 使分配速率下降,但工作集和数据泄露风险上升,应怎样验收?

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

指针碰撞分配
在线性区域推进指针以快速保留对象存储。
分配上下文
线程可局部推进分配指针的一段地址范围。
分配慢路径
补充上下文、处理特殊区域或协调 GC 的较慢分配路径。
托管数组
包含对象头、长度、连续元素与对齐的托管对象。
装箱
把值类型副本放入托管对象并通过对象引用访问。
闭包捕获
用编译器生成环境对象保存 lambda 所需外部变量。
字符串分配
构造新的不可变 String 结果及其可能中间状态。
对象池化
复用已分配对象或缓冲以减少重复创建的策略。

讨论

评论区加载中…