云计算百科
云计算领域专业知识百科平台

08-01-优化-字符串与文本操作的GC陷阱

字符串与文本操作的 GC 陷阱

篇章:08-性能优化篇 | 阅读时间:约 45 分钟 | 前置知识:了解 C# 字符串基本操作与 Unity Profiler 的 GC.Alloc 用法


一、引言

字符串操作是 C# 程序中最常见的 GC 分配来源。字符串的不可变性导致每次"修改"都创建新对象。这一条几乎每个 C# 开发者都背得出,但在 Unity 项目里,它真正咬人的地方远不止"拼接产生新字符串"这一句话:插值字符串背后的装箱、Substring 的整段复制、每次 ToString 的临时对象、UTF8 编码往返时成倍的字节分配、UI 每帧改文本引发的连锁重建,这些分配点单独看都不大,叠在每帧执行的热路径上就是 GC 卡顿的主力。

本文的目标很具体:把字符串相关的分配陷阱逐个拆开,每个陷阱都按"现象 → 分配在哪产生 → 量级估算 → 改法"四步讲清楚,读完之后你可以直接拿着清单去改自己的项目。全文按这个顺序展开:第二节讲不可变性为什么让字符串成为分配大户;第三到第五节依次拆拼接、Substring、ToString 与装箱;第六节讲 StringBuilder 的正确用法与误用;第七、八节覆盖字符串池化与编码转换;第九节单独谈 TextMeshPro 的常见误用;最后三节给出量级估算表、误区清单与工程决策清单。

需要先说清楚口径:本文所有字节数都是典型估算或示例量级,不是任何机型上的实测常数。分配量取决于字符串长度、运行时版本与后端(Mono 与 IL2CPP 的字符串布局细节并不完全一致),唯一可靠的验收方式是连真机 Profiler 看 GC Allocated In Frame 计数器。

二、字符串为什么天生是分配大户

2.1 不可变性与对象布局

C# 的 string 是不可变类型:对象一旦创建,字符内容不能再改。所谓"修改"字符串,本质上都是"新建一个字符串,把旧内容和新内容拼进去,旧的丢给 GC"。从内存布局上看,一个 string 对象大致是这样占用堆空间的:

string s = "HP:1234/9999"; // 14 个字符(示例)

┌──────────────────────────────────────────────┐
│ 对象头(同步块索引 + 方法表指针,8~16B 量级) │
├──────────────────────────────────────────────┤
│ 长度字段 length(4B) │
├──────────────────────────────────────────────┤
│ 字符数据(UTF-16,每字符 2B) │
│ 'H' 'P' ':' '1' '2' '3' '4' '/' … │
│ 共 14 × 2B = 28B(典型估算) │
└──────────────────────────────────────────────┘
对象总大小 ≈ 对象头 + 4B + 2×字符数,再对齐到指针宽度

这张图解释了两个后面反复用到的事实。第一,字符串的内存开销和字符数线性相关,一个 500 字符的长文本对象就是 1KB 上下(典型估算),切它一小段并不会自动变小。第二,每次"修改"都会完整复制一遍字符数据,字符越多单次分配越贵。分配本身还没完,Unity 默认的 Boehm 收集器不压缩、不移动对象,这些短命字符串在堆里留下的是实打实的空洞,长期运行后碎片持续累积(机制细节参见本专栏 03 篇与 06 篇)。

2.2 一次"修改"产生多少垃圾

把一次看似无害的字符串更新放到时间线上看,分配是成串出现的:

代码: msg = "HP:" + hp + "/" + max; // hp、max 为 int

执行期实际发生的事(示意):
int.ToString(hp) → 新字符串 "1234" [分配 1]
int.ToString(max) → 新字符串 "9999" [分配 2]
string.Concat(…) → 临时串 "HP:1234" [分配 3]
string.Concat(…) → 最终串 "HP:1234/9999" [分配 4]
旧 msg 变成垃圾(如果它还有引用就先活着)

一帧里如果 200 个血条都在做这件事:
200 × 4 个短命字符串 ≈ 800 个对象进入堆,等待下一轮 GC 清理

单个短字符串的分配与回收很快,问题出在频率。游戏里血量、弹药、计时器、伤害飘字、战斗日志,全都在按帧或按事件重复这套动作。分配速率一高,GC 触发就会变勤,每次收集面对的都是一大堆本可以不存在的垃圾。

