ZString-Unity的0GC字符串操作
Unity 性能优化:用 ZString 实现真正的 0GC 字符串操作
你是否在 Unity Profiler 里看到过一帧里几百个
GC.Alloc,追查下去全是字符串拼接?本文从 Unity 的字符串内存模型出发,深入讲解 ZString 的零分配原理,并与传统StringBuilder做全面对比。
一、Unity 的字符串是什么
在深入 ZString 之前,必须先搞清楚 Unity(以及 C#)里字符串的本质。
string 是引用类型,活在托管堆上
C# 的 string 是引用类型,这意味着每一个字符串实例都分配在 托管堆(Managed Heap)。堆上的对象由 GC(Garbage Collector)负责管理和回收。
1 | string a = "Hello"; // 堆上的对象 |
每一次拼接都会产生一个新的 string 对象,旧的那些没有引用的对象就成了垃圾,等待 GC 回收。
string 是不可变的
C# 的 string 是 不可变(immutable)。一旦创建,内容永远不能改变。看起来像”修改”的操作,实际上都是在产生新对象:
1 | string s = "Hello"; |
这个特性决定了简单的字符串拼接在循环里会产生大量垃圾对象。
Unity 的 GC 是 Stop-the-World
这是问题的核心所在。Unity 默认使用的是 Boehm GC,它在执行垃圾回收时会暂停所有线程(Stop-the-World),直到回收完成才恢复运行。
对于游戏而言,这意味着:
- 目标帧率 60FPS,每帧预算约 16.6ms
- 一次 GC 回收可能耗时 1~5ms 甚至更长
- 帧内字符串垃圾积累越多,GC 触发越频繁,卡顿越明显
Unity 2019 之后引入了增量式 GC(Incremental GC),可以把回收工作分散到多帧,但根本问题没有解决——产生的垃圾越少,GC 压力就越低,这是无法绕过的物理规律。
一个典型的性能陷阱
1 | // Update() 每帧调用 60 次/秒 |
一个简单的 UI 更新,每帧产生多个垃圾对象,每秒数百个,长期运行积累下来 GC 就会频繁触发。这还只是一处代码。
二、传统解法:StringBuilder
StringBuilder 是 .NET 提供的官方解法,通过内部维护一个可变的字符数组,避免每次拼接都产生新的 string 对象。
StringBuilder 的内存模型
1 | StringBuilder 对象(class,堆上) |
StringBuilder 本身是 class,它的实例在堆上。内部使用链表结构存储多个 char[] chunk,每次扩容时分配新的 chunk。
StringBuilder 的实际分配情况
1 | var sb = new StringBuilder(); // 1次堆分配:StringBuilder 对象本身 |
StringBuilder 确实比直接 + 拼接好,但仍然有以下问题:
StringBuilder对象本身在堆上,每次new StringBuilder()都是一次分配- 数值类型
.Append(int)内部调用.ToString(),产生临时 string ToString()需要合并所有 chunk,如果有多个 chunk 还有额外开销- 扩容时旧 chunk 最终也要被 GC 回收
StringBuilder 的复用方案
为了规避反复创建的问题,常见做法是把 StringBuilder 作为成员变量复用:
1 | public class UIManager : MonoBehaviour |
这个方案减少了 StringBuilder 对象本身的分配,但数值 .ToString() 和最终的 sb.ToString() 仍然存在。而且 StringBuilder 作为成员变量会长期占用托管堆内存,影响 GC 的扫描成本。
三、ZString 的零分配原理
ZString 是 Cysharp 开发的开源库,核心思想是:把字符串构建的全过程限制在池化内存上,彻底降低 GC 压力。
Utf16ValueStringBuilder 是什么
ZString 的核心类型是 Utf16ValueStringBuilder,它是一个普通的 struct(值类型),而不是 class:
1 | public partial struct Utf16ValueStringBuilder |
作为 struct,当你用 var sb = ZString.CreateStringBuilder() 创建它时,sb 这个变量本身存在于调用方的栈帧上,不需要堆分配。而它持有的 buffer 则来自池化内存。
核心一:两种缓冲区模式
ZString 提供两种创建方式,对应不同的缓冲区来源:
默认模式(notNested: false)— 从 ArrayPool 租借
1 | // 默认模式,适合大多数场景,支持嵌套使用 |
创建时立即从 ArrayPool<char>.Shared 租借一块 64K 的字符数组。这块数组是预先分配好的,借出和归还不产生新的堆对象。Dispose() 时将其归还:
1 | public void Dispose() |
notNested: true 模式 — 使用 ThreadStatic 缓冲区
1 | // 使用线程静态缓冲区,更快,但不能嵌套 |
内部使用 [ThreadStatic] 静态字段持有一个缓冲区,每个线程第一次使用时分配,后续完全复用,连 ArrayPool 的借还开销都省掉了。但同一线程上同时只能有一个这样的 builder 存在,嵌套使用会抛异常:
1 | using var sb1 = ZString.CreateStringBuilder(notNested: true); |
核心二:数值类型直接写入缓冲区,无临时 string
这是 ZString 和 StringBuilder 最关键的差异。
StringBuilder.Append(int) 内部会调用 int.ToString() 产生一个临时 string,再把内容复制进缓冲区。
ZString 使用自己的格式化委托和 Span<char>,让数值类型直接把内容格式化写入缓冲区,完全跳过中间 string:
1 | // StringBuilder 的路径(有临时 string 分配) |
默认支持直写(无临时分配)的类型包括:SByte、Int16、Int32、Int64、Byte、UInt16、UInt32、UInt64、Single、Double、TimeSpan、DateTime、DateTimeOffset、Decimal、Guid、String、Char。
对于未在列表中的自定义类型,ZString 会退化调用 .ToString(),但你可以注册自定义格式化委托:
1 | // 注册自定义类型的格式化,避免 .ToString() 分配 |
核心三:string.Create — 最终输出只分配一次
ToString() 那一次堆分配是不可避免的,string 本身是托管堆上的引用类型。但 ZString 用 string.Create 把这次分配的代价降到理论最低:
1 | public override string ToString() |
普通 StringBuilder.ToString() 需要先 new string,再把 chunk 内容逐一复制进去,多 chunk 时还要合并。ZString 是单块连续内存,string.Create 一次完成分配和写入,没有中间步骤。
核心四:struct 的陷阱——必须用 ref 传递
因为 Utf16ValueStringBuilder 是 struct(值类型),直接作为参数传给方法时会复制整个 struct,导致子方法操作的是副本,写入内容不会反映到原始变量上:
1 | // 错误:值传递,AppendHeader 拿到的是 sb 的副本 |
这是 ZString 官方文档特别强调的陷阱:mutable struct 通过方法传递时必须用 ref。
四、ZString vs StringBuilder 全面对比
内存模型对比
1 | StringBuilder: |
功能特性对比
| 特性 | StringBuilder | ZString(默认) | ZString(notNested) |
|---|---|---|---|
| 类型 | class,堆上 |
struct,栈上 |
struct,栈上 |
| 创建开销 | 一次堆分配 | ArrayPool 租借(无新分配) | ThreadStatic 复用(极快) |
| 数值 Append | 内部调 .ToString() |
直写 Span,零临时对象 | 同左 |
| 扩容 | 产生垃圾 chunk | ArrayPool 借还,零垃圾 | 同左 |
ToString() |
合并多个 chunk | string.Create 一步完成 |
同左 |
| 做类字段 | ✓ | ✓(注意 struct 复制问题) | ✓ |
| 必须 Dispose | 否 | 是(否则 64K 不归还) | 是 |
| 跨方法传递 | 直接传(引用语义) | 必须用 ref | 必须用 ref |
| 嵌套使用 | ✓ | ✓ | ✗ |
AsSpan() |
✗ | ✓ | ✓ |
| GC 压力 | 中 | 极低 | 极低 |
性能数据参考
拼接 “HP: 99 / 100” 这类典型 UI 字符串,BenchmarkDotNet 的测试结果大致如下:
| 方式 | 耗时 | 堆分配 |
|---|---|---|
string + |
~350ns | ~200B |
string.Format |
~280ns | ~160B |
new StringBuilder() |
~180ns | ~128B |
复用的 StringBuilder |
~120ns | ~48B(仅结果 string) |
ZString.Format |
~85ns | ~48B(仅结果 string) |
ZString 手动 Append |
~70ns | ~48B(仅结果 string) |
ZString 和复用 StringBuilder 的最终堆分配量相同(都只有最终那个 string),但 ZString 快了约 40%,原因是省掉了数值 .ToString() 的临时对象和多 chunk 合并的开销。
五、在 Unity 中使用 ZString
安装
通过 UPM 安装(推荐),在 Packages/manifest.json 中添加:
1 | { |
指定版本:
1 | https://github.com/Cysharp/ZString.git?path=src/ZString.Unity/Assets/Scripts/ZString#2.4.0 |
基础用法:替代帧循环里的字符串拼接
1 | using Cysharp.Text; |
TextMeshPro:连 ToString 都省掉
ZString 为 Unity 的 TextMeshPro 提供了扩展方法,可以直接把内部缓冲区传给 TMP,连最终那次 string 分配都省掉,实现真正的完全零分配:
1 | using Cysharp.Text; |
StringBuilder 风格:复杂拼接逻辑
1 | // 注意:必须用 using,否则 64K 的 ArrayPool 缓冲区不会归还 |
跨方法传递:必须用 ref + try/finally
1 | void BuildBattleLog(BattleResult result) |
notNested 模式:帧循环的最优选择
如果你的构建逻辑简单且不会嵌套,notNested: true 是性能最好的选项:
1 | void Update() |
与异步代码配合
ZString 的 struct 特性在异步代码里需要注意。跨越 await 时 struct 可能被复制,推荐在进入 await 之前就完成构建:
1 | // 推荐:构建和异步操作分离 |
六、ZString 的使用限制和注意事项
1. 必须用 using 或手动 Dispose(最重要)
默认模式下创建时立即租借了 64K 的 ArrayPool 缓冲区,不 Dispose 就不会归还,导致内存泄漏:
1 | // 危险:抛异常时 Dispose 不会被调用,64K 数组永久泄漏 |
2. 跨方法必须用 ref,否则发生值复制
struct 默认值传递,子方法拿到的是副本,写入不会影响原始变量,是最容易踩的坑。
3. notNested 模式不能嵌套
同一线程上同时只能有一个 notNested: true 的 builder,且不能与 ZString.Concat/Join/Format 同时使用,因为后者也消耗同一块 ThreadStatic 缓冲区。
4. 自定义类型需要注册格式化委托
未注册的类型会退化调用 .ToString(),这次分配是无法避免的。
5. 装箱陷阱
Utf16ValueStringBuilder 实现了 IBufferWriter<char> 接口,把 struct 赋给接口变量会发生装箱(堆分配):
1 | using var sb = ZString.CreateStringBuilder(); |
七、什么时候用,什么时候不用
应该用 ZString 的场景:
- 帧循环里的 UI 文字更新(
Update()、LateUpdate()) - 使用 TextMeshPro 且追求完全零分配(
SetTextFormat/SetText) - 高频日志输出
- 网络消息/协议拼包
- 大量数值数据的序列化构建
不需要用 ZString 的场景:
- 初始化逻辑(
Start()、Awake()),只跑一次 - 用户事件响应(按钮点击),频率很低
- 包含复杂异步逻辑的字符串构建
判断原则:先用 Unity Profiler 测量,看 GC Alloc 是否真的是瓶颈,再优化,不要过早引入复杂度。
八、总结
| string + | StringBuilder | ZString(默认) | ZString(notNested) | |
|---|---|---|---|---|
| builder 本身 | 无 | 堆上 class | 栈上 struct | 栈上 struct |
| 初始缓冲区 | 无 | 堆上 char[] | ArrayPool 租借 64K | ThreadStatic 复用 |
| 数值 Append | 产生临时 string | 产生临时 string | 直写缓冲区,零分配 | 直写缓冲区,零分配 |
| 扩容 | 每次产生垃圾 | 产生垃圾 chunk | ArrayPool 借还 | ArrayPool 借还 |
| 最终 ToString | 一次分配 | 一次分配+合并 | string.Create,一步到位 | 同左 |
| 必须 Dispose | 否 | 否 | 是 | 是 |
| 跨方法传递 | 直接传 | 直接传 | 必须 ref | 必须 ref |
| 支持嵌套 | — | — | ✓ | ✗ |
| GC 压力 | 高 | 中 | 极低 | 极低 |
ZString 真正的价值有两点:一是消除数值 Append 产生的临时 string,二是在 Unity TextMeshPro 场景下连最终 string 都省掉(SetText / SetTextFormat),实现完全零分配。
两条使用规范:using 确保归还缓冲区、ref 确保跨方法写入正确。
