
开场一个就拼个分数显示却卡出鬼的诡异小王做了个分数显示每帧更新 UIscoreText.text 分数 score;结果游戏时不时卡一下Profiler 一看——GC 频繁触发“我就拼个字符串显示分数啊能有多大开销为什么老鸟说’字符串拼接是 GC 大户’拼字符串到底产生了什么垃圾原理是什么”老鸟说“这触及了 C# 的核心机制——字符串是不可变的Immutable你每拼一次其实都在创建全新的字符串对象旧的就变成’垃圾’等着被 GC 回收每帧拼 每帧造垃圾 GC 疯狂工作 卡顿今天把这个原理从底层讲透” 第一幕核心根源——字符串不可变(Immutable)⭐关键真相C#中字符串的核心特性 字符串是不可变的(Immutable)! ↓ 意思是: 字符串一旦创建 它的内容就永远不能改变! ↓ 那拼接是怎么回事? → 拼接不是改变原字符串 → 而是创建一个全新的字符串!拼接的真实过程string a 分数; string b a score; // 拼接 实际发生的 1. 系统开辟一块新内存 2. 把分数复制进去 3. 把score的值复制进去 4. 得到全新字符串分数100 ↓ 原来的分数还在内存里 → 没人用了 → 变成垃圾!生动理解不可变字符串不可变像刻好的石碑 石碑一旦刻好,字就不能改! ↓ 想要分数100? → 不能在旧石碑上加字 → 只能刻一块全新的石碑! ↓ 旧石碑(旧字符串)就废弃了 → 堆在那等清理(垃圾)! 第二幕垃圾从哪来——每次拼接都造新对象单次拼接的垃圾string result Hello World; ↓ 产生的对象: → 新字符串HelloWorld(有用) (中间临时对象也可能产生) ↓ 这些在堆(Heap)上分配的内存 → 用完就成GC要回收的垃圾多次拼接的垃圾爆炸⭐string s ; for (int i 0; i 5; i) { s s i; // 每次都造新字符串! } ↓ 过程 s 0 → 造0 (旧成垃圾) s 0 1 → 造01 (旧0成垃圾) s 01 2 → 造012 (旧01成垃圾) s 012 3 → 造0123 (旧012成垃圾) s 0123 4 → 造01234 (旧0123成垃圾) ↓ 5次循环,造了5个字符串,4个变垃圾! ↓ 循环越多,垃圾越多!生动理解垃圾累积循环拼接像每加一个字重刻一次碑 要01234这5个字: 刻0 → 废弃 刻01 → 废弃0 刻012 → 废弃01 刻0123 → 废弃012 刻01234 → 废弃0123 ↓ 为了最终一块碑,刻废了4块! → 4块废碑堆积(垃圾)! ↓ 明明可以一次刻好,却反复重刻! 第三幕为什么垃圾会导致卡顿垃圾 → GC → 卡顿的链条【完整因果链】 字符串拼接产生垃圾对象(堆内存) ↓ 垃圾越积越多 ↓ 触发GC(垃圾回收) ↓ GC工作时可能暂停程序(Stop) ↓ 这一帧变长 → 卡顿(掉帧)! ↓ 每帧拼接 → 频繁GC → 频繁卡顿!关键堆分配触发GC垃圾产生在堆(Heap)上 → 每次拼接都在堆分配新字符串 ↓ 堆分配到一定量 → 触发GC回收 ↓ GC是有开销的(尤其可能卡顿) ↓ 所以: 减少堆分配 减少GC 减少卡顿 ↓ 字符串拼接是堆分配大户!生动理解卡顿GC卡顿像垃圾满了要停工清理 游戏一边跑一边拼字符串(造垃圾) → 垃圾桶(堆)满了 → 保洁(GC)来清理 → 清理时得暂停一下 → 玩家感觉卡了一下! ↓ 造垃圾越勤 → 清理越频繁 → 越卡!最坑的场景Update里拼接❌ 最典型的坑 void Update() // 每帧执行! { scoreText.text 分数 score; // ↑ 每帧造一个新字符串! } ↓ 60帧/秒 → 每秒造60个垃圾字符串! → 持续造垃圾 → GC频繁 → 卡! 第四幕不同拼接方式的垃圾对比方式对比① a b (简单拼接): → 产生新字符串(少量垃圾) ② 循环里 s ... : → 每次迭代造新串(垃圾爆炸!)⭐最坏 ③ string.Format({0}, x): → 也会产生垃圾(内部有分配) ④ $分数{score}(插值): → 本质也是拼接,同样产生垃圾 ⑤ StringBuilder: → 可复用缓冲,大幅减少垃圾⭐最优 ↓ 循环拼接最坏,StringBuilder最好!为什么 StringBuilder 好StringBuilder(可变字符串) 内部维护一个可扩展的缓冲区 ↓ 拼接时: 往缓冲区里追加 → 不每次都造新字符串! → 复用同一块内存! ↓ 最后一次性生成结果 → 大幅减少垃圾!生动理解 StringBuilderStringBuilder像可擦写的白板 普通拼接: 每次重刻石碑(造垃圾) StringBuilder: 在白板上不断追加 → 白板可重复写,不用每次换新的! → 写完了再拍照成最终字符串 ↓ 一块白板搞定,几乎不造垃圾!️ 第五幕优化方案与代码方案1StringBuilder(多次拼接)usingSystem.Text;// ❌ 坏:循环拼接,大量垃圾stringBadConcat(){stringresult;for(inti0;i100;i)resulti.ToString();// 造100个垃圾!returnresult;}// ✅ 好:StringBuilder,极少垃圾stringGoodConcat(){StringBuildersbnewStringBuilder();for(inti0;i100;i)sb.Append(i);// 追加到缓冲,不造新串returnsb.ToString();// 最后一次生成}方案2缓存不变的部分// ❌ 坏:每帧都拼完整字符串voidUpdate(){scoreText.text分数score;}// ✅ 好:只在分数变化时更新intlastScore-1;voidUpdate(){if(score!lastScore)// 只在变化时才拼!{scoreText.text分数score;lastScorescore;}}↓ 分数不变的帧,完全不拼接,不造垃圾!方案3复用 StringBuilder// ✅ 更好:StringBuilder也缓存复用StringBuildersbnewStringBuilder(64);// 预分配voidUpdate(){if(score!lastScore){sb.Clear();// 清空复用(不重新分配)sb.Append(分数);sb.Append(score);scoreText.textsb.ToString();lastScorescore;}}↓ StringBuilder缓存只变化时更新最优!方案4避免频繁 ToString// ⚠️ 注意:int.ToString()也产生垃圾!intscore100;stringsscore.ToString();// 产生字符串垃圾// ✅ 数字转字符串也尽量少做/缓存// 或用TextMeshPro的SetText(支持数字少GC)生动理解优化优化字符串垃圾的核心思路 ① 别每帧拼 → 只在真正变化时拼 ② 多次拼用StringBuilder → 别用 ③ StringBuilder也复用 → 别每次new ↓ 核心: 减少造新字符串的次数! 第六幕常见陷阱汇总陷阱清单❌ 陷阱1: Update里拼字符串 → 每帧造垃圾 ❌ 陷阱2: 循环里用 拼接 → 垃圾爆炸 ❌ 陷阱3: string.Format / 插值频繁调用 → 同样产生垃圾 ❌ 陷阱4: 频繁 int.ToString() / float.ToString() → 数字转串也造垃圾 ❌ 陷阱5: 用 拼很多段 → abcd产生多个临时串 ❌ 陷阱6: Debug.Log拼接字符串 → 即使发布版不显示,拼接照样执行!Debug.Log 的隐藏坑// ⚠️ 隐藏坑:Log的字符串拼接照样执行!voidUpdate(){Debug.Log(位置transform.position);// ↑ 即使不看Log,拼接和ToString照样跑,造垃圾!}// ✅ 发布版用条件编译剔除[System.Diagnostics.Conditional(UNITY_EDITOR)]voidDebugLog(stringmsg){Debug.Log(msg);}生动理解陷阱字符串垃圾陷阱像处处漏水的水管 Update拼接 → 每帧漏 循环拼接 → 疯狂漏 Log拼接 → 看不见也在漏 数字转串 → 悄悄漏 ↓ 到处都是造垃圾的地方! 要一个个堵上! 第七幕如何检测字符串垃圾用 Profiler 检测✅ Unity Profiler 1. 看Memory模块的GC Alloc 2. 或Deep Profile看哪个函数分配多 3. 找到每帧都有GC Alloc的地方 ↓ 定位到字符串拼接的元凶!关注 GC Alloc 列✅ Profiler CPU模块 有 GC Alloc 列 → 显示每个函数的堆分配 → 字符串拼接会在这列显示分配量 ↓ 每帧非0的GC Alloc 需要优化!目标Update里零GC✅ 优化目标 稳定运行时(非加载), Update等每帧函数的GC Alloc 0! ↓ 字符串是常见的破坏这个目标的元凶✅ 字符串垃圾理解检查清单原理 □ 明白字符串是不可变(Immutable)⭐ □ 明白拼接是创建新字符串 □ 明白旧字符串变成垃圾 □ 明白垃圾→GC→卡顿的链条 垃圾来源 □ 知道循环拼接垃圾爆炸⭐ □ 知道Update里拼接每帧造垃圾 □ 知道Format/插值也造垃圾 □ 知道数字ToString也造垃圾 □ 知道Debug.Log拼接的隐藏坑 优化 □ 会用StringBuilder⭐ □ 会缓存StringBuilder复用 □ 会只在变化时才拼 □ 会用条件编译处理Log 检测 □ 会用Profiler看GC Alloc □ 目标是Update里零GC 一句话总结字符串拼接产生垃圾的原理根本原因是——C# 中字符串是不可变的Immutable一旦创建就不能改变。所以拼接实际上不是修改原字符串而是在堆上创建一个全新的字符串对象原来的字符串没人用了就变成了等待 GC 回收的垃圾。循环拼接最恐怖s i每次迭代都造一个新串、废弃一个旧串——拼 100 次就造 100 个字符串、99 个垃圾完整链条拼接造垃圾堆分配→ 垃圾累积 → 触发 GC → GC 暂停程序 → 卡顿掉帧。在 Update 里拼接最坑每帧都造垃圾60 帧就是每秒 60 个垃圾。优化多次拼接用 StringBuilder可复用缓冲不每次造新串、缓存 StringBuilder、只在值真正变化时才拼、Debug.Log 用条件编译目标是 Update 里 GC Alloc 为 0核心口诀字符串不可变拼接就是造新对象旧的变垃圾循环拼接垃圾爆炸Update拼接每帧造垃圾垃圾多了GC卡顿用StringBuilder复用只在变化时拼Profiler看GC Alloc追零 字符串垃圾原理速查表概念说明根源字符串不可变(Immutable)⭐拼接本质创建全新字符串对象垃圾来源旧字符串没人用了最坏场景循环拼接/Update拼接危害链垃圾→GC→暂停→卡顿隐藏坑Debug.Log拼接、数字ToString最优方案StringBuilder缓存按需拼检测Profiler看GC Alloc目标Update里零GC 一句话记住核心字符串不可变 → 拼接 造新对象 → 旧的成垃圾 → GC → 卡顿。循环里是垃圾爆炸元凶Update 里拼接每帧造垃圾。多次拼接用 StringBuilder、只在值变化时才拼——目标 Update 里 GC Alloc 归零 延伸从字符串垃圾看 GC 优化全景【字符串只是GC垃圾的冰山一角】 Update里产生垃圾的常见元凶: ① 字符串拼接 → 本篇 ② 装箱(Boxing) → 值类型转object ③ 闭包/Lambda捕获 → 隐式分配 ④ LINQ → 大量临时分配 ⑤ 每帧new对象/数组 → 直接堆分配 ⑥ foreach某些集合 → 迭代器分配 ⑦ params参数 → 数组分配 ↓ 共同目标: 减少堆分配! ↓ 核心优化思想: - 缓存复用(对象池、StringBuilder) - 避免每帧分配 - 用Profiler追踪GC Alloc到0 ↓ 字符串优化是GC优化的入门课!