2.3 常见误解先纠正两条

第一条误解是"我用的都是常量拼接,编译期就合并了"。只有每一项在编译期都是字面量常量时,编译器才会折叠成一个常量字符串,运行期零分配。只要有一项来自变量,比如 "HP:" + hp,运行期该做的分配一个跑不掉。

第二条误解是"小字符串没几个字节,无所谓"。短命对象的成本不在于它占多少字节,而在于出现次数乘以 GC 扫描它的次数。Boehm 不分代,没有新生代"朝生夕死快速回收"的缓冲区。对 Unity 项目来说,把每帧 KB 级的字符串分配压下去,收益通常大于把某个大数组缩小一半。

三、拼接:+、string.Concat、$"" 与循环拼接

3.1 编译器把这些写法降级成什么

先看清楚每种拼接写法在 IL 层面落到哪个调用上,这是判断分配量的前提。下表是 Unity 的 Mono/IL2CPP 脚本后端上的典型降级行为(以你所用 Unity 版本的编译器为准,不同版本 Roslyn 有细节差异):

写法降级目标分配来源典型估算
a + b string.Concat(string, string) 1 个新字符串 每次 1 个对象
a + b + c string.Concat(string, string, string) 1 个新字符串 每次 1 个对象
a + b + c + d(4 项及以上) string.Concat(params string[]) 参数数组 + 新字符串 每次 2 个对象
"x = " + x(x 为 int) 先 x.ToString() 再 Concat 装箱无关,但多一个 int.ToString 串 每次 2 个对象
$"{hp}/{max}" 旧运行时多为 string.Format,简单场景降为 Concat 格式化数组、装箱、结果串 每次 2~4 个对象(示例量级)
string.Format("{0}/{1}", hp, max) 原样调用 object[] 数组 + 两个装箱 + 结果串 每次 4 个起(示例量级)
循环内 s += item 每轮一次 Concat 每轮 1 个新串,旧串变垃圾 n 轮 n 个对象,字符复制 O(n²)

有两行值得展开。第一是 params string[]:一旦拼接项超过三个,编译器就会分配一个字符串数组来填 params 参数,数组本身也是托管堆对象,这是很多人没意识到的"多出来的一个分配"。第二是插值字符串:Unity 的脚本后端走的 API 面大约是 .NET Standard 2.1 一级,没有 .NET 6 引入的 DefaultInterpolatedStringHandler,所以 $"" 的实际成本接近 string.Format,参数里有值类型时装箱几乎跑不掉,第五节会展开。

3.2 坏写法与好写法对照

先看循环拼接这个最经典的反例,以及它对应的改法:

// 错误:每次拼接产生新字符串
string msg = "";
for (int i = 0; i < 100; i++) msg += i + ",";

// 正确:StringBuilder 零多余分配
var sb = new StringBuilder(512);
for (int i = 0; i < 100; i++) sb.Append(i).Append(',');
string msg = sb.ToString(); // 只有一次分配

逐段解读这段坏代码。msg += i + "," 这一行里其实叠了三层分配:i + "," 先做一次 int 到字符串的转换加拼接,产生一个临时串;然后 msg + 临时串 再调一次 string.Concat,产生一个比上一轮更长的新串;上一轮的 msg 失去引用变成垃圾。100 轮下来产生约 200 个字符串对象(示例量级),更要命的是字符复制总量是 1 加 2 加 3 一直加到 100,也就是 O(n²) 量级的字符搬运:列表越长浪费增长越快,1000 项的循环搬运的字符数会到最终串的几百倍。

再看好的那段。StringBuilder 内部持有一块字符缓冲区,Append 只是把字符写进缓冲区尾部,缓冲区装得下就不再产生任何托管分配。Append(i) 走的是不装箱的泛型重载(Append(int) 直接把数字格式化进缓冲区),连 ToString 的临时串都省了。整段循环最终只在 sb.ToString() 时产生一个结果字符串。构造时传入 512 是预估容量,估算偏小也无妨,只是缓冲区会按需翻倍扩容,多产生一两次数组拷贝。

3.3 拼接方式怎么选

