Unity 性能优化:用 ZString 实现真正的 0GC 字符串操作

你是否在 Unity Profiler 里看到过一帧里几百个 GC.Alloc,追查下去全是字符串拼接?本文从 Unity 的字符串内存模型出发,深入讲解 ZString 的零分配原理,并与传统 StringBuilder 做全面对比。


一、Unity 的字符串是什么

在深入 ZString 之前,必须先搞清楚 Unity(以及 C#)里字符串的本质。

string 是引用类型,活在托管堆上

C# 的 string 是引用类型,这意味着每一个字符串实例都分配在 托管堆(Managed Heap)。堆上的对象由 GC(Garbage Collector)负责管理和回收。

1
2
3
string a = "Hello";          // 堆上的对象
string b = "World"; // 又一个堆上的对象
string c = a + ", " + b; // 又一个堆上的对象,a 和 b 变成垃圾

每一次拼接都会产生一个新的 string 对象,旧的那些没有引用的对象就成了垃圾,等待 GC 回收。

string 是不可变的

C# 的 string不可变(immutable)。一旦创建,内容永远不能改变。看起来像”修改”的操作,实际上都是在产生新对象:

1
2
3
string s = "Hello";
s += " World"; // 并不是修改了 s,而是创建了新字符串,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
2
3
4
5
6
7
// Update() 每帧调用 60 次/秒
void Update()
{
// 每帧产生多个临时 string 对象
hpText.text = "HP: " + currentHp + " / " + maxHp;
// ↑字面量 ↑int.ToString() ↑int.ToString() ↑拼接结果
}

一个简单的 UI 更新,每帧产生多个垃圾对象,每秒数百个,长期运行积累下来 GC 就会频繁触发。这还只是一处代码。


二、传统解法:StringBuilder

StringBuilder 是 .NET 提供的官方解法,通过内部维护一个可变的字符数组,避免每次拼接都产生新的 string 对象。

StringBuilder 的内存模型

1
2
3
4
5
6
StringBuilder 对象(class,堆上)
├── _chunks: LinkedList<char[]> ← 每个 chunk 也在堆上
│ ├── chunk[0]: char[16] ← 初始 chunk
│ ├── chunk[1]: char[32] ← 扩容时新分配的 chunk
│ └── ...
└── _length: int

StringBuilder 本身是 class,它的实例在堆上。内部使用链表结构存储多个 char[] chunk,每次扩容时分配新的 chunk。

StringBuilder 的实际分配情况

1
2
3
4
5
6
var sb = new StringBuilder();    // 1次堆分配:StringBuilder 对象本身
sb.Append("HP: "); // 内部 char[] chunk 可能分配
sb.Append(currentHp); // currentHp 是 int,内部调用 .ToString() → 1次临时 string
sb.Append(" / ");
sb.Append(maxHp); // 同上 → 1次临时 string
string result = sb.ToString(); // 1次堆分配:最终的 string 对象

StringBuilder 确实比直接 + 拼接好,但仍然有以下问题:

  1. StringBuilder 对象本身在堆上,每次 new StringBuilder() 都是一次分配
  2. 数值类型 .Append(int) 内部调用 .ToString(),产生临时 string
  3. ToString() 需要合并所有 chunk,如果有多个 chunk 还有额外开销
  4. 扩容时旧 chunk 最终也要被 GC 回收

StringBuilder 的复用方案

为了规避反复创建的问题,常见做法是把 StringBuilder 作为成员变量复用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class UIManager : MonoBehaviour
{
private StringBuilder _sb = new StringBuilder(); // 只创建一次

void Update()
{
_sb.Clear();
_sb.Append("HP: ");
_sb.Append(currentHp); // 内部还是 .ToString(),还是有分配
_sb.Append(" / ");
_sb.Append(maxHp);
hpText.text = _sb.ToString(); // 还是有一次 string 分配
}
}

这个方案减少了 StringBuilder 对象本身的分配,但数值 .ToString() 和最终的 sb.ToString() 仍然存在。而且 StringBuilder 作为成员变量会长期占用托管堆内存,影响 GC 的扫描成本。


三、ZString 的零分配原理

ZString 是 Cysharp 开发的开源库,核心思想是:把字符串构建的全过程限制在池化内存上,彻底降低 GC 压力。

Utf16ValueStringBuilder 是什么

ZString 的核心类型是 Utf16ValueStringBuilder,它是一个普通的 struct(值类型),而不是 class

1
2
3
4
5
6
7
8
public partial struct Utf16ValueStringBuilder
: IDisposable, IBufferWriter<char>, IResettableBufferWriter<char>
{
char[] buffer; // 指向当前缓冲区(来自 ArrayPool 或 ThreadStatic)
int index; // 已写入的字符数
bool disposeImmediately; // 是否是 notNested 模式
// ...
}

作为 struct,当你用 var sb = ZString.CreateStringBuilder() 创建它时,sb 这个变量本身存在于调用方的栈帧上,不需要堆分配。而它持有的 buffer 则来自池化内存。

核心一:两种缓冲区模式

ZString 提供两种创建方式,对应不同的缓冲区来源:

默认模式(notNested: false)— 从 ArrayPool 租借

1
2
3
4
// 默认模式,适合大多数场景,支持嵌套使用
using var sb = ZString.CreateStringBuilder();
// 内部:ArrayPool<char>.Shared.Rent(DefaultBufferSize)
// DefaultBufferSize = 65536(64K 字符)

创建时立即从 ArrayPool<char>.Shared 租借一块 64K 的字符数组。这块数组是预先分配好的,借出和归还不产生新的堆对象。Dispose() 时将其归还:

1
2
3
4
5
6
7
8
public void Dispose()
{
if (!disposeImmediately)
{
ArrayPool<char>.Shared.Return(buffer); // 归还,不产生垃圾
}
// ...
}

notNested: true 模式 — 使用 ThreadStatic 缓冲区

1
2
// 使用线程静态缓冲区,更快,但不能嵌套
using var sb = ZString.CreateStringBuilder(notNested: true);

内部使用 [ThreadStatic] 静态字段持有一个缓冲区,每个线程第一次使用时分配,后续完全复用,连 ArrayPool 的借还开销都省掉了。但同一线程上同时只能有一个这样的 builder 存在,嵌套使用会抛异常:

1
2
3
4
5
6
using var sb1 = ZString.CreateStringBuilder(notNested: true);
sb1.Append("foo");

// 以下都会导致冲突,使用同一块 ThreadStatic 缓冲区
using var sb2 = ZString.CreateStringBuilder(notNested: true); // NG!
var str = ZString.Concat("x", 100); // NG! Concat 也用 ThreadStatic

核心二:数值类型直接写入缓冲区,无临时 string

这是 ZString 和 StringBuilder 最关键的差异。

StringBuilder.Append(int) 内部会调用 int.ToString() 产生一个临时 string,再把内容复制进缓冲区。

ZString 使用自己的格式化委托和 Span<char>,让数值类型直接把内容格式化写入缓冲区,完全跳过中间 string:

1
2
3
4
5
// StringBuilder 的路径(有临时 string 分配)
// int → .ToString() → 临时 string → 复制到 char[]

// ZString 的路径(零临时分配)
// int → 直接 TryFormat 写入 Span<char>,无中间对象

默认支持直写(无临时分配)的类型包括:SByteInt16Int32Int64ByteUInt16UInt32UInt64SingleDoubleTimeSpanDateTimeDateTimeOffsetDecimalGuidStringChar

对于未在列表中的自定义类型,ZString 会退化调用 .ToString(),但你可以注册自定义格式化委托:

1
2
3
4
5
6
7
// 注册自定义类型的格式化,避免 .ToString() 分配
Utf16ValueStringBuilder.RegisterTryFormat(
(MyStruct value, Span<char> destination, out int charsWritten, ReadOnlySpan<char> format) =>
{
// 直接写入 destination
// 返回 true 表示成功,charsWritten 是实际写入的字符数
});

核心三:string.Create — 最终输出只分配一次

ToString() 那一次堆分配是不可避免的,string 本身是托管堆上的引用类型。但 ZString 用 string.Create 把这次分配的代价降到理论最低:

1
2
3
4
5
6
7
8
9
public override string ToString()
{
// string.Create 在分配 string 的同时,
// 直接把内容写入其内部缓冲区,一步完成
return string.Create(index, this, static (dest, self) =>
{
self.buffer.AsSpan(0, self.index).CopyTo(dest);
});
}

普通 StringBuilder.ToString() 需要先 new string,再把 chunk 内容逐一复制进去,多 chunk 时还要合并。ZString 是单块连续内存,string.Create 一次完成分配和写入,没有中间步骤。

核心四:struct 的陷阱——必须用 ref 传递

因为 Utf16ValueStringBuilderstruct(值类型),直接作为参数传给方法时会复制整个 struct,导致子方法操作的是副本,写入内容不会反映到原始变量上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 错误:值传递,AppendHeader 拿到的是 sb 的副本
void Build()
{
using var sb = ZString.CreateStringBuilder();
AppendHeader(sb); // sb 被复制!
var result = sb.ToString(); // result 是空的,因为写入了副本
}

void AppendHeader(Utf16ValueStringBuilder sb) // 值传递,拿到副本
{
sb.Append("Header"); // 写入副本,外部 sb 不受影响
}

// 正确:ref 传递,操作同一个 struct
void Build()
{
// 注意:使用 ref 传递时不能用 using,需要手动 try/finally
var sb = ZString.CreateStringBuilder();
try
{
AppendHeader(ref sb);
var result = sb.ToString();
}
finally
{
sb.Dispose(); // 确保归还缓冲区
}
}

void AppendHeader(ref Utf16ValueStringBuilder sb)
{
sb.Append("Header"); // 写入原始 sb,正确
}

这是 ZString 官方文档特别强调的陷阱:mutable struct 通过方法传递时必须用 ref


四、ZString vs StringBuilder 全面对比

内存模型对比

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
StringBuilder:
new StringBuilder() → 堆上分配一个 class 对象
.Append("text") → 可能触发 char[] chunk 分配
.Append(42) → 调用 42.ToString() → 堆上临时 string
.ToString() → 堆上分配最终 string,合并所有 chunk

ZString(默认模式):
CreateStringBuilder() → struct 在栈上,立即从 ArrayPool 租借 64K 缓冲区
.Append("text") → 写入池化缓冲区
.Append(42) → 直接 TryFormat 写入,零临时对象
.ToString() → 堆上分配最终 string(string.Create,一步到位)
Dispose() → 归还 ArrayPool 数组,干净结束

ZString(notNested: true):
CreateStringBuilder() → struct 在栈上,使用 ThreadStatic 缓冲区,连 ArrayPool 借还都省
.Append(...) → 同上
.ToString() → 同上
Dispose() → 标记缓冲区可用,无需归还

功能特性对比

特性 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
2
3
4
5
{
"dependencies": {
"com.cysharp.zstring": "https://github.com/Cysharp/ZString.git?path=src/ZString.Unity/Assets/Scripts/ZString"
}
}

指定版本:

1
https://github.com/Cysharp/ZString.git?path=src/ZString.Unity/Assets/Scripts/ZString#2.4.0

基础用法:替代帧循环里的字符串拼接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using Cysharp.Text;

public class HpDisplay : MonoBehaviour
{
[SerializeField] private Text hpText;
private int _currentHp = 99;
private int _maxHp = 100;

void Update()
{
// 旧写法:每帧产生多个临时对象
// hpText.text = "HP: " + _currentHp + " / " + _maxHp;

// ZString 写法:只有最终 string 一次分配,数值直写缓冲区
hpText.text = ZString.Format("HP: {0} / {1}", _currentHp, _maxHp);
}
}

TextMeshPro:连 ToString 都省掉

ZString 为 Unity 的 TextMeshPro 提供了扩展方法,可以直接把内部缓冲区传给 TMP,连最终那次 string 分配都省掉,实现真正的完全零分配

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
using Cysharp.Text;

public class HpDisplay : MonoBehaviour
{
[SerializeField] private TextMeshProUGUI hpText;

void Update()
{
// SetTextFormat:完全零分配,直接写入 TMP 内部缓冲区
hpText.SetTextFormat("HP: {0} / {1}", _currentHp, _maxHp);

// 或者用 StringBuilder 风格再 SetText
using var sb = ZString.CreateStringBuilder();
sb.Append("HP: ");
sb.Append(_currentHp);
sb.Append('/');
sb.Append(_maxHp);
hpText.SetText(sb); // 直接传 builder,不调 ToString,完全零分配
}
}

StringBuilder 风格:复杂拼接逻辑

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 注意:必须用 using,否则 64K 的 ArrayPool 缓冲区不会归还
void UpdateStatusPanel(PlayerData player)
{
using var sb = ZString.CreateStringBuilder();

sb.Append("Name: ");
sb.Append(player.Name);
sb.Append("\nLevel: ");
sb.Append(player.Level);
sb.Append("\nHP: ");
sb.Append(player.CurrentHp);
sb.Append('/');
sb.Append(player.MaxHp);
sb.Append("\nGold: ");
sb.AppendFormat("{0:N0}", player.Gold);

statusText.text = sb.ToString();
}

跨方法传递:必须用 ref + try/finally

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
void BuildBattleLog(BattleResult result)
{
// 使用 ref 传递时不能用 using,需要手动 try/finally
var sb = ZString.CreateStringBuilder();
try
{
AppendHeader(ref sb, result);
AppendDamageRecords(ref sb, result.DamageRecords);
AppendSummary(ref sb, result);
battleLogText.text = sb.ToString();
}
finally
{
sb.Dispose(); // 确保归还 ArrayPool 缓冲区
}
}

static void AppendHeader(ref Utf16ValueStringBuilder sb, BattleResult result)
{
sb.Append("=== Battle Result ===\n");
sb.AppendFormat("Round: {0}\n", result.Round);
}

static void AppendDamageRecords(ref Utf16ValueStringBuilder sb, IList<DamageRecord> records)
{
foreach (var record in records)
{
sb.Append(record.AttackerName);
sb.Append(" → ");
sb.Append(record.TargetName);
sb.Append(": -");
sb.Append(record.Damage);
sb.Append(" HP\n");
}
}

static void AppendSummary(ref Utf16ValueStringBuilder sb, BattleResult result)
{
sb.AppendFormat("Winner: {0}\n", result.Winner);
}

notNested 模式:帧循环的最优选择

如果你的构建逻辑简单且不会嵌套,notNested: true 是性能最好的选项:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void Update()
{
// notNested: true 使用 ThreadStatic 缓冲区,连 ArrayPool 借还都省
// 适合简单、不嵌套的场景
using var sb = ZString.CreateStringBuilder(notNested: true);
sb.Append("Pos: (");
sb.Append(transform.position.x);
sb.Append(", ");
sb.Append(transform.position.y);
sb.Append(')');
posText.text = sb.ToString();

// 注意:同帧内不能再有另一个 notNested: true 的 builder
// 也不能调用 ZString.Concat/Format(它们内部也用 ThreadStatic)
}

与异步代码配合

ZString 的 struct 特性在异步代码里需要注意。跨越 await 时 struct 可能被复制,推荐在进入 await 之前就完成构建:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// 推荐:构建和异步操作分离
async Task SendGameResultAsync(GameResult result)
{
// 同步构建,立即 ToString
var payload = BuildPayload(result);

// 异步操作只用普通 string
await networkService.SendAsync(payload);
}

string BuildPayload(GameResult result)
{
using var sb = ZString.CreateStringBuilder();
sb.AppendFormat("{{\"score\":{0},\"rank\":{1}}}", result.Score, result.Rank);
return sb.ToString();
}

六、ZString 的使用限制和注意事项

1. 必须用 using 或手动 Dispose(最重要)

默认模式下创建时立即租借了 64K 的 ArrayPool 缓冲区,不 Dispose 就不会归还,导致内存泄漏:

1
2
3
4
5
6
7
8
// 危险:抛异常时 Dispose 不会被调用,64K 数组永久泄漏
var sb = ZString.CreateStringBuilder();
sb.Append(someOperation()); // 如果这里抛异常
sb.Dispose(); // 这行不会执行

// 安全:using 保证无论如何都会 Dispose
using var sb = ZString.CreateStringBuilder();
sb.Append(someOperation());

2. 跨方法必须用 ref,否则发生值复制

struct 默认值传递,子方法拿到的是副本,写入不会影响原始变量,是最容易踩的坑。

3. notNested 模式不能嵌套

同一线程上同时只能有一个 notNested: true 的 builder,且不能与 ZString.Concat/Join/Format 同时使用,因为后者也消耗同一块 ThreadStatic 缓冲区。

4. 自定义类型需要注册格式化委托

未注册的类型会退化调用 .ToString(),这次分配是无法避免的。

5. 装箱陷阱

Utf16ValueStringBuilder 实现了 IBufferWriter<char> 接口,把 struct 赋给接口变量会发生装箱(堆分配):

1
2
using var sb = ZString.CreateStringBuilder();
IBufferWriter<char> writer = sb; // 装箱!产生堆分配,失去了 struct 的优势

七、什么时候用,什么时候不用

应该用 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 确保跨方法写入正确


参考资料