Span 能避免字符串切片时的堆分配,因其是栈上视图,不复制数据,仅记录起始地址和长度;而 string.Substring() 每次都新建字符串并触发堆分配。

Span 为什么能避免字符串切片时的堆分配
因为 string.Substring() 每次都 new 一个新字符串,走堆分配;而 Span<char></char> 是栈上视图,不复制数据,只记录起始地址和长度。它本质是“切片指针”,只要原字符串生命周期够长、没被 GC 回收,Span 就安全。
但注意:不能把 Span<char></char> 存到字段或跨 async 方法传递(会报 Cannot use local variable 'xxx' in an async method),它不是引用类型,也不支持装箱。
- 适用场景:解析日志行、HTTP header 拆分、CSV 字段提取等短时高频切片
- 不适用场景:需要长期持有切片结果、要序列化、要传给不接受
ReadOnlySpan<char></char>的老 API - 性能差异明显:10 万次切片,
Substring可能多出 2–3 MB 堆分配,GC 压力上升
用 AsSpan() + Slice() 替代 Substring 的写法
直接调 str.AsSpan().Slice(start, length),比 str.Substring(start, length) 更轻量。注意 Slice() 不做越界检查(Release 模式下),所以必须自己确保 start + length ,否则运行时抛 <code>System.IndexOutOfRangeException。
常见错误:把 length 当成 end 索引传进去 —— Slice(3, 5) 是从索引 3 开始取 5 个字符,不是取 [3..5)。
-
str.AsSpan().Slice(0, 3)→ 前三个字符 -
str.AsSpan()[3..]→ C# 8+ 支持范围语法,语义清晰,底层仍是Slice - 如果 start 或 length 可能越界,先用
Math.Min()截断,别依赖 try/catch —— 异常开销大
ReadOnlySpan 传参时要注意函数签名
很多基础方法已重载支持 ReadOnlySpan<char></char>,比如 int.TryParse(ReadOnlySpan<char>, out int)</char>、string.Equals(ReadOnlySpan<char>, string, StringComparison)</char>。但如果你自己写工具方法,务必把参数声明为 ReadOnlySpan<char></char> 而不是 string,否则一接参就隐式转成 string,前功尽弃。
典型坑:写了 void ParseHeader(ReadOnlySpan<char> s)</char>,结果里面又调了 s.ToString() —— 这句立刻触发堆分配,白用了 Span。
- 优先用
ReadOnlySpan<char></char>接收,用Span<char>.Trim()</char>、Span<char>.IndexOf()</char>等原生方法处理 - 避免中间转
string,尤其不要在循环里反复.ToString() - 第三方库若不支持
Span,可临时用stackalloc char[256]配合Encoding.UTF8.GetBytes()绕过,但需确认长度上限
字符串字面量和常量字符串的 Span 安全性
字符串字面量(如 "hello")和 const string 编译期确定,内存稳定,转 Span 完全安全。但变量字符串(string s = GetFromDb();)只要没被 GC 回收,其 Span 也有效——关键在作用域,不在是否 const。
真正危险的是:把局部字符串的 Span 存进静态字段,或者返回给调用方长期持有。编译器会直接报错 Cannot return a stack-allocated value,这是好事。
- 可以放心写
"user:name".AsSpan().Slice(6),无任何副作用 - 不要写
static ReadOnlySpan<char> Cache = "default".AsSpan()</char>—— 编译不过 - 异步方法里用
await后继续用之前创建的Span?不行。async state machine 会拆分栈帧,Span失效
事情说清了就结束。最常被忽略的是:以为用了 Span 就万事大吉,结果在某个不起眼的 .ToString() 或日志打印处又掉回堆分配。盯住每一处出参和日志点。