实践中的选择可以收成一张决策表:

场景推荐写法理由
两三个字符串拼一次,低频调用 + 或 $"" 可读性最好,分配量也可接受
循环内拼接、日志聚合、协议组包 StringBuilder,预估容量 单结果分配,避免 O(n²) 复制
每帧执行的文本拼接 先消灭拼接需求,非留不可再用复用的 StringBuilder 见第六节,每帧 ToString 结果仍是分配
大量同结构文本(如 CSV 一行) string.Join 或 Span/string.Create(参见 08-07) Join 内部一次计算总长再一次分配

string.Join 先遍历算出总长度再一次分配并填充,没有中间垃圾串,"很多短串拼成长串"时分配次数显著少于手写循环加加。

四、Substring 与切片:小窗口大复制

4.1 Substring 的分配路径

Substring 会复制原字符串的一部分创建新字符串,大字符串取小部分时浪费明显。这条在 Unity 的 Mono 与 IL2CPP 后端上成立,展开看它的代价:

// 从一份 100KB 的配置文本里反复截取小段
string big = LoadConfigText(); // 假设 100KB,约 5 万个字符
for (int i = 0; i < parsedKeys.Count; i++)
{
string key = big.Substring(start[i], length[i]); // 每次都是全新字符串
table[key] = i;
}

逐段解读。big 只加载一次,这没问题;问题全在循环体里那一次 Substring。它做的两件事是:在堆上分配一个全新的 string 对象,然后把 big 里从 start[i] 开始、长度 length[i] 的那段字符复制进去。原串 big 一个字节都不会被省略,它始终完整地占着那 100KB。于是同一份数据在内存里存在两份拷贝:完整的配置串,加上若干个从它身上复制出来的小串。循环 500 次就是 500 个新字符串对象加 500 次字符复制,而这些小串多数用完键值对构造就死了,纯粹是过路垃圾。

复制过程用图看更直观:

原串 big(100KB,常驻):
[————————————————– 5万字符 ————————————————–]
▲
Substring(start=1000, len=8) 会做的事: │ 截取窗口
▼
新串 key(每次循环都新分配):
[对象头][len=8]['k','e','y','N','a','m','e','!'] ← 独立副本,与 big 无共享

顺带说清楚一个常见疑问:桌面 .NET 上有人说 Substring 不复制,有些运行时版本也确实对它做过内部优化,但结论不能搬到 Unity:Mono 与 IL2CPP 后端按"分配加复制"实现,官方文档也没有零拷贝承诺。写 Unity 代码按最坏情况做性能预算,才不会在真机上被打脸。

4.2 改法:索引化、Span、结果缓存

改法按侵入性从低到高排三种。第一种是索引化:不要把小窗口立刻变成字符串,把"起点加长度"这对索引传下去,真正需要字符串的位置只转换一次。对 4.1 的例子,若这些小窗口只用于临时查找,完全可以改成对原串的范围比较,只在最终要长期持有的键上转一次字符串。

第二种是用 ReadOnlySpan<char> 表示切片,Span 的切片只是两个字段的偏移,不分配不复制。Unity 版本较新的项目可以在热路径上用它,细节与更多零分配技巧参见本篇 08-07《Span 与零分配编程》。

第三种是结果缓存:同一个切片结果在一个逻辑帧或一次解析里被反复使用时,只截取一次存起来。这条对"每帧 Substring 同一段"的代码尤其有效,把每帧 N 次分配变成每次配置变更 N 次分配。

五、ToString 与装箱的叠加效应

5.1 值类型 ToString 本身就分配

值类型的 ToString 走的通常是 Number.ToString 那一套实现,产出必然是一次字符串分配,性能热路径中应缓存或避免调用。补上量级视角:一个 int 的 ToString 在堆上产生一个 1 到 10 字符不等的新字符串(负数更长),对象头加字符数据约 24 到 32 字节(典型估算,随运行时对齐方式浮动)。单看很小,但 ToString 往往不是孤立调用,它经常和拼接、格式化叠在一起成串出现。

5.2 装箱是怎么叠进来的

真正把 ToString 的成本推上去的是它与装箱、格式化 API 的组合。看一组对照:

// 坏:三层叠加,字符串 + 装箱 + 数组
int hp = 1234;
string s1 = hp.ToString(); // 分配 1 个字符串
string s2 = string.Format("{0}/{1}", hp, max); // object[] 数组 + 2 个装箱 + 1 个结果串
string s3 = $"{hp}/{max}"; // Unity 运行时上接近 Format 的路径
string s4 = string.Concat(hp, "/", max); // Concat(object,…) 重载:2 次隐式 ToString

// 好:不装箱、按需分配
var sb = new StringBuilder(24);
sb.Append(hp).Append('/').Append(max); // Append(int) 不装箱不建临时串
string s5 = sb.ToString(); // 只在真需要字符串时分配一次

逐段解读。第一行 hp.ToString() 是最单纯的分配:一个短命字符串,没有别的开销。第二行 string.Format 的参数表要求 object,两个 int 各被装箱一次,装箱意味着在堆上分配盒子对象再把值拷进去(装箱机制见 08-04 专章,本文只看它在字符串路径上的叠加);同时可变参数还会分配一个 object[] 数组装下这两个盒子,最后才是格式化引擎扫描格式串、写进结果字符串。一次调用四个托管对象,其中三个在结果字符串之外纯属过路。

第三行插值字符串在 Unity 的运行时面上走的还是格式化路径,装箱与数组一个不少,写法变优雅了,GC 成本没有变优雅。第四行容易被当成"Concat 更快所以更省",实际上 string.Concat(params object[]) 这一族重载内部仍然逐个调用参数的 ToString,两个装箱照样发生,只是数组可能被优化掉。右边的好写法之所以干净,靠的是两件事:Append(int) 这类泛型重载直接把数字写进缓冲区,绕开了 ToString 与装箱;结果串只在真正需要 string 的边界上产生一次。

5.3 热路径上的策略

热路径上对 ToString 的正确态度是"能不转就不转,非转不可就攒一次"。计分板每帧更新时,分数没变就不调用 ToString;变了也只是把数字 append 进复用的 StringBuilder。日志系统如果每帧都在拼参数,先把"是否开启该级别日志"的判断放在拼接之前,让拼接代码根本别执行。还有一类隐蔽点是查找用的 key:dict[key] 的 key 如果是刚拼出来的临时串,查找本身不分配,但拼 key 的过程已经付过一次账,把热 key 缓存成字段能直接砍掉这条链。

六、StringBuilder:缓冲区机制、阈值与误用

6.1 内部机制回顾

StringBuilder 内部维护字符缓冲区,缓冲区足够时不再产生分配,循环与多次拼接场景都推荐它。把这几句话拆开讲透。核心字段是一个字符数组加一个逻辑长度,每次 Append 把字符写进数组的空闲尾部,只移动逻辑长度,不新建字符串。数组装不下时按策略扩容,常见策略是翻倍或按需增长加一个余量,扩容会分配新数组、复制旧内容、释放旧数组,这一下是真实的分配,所以对最终长度有预估时宁可构造时传入容量,把扩容次数压到零。

另一个关键点是 ToString():它按当前逻辑长度复制出一个全新的 string。也就是说 StringBuilder 只把"中间过程"的分配消掉了,"产出最终字符串"这一次分配仍然在。一段代码若每帧调用 sb.ToString() 并把结果存起来用,那么无论 StringBuilder 复用得多好,每帧照样产生一个结果字符串。复用 StringBuilder 解决的是过程垃圾,解决不了结果垃圾。

6.2 复用的正确姿势

// 每帧复用的 StringBuilder:类字段持有,Clear 只清长度,保留容量
public class DamagePopup
{
readonly System.Text.StringBuilder _sb = new System.Text.StringBuilder(64);

public string Build(int amount, string suffix)
{
_sb.Clear(); // 长度归零,缓冲区保留,无分配
_sb.Append('-').Append(amount); // Append(int):格式化进缓冲区,无中间串
if (!string.IsNullOrEmpty(suffix))
_sb.Append(suffix);
return _sb.ToString(); // 本次循环唯一的字符串分配
}
}

逐段解读。_sb 放在类字段而不是方法内,是复用的前提:方法内 new StringBuilder() 每次调用都要新建对象,复用就无从谈起。Clear() 把逻辑长度归零但不动容量,这是它比"赋值成 new"优越的地方;反复 new 等于每次重新要一块缓冲区,旧的直接变垃圾。Append('-') 与 Append(amount) 走字符与整数重载,格式化直接落在缓冲区里。末尾的 ToString() 是唯一分配:结果存进 TMP 的 text 属性时它会活到下次更新,属于结果分配;只用于日志判断就该先判断再决定要不要 ToString。

6.3 误用清单

把实践中高频出现的误用列成表,每一条都对应前面讲过的机制:

误用为什么有害正确做法
循环内 new StringBuilder() 复用无从谈起,每轮新建对象 类字段持有,Clear() 复用
每帧 sb.ToString() 且结果仅用一次 过程零分配,结果仍每帧一个串 先判断是否真需要字符串
AppendFormat 逐段拼 内部走格式化,数组与装箱重新出现 Append 系列重载逐段写入
容量从默认 16 一路翻倍 多次扩容加多次复制 构造时按预估传容量
用 StringBuilder 替代单次拼接 反而多要一个缓冲区对象 单次 + 或 $"" 更划算
把 StringBuilder 当参数到处传并并发修改 单实例非线程安全,共享即事故 每个使用者各自持有或加锁

表里"单次拼接不值得上 StringBuilder"这条常被忽略:StringBuilder 对象本身也有对象头和缓冲区数组,一次性拼两三段就丢的场景,a + b 只产生一个结果串,反而比"new 再 ToString"少一个中间对象。工具要用在它划算的地方。

七、字符串池化:Intern、应用级池与命名数据

7.1 string.Intern 的边界

CLR 内置了一个驻留池,string.Intern(s) 返回池中已有的同内容字符串,没有就登记进去。它对"成千上万个相同短串"的场景很有效,比如从大量数据行里反复读取同一组枚举字符串。但边界必须先讲清楚:驻留池只进不出,字符串一旦登记就长期驻留,把本该被回收的字符串变成常驻内存,反而抬高堆水位;对超长字符串做 Intern 的查找成本也不低;在 IL2CPP 后端上驻留行为受实现差异影响,不能假设与桌面 .NET 完全一致。结论是 Intern 只适合"有限集合、高频重复、生命周期本就接近静态"的字符串,例如协议字段名、配置键名、技能枚举名。

7.2 应用级池

多数项目更适合自己控制的应用级池。先看一个最小实现,后面逐段说清楚它的适用边界:

// 简单的字符串池化实现
public static class StringPool {
private static Dictionary<string, string> pool = new();
public static string Intern(string s) {
if (pool.TryGetValue(s, out var cached)) return cached;
pool[s] = s;
return s;
}
}
// 适用场景: 大量重复字符串(如物品名称、特效名称)
// string.Intern 使用 CLR 内置池, 上面的实现使用应用级池, 可按需清理

逐段解读。这个池的本质是一个以字符串内容为键的字典:新串进来先查有没有同内容的驻留版本,有就复用旧的,没有就登记。相比 string.Intern,它的好处全在"可控"上:字典是你自己的,可以在关卡卸载、商城关闭、协议重连这些时机按前缀或按整表清理,把不该常驻的名字放回堆里让 GC 处理。这也正是注释里"可按需清理"四个字的含义。

它的代价同样要正视。第一,字典的键持有字符串引用,池有多大常驻内存就有多大,清理策略没设计好等于自己造了一个只进不出的驻留池。第二,TryGetValue 有散列与比较成本,字符串很长或调用极频繁时要算这笔账,必要时改用"短内容做键"的两级结构。第三,多线程访问需要加锁或换并发字典,锁又引入争用,池化调用点最好集中在加载期与初始化期,而不是每帧热路径。第四,Unity 关闭域重载时静态池跨场景存活,不关则每次进播放模式都重建,两种行为对内存曲线影响不同,做真机内存评估要把这个变量固定下来。

7.3 什么时候不值得池化

三个信号出现任何一个就该停手。一是字符串种类无界增长,比如把玩家昵称、聊天内容、动态生成的路径塞进池,池本身会无限膨胀。二是字符串很长,池化省下的分配被字典存储成本反超。三是调用点在每帧热路径且无锁不可行,为省几个小分配引入锁争用通常得不偿失。池化的正确心态是把它当"有限词典",服务枚举名与配置名这类天然封闭的集合。

再补三条代码审查时值得盯的模式,并附量级:"" + x 产生 2 个临时字符串(空串常量本身不分配,x 的 ToString 加一次 Concat 各一个);x + ", " + y 产生 3 个(两次 ToString 或已有串加一次中间 Concat 加一次最终 Concat,视项的静态类型浮动);循环内拼接是 O(n²) 字符复制。这三条可以直接抄进团队的静态扫描规则或 Code Review 清单。

八、编码转换:UTF8 往返的双倍分配

字符串与字节数组之间的转换是另一类隐蔽分配,出现在存档、网络、原生插件交互、文件读写这些地方。Encoding.UTF8.GetBytes(string) 在堆上分配一个 byte[] 并把 UTF-8 字节写进去;Encoding.UTF8.GetString(byte[]) 反过来分配一个新 string。一次往返,字符串和字节数组各来一遍,谁都没省。

// 坏:每次收发包都新建缓冲区并往返转换
byte[] Send(string payload)
{
byte[] bytes = Encoding.UTF8.GetBytes(payload); // 分配 byte[]
WriteToSocket(bytes); // 发送
return bytes; // 用完即弃,下一轮再来
}

// 好:缓冲区复用,只在内容变化时重新编码
readonly byte[] _sendBuffer = new byte[8 * 1024];
int _sendLength;

void PrepareSend(string payload)
{
_sendLength = Encoding.UTF8.GetBytes(
payload.AsSpan(), _sendBuffer); // 编码进已有缓冲区,无 byte[] 分配
WriteToSocket(_sendBuffer, _sendLength);
}

逐段解读。坏版本每次调用两个分配视角:GetBytes(string) 返回全新数组,其容量由字符串长度决定,内容一变数组就作废;在高频收发的场景里这就是每包一次的数组垃圾,包大一点还会逼近 LOH 阈值(.NET 系运行时大对象阈值为 85,000 字节,Unity 的 Boehm 路径上大数组同样是最不希望频繁重生的对象类别)。好版本把缓冲区提为字段,GetBytes 调用改用接收缓冲区的重载,编码结果直接写进已有数组,返回值是实际写入的字节数而不是新数组。这套改法的前提是对最大长度有把握,超长内容需要扩容策略配合,宁可分帧或分片发送,也不要退回每包新建。

同一个模式还有几个变体:从原生插件取字符串时,优先让对方写入预分配的 byte[] 或 NativeArray<byte>,自己再解码一次;高频小文件读取复用缓冲区加流式读取;与文本协议打交道时,把"解码出来的 string"也纳入分配预算,UTF-8 到 UTF-16 的转换本身就是一次字符串分配。这些路径上的 API 随 Unity 版本变化,判断标准不变:看 Profiler 里有没有你不认识的 byte 数组与字符串成对出现。

九、TextMeshPro 与 UI 文本的常见误用

UI 文本是字符串分配的重灾区,因为它同时踩中"每帧拼接"和"改一个字触发整块重建"两件事。先分清两类成本:字符串分配是 GC 问题,文本网格重建是 CPU 问题,两者都拖累帧时间,治疗方案不同,本文只负责前者,后者见 08-09 与 08-10。

常见误用一:每帧无条件赋值。scoreText.text = score.ToString() 在分数没变的帧里也照做一遍 ToString 与属性赋值。属性赋值本身还会触发 TMP 内部的解析流程,内容与上次相同也要走一遍检查。改法是缓存上次值,内容变化才更新:

int _shownScore = int.MinValue;

void RefreshScore(int score)
{
if (score == _shownScore) return; // 内容没变:不 ToString,不赋值
_shownScore = score;
_scoreText.text = score.ToString(); // 变化帧才承担这一次分配
}

逐段解读。这个守卫只有三行,砍掉的却是"每帧一次 ToString 加一次属性赋值"的固定开销。int.MinValue 作为初值是为了保证首帧必然刷新,避免 UI 显示初值与逻辑值巧合相同时的显示错误。守卫模式对所有高频 UI 字段通用:血量、弹药、冷却倒计时、网络延迟,都按"变化才写"改造。

常见误用二:用拼接更新复合文本。hpText.text = $"{cur}/{max} HP" 每帧执行时,插值的装箱与结果串前面已经算过账。TMP 提供了带参数的 SetText 重载与直接接收 StringBuilder 的重载,前者把格式化收进 TMP 内部,后者让格式化结果直接落在你复用的缓冲区里,调用方不再产出中间字符串。具体重载集合随 TMP 包版本变化,以你工程里实际的 API 面为准,判断方法还是 Profiler:对同一处更新换写法,看 GC Allocated In Frame 的差值。

常见误用三:把长文本整体重建。聊天记录、战斗日志这类滚动文本,每来一条新消息就 oldText + "\\n" + newText,拼接本身 O(n²),拼出来的整串每次都要 TMP 重新解析与重建网格。正路是按行管理:用缓冲的 StringBuilder 生成本次追加的单行,或者用 TMP 的追加接口与富文本标签局部更新,把"新消息"的字符串成本控制在单行长度,而不是整页长度。

十、量级估算与风险对照表

把全文的陷阱收进一张表,最右列的风险分级供排期参考,所有字节数为典型估算,实测以 Profiler 为准:

操作分配在哪产生典型估算改法风险
循环内 s += x(100 项) 每轮 Concat 加 ToString 约 200 个短串,字符复制 O(n²) StringBuilder 预估容量 高
a + b + c + d(4 项以上) params string[] 数组加结果串 每次 2 个对象 拆成两次 3 项内拼接或用 Join 低
$"{hp}/{max}"(Unity 运行时) 格式化数组、装箱、结果串 每次 2~4 个对象(示例量级) StringBuilder.Append 重载 中
string.Format 带值类型参数 object[] 加装箱加结果串 每次 4 个起(示例量级) 同上,或换 Concat 加 ToString 中
循环内 Substring 切大串 新 string 加整段字符复制 每次约 24B 加 2×窗口长度 索引化或 Span 切片 高
每帧 int.ToString 短命字符串 每次约 24~32B(典型估算) 缓存上次值,变化才转 中
每帧 sb.ToString() 结果字符串 每次一个新串 仅在需要 string 的边界调用 中
Encoding.UTF8.GetBytes / GetString 新 byte[] 与新 string 各约 2×自身字节量 编码进复用缓冲区 高
每帧 text = 任意新串 调用方字符串加 TMP 解析 字符串一次,CPU 成本另计 变化才赋值,缓存上次串 高
高频重复枚举名 同内容字符串反复创建 每次一个短串 应用级池或 Intern 低

表里风险分级的口径是"在每帧路径出现时对 GC 卡顿的贡献预期":高表示单点就足以推动 GC 频率上升,中表示叠加后可观,低表示主要在超高频或超长字符串时才需要处理。排优化顺序时先扫"高",再看 Profiler 里实际的分配热点,不要按代码行数平均用力。

十一、常见误区与反模式

误区一:字符串分配小,单个不心疼,所以先不管。
错误做法是看到 Profiler 里每次只有二三十字节的字符串就跳过,只追大对象。为什么错:GC 的触发看的是分配速率的累积,不是单次大小。每帧 300 次二十几字节的字符串分配,一秒钟就是上万次,Boehm 不分代,这些垃圾每一次都要参与后续的全堆标记与清扫。正确做法是先按"每帧字节总量"看字符串类分配的占比,占比进入前三再动手,但动起手来就要按本文清单逐条清。

误区二:StringBuilder 是万灵药,所有拼接都换成它。
错误做法是把一次性的两段拼接也改写成 new 加 Append 加 ToString。为什么错:StringBuilder 对象加缓冲区数组本身也是分配,单次两三段的拼接用 + 只产生一个结果串,换工具反而多付一个中间对象。正确做法是看频率和拼接段数:循环里、多次拼接、容量可预估的场景上 StringBuilder;一次性低频拼接保留 + 或 $"",把精力挪去更高收益的分配点。

误区三:字符串池化能省分配,那就把所有字符串都进池。
错误做法是把聊天内容、玩家输入、动态路径全部 Intern 或入池。为什么错:池是常驻内存,无界内容入池等于把本可回收的垃圾变成永久居民,堆水位只升不降,低端机上表现为内存持续上涨直至进程被杀。正确做法是只池化封闭集合,给池配清理时机(关卡卸载、会话结束),并把池的容量纳入内存预算监控。

误区四:Editor 里看到 0 B 分配就说明改对了。
错误做法是在 Editor 的 Profiler 里扫一眼没有 GC.Alloc 就宣布完工。为什么错:Editor 与真机的脚本后端、字符串实现细节、UI 渲染路径都不同,Editor 的分配数只能证明路径走到了,不能证明真机达标。正确做法是 Development Build 连真机,改前后各采一段 GC Allocated In Frame 序列做对比。

十二、工程实践决策清单

把可执行的动作按实施顺序列出来,每条都能直接进你们团队的优化排期:

  • 打开 Profiler,按 GC Allocated In Frame 定位字符串类分配占比高的调用栈,先抓每帧路径上的,再抓事件路径上的。
  • 全局搜循环内的 += 字符串拼接与循环内 Substring,这两类收益最直接,按第三节和第四节的改法清零。
  • 把热路径上的 string.Format 与带值类型参数的插值逐个换成 StringBuilder 的 Append 重载,顺手砍掉装箱。
  • 给所有每帧赋值的 UI 文本加"变化才写"守卫,复合文本改用 SetText 参数重载或 StringBuilder 重载。
  • 检查存档、网络、原生交互路径上的 GetBytes 与 GetString,确认缓冲区是复用的。
  • 为枚举名、配置键这类封闭字符串集合评估应用级池,配好清理时机,把池大小纳入内存监控。
  • 复查团队规范:StringBuilder 必须字段持有、Clear 复用;日志拼接必须在开关判断之后;禁止在属性 getter 里做拼接。
  • 全部改完后连真机复测,记录改前改后的每帧分配量、GC 次数与 P99 帧时间。
  • 第 7 条最容易被跳过也最值得坚持:字符串分配点散、形态杂,一轮专项清理挡不住回潮,硬规则进 Code Review 清单比一次性重构管用。

    十三、与相关章节的关系

    本文专注字符串与文本这一条线,在全专栏里的位置如下,相关机制不重复展开,按需跳转:

    主题本文覆盖更深的内容在
    不可变性与拼接分配 第二、三节 无需外跳
    闭包与匿名函数的堆分配 仅在 lambda 捕获字符串变量处轻提 08-03 闭包与匿名函数
    装箱如何叠加进 ToString 与 Format 第五节讲现象与改法 08-04 装箱与反射
    Span 与零分配文本处理 第四、八节给方向 08-07 Span 与零分配编程
    Unity 各 API 的分配清单 第九节只谈 TMP 08-08 Unity API 的 GC 陷阱清单
    GC 为什么对小分配敏感、增量 GC 与碎片 第二节提结论 03 架构篇、06 收集器篇

    这样分工的原因一句话带过:字符串问题的根子在分配模式,分配出去之后 GC 怎么处理归收集器那几篇讲,排查时两件事串起来看,写作时拆开写。

    本章总结

    收拢全文的要点,按重要度排:

  • 字符串的不可变性决定了每次"修改"都是完整的分配加复制,热路径上的字符串问题本质是分配速率问题。
  • 拼接的账要按降级结果算:三项以内是单次 Concat,四项以上多一个参数数组,Unity 运行时的插值字符串接近 Format,带值类型就叠加装箱。
  • Substring 复制窗口字符,切小取大时浪费最明显,索引化与 Span 是根治方向。
  • StringBuilder 把过程分配消到只剩结果串,复用靠字段持有加 Clear,容量预估决定它是否真零扩容。
  • 池化只服务封闭字符串集合,Intern 只进不出,应用级池要配清理,无界内容永不入池。
  • 编码转换与 TMP 赋值是两类容易漏账的分配,缓冲区复用和变化才写是对应的两把钥匙。
  • 一句话记忆点:字符串优化不抠单个字节,只抠"每帧少造几个对象"。按本文清单清一遍热路径的字符串分配,再拿真机 Profiler 验收,GC 卡顿里属于文本的那一块会退到背景噪声的水平。

    赞(0)
    未经允许不得转载:网硕互联帮助中心 » 08-01-优化-字符串与文本操作的GC陷阱
    分享到: 更多 (0)

    评论 抢沙发

    评论前必须登录!