1.5 字符串的不可变与驻留


1.5 字符串的不可变与驻留

本节摘要:string 是引用类型中的异类——不可变、内容判等、字面量驻留。本节解释这三个设计的联动逻辑,实测循环拼接的性能灾难与 StringBuilder 的收益边界,并给出字符串高频操作的选型依据。

三个特性是一体的

上一节留了一个伏笔:string 内容相同就判等,这在引用类型里是例外。要理解它,得把 string 的三个特性连起来看:

不可变。任何"修改"字符串的操作(ToUpperSubstringReplace)都不改原对象,而是返回新对象。设计动机有三:线程安全不需要锁、哈希码可以缓存(字典键的快)、同一实例可被多个引用安全共享。代价是每次"修改"都是一次新分配。

内容判等== 运算符被 string 重载为逐字符比较。因为不可变保证了两份内容相同的字符串永远不会分道扬镳,比较内容才是安全的语义。

驻留。编译器把代码里的字符串字面量收进一张驻留池,相同字面量全程序共享一个实例:

string a = "hello"; string b = "hello"; Console.WriteLine(ReferenceEquals(a, b)); // True:同一实例 string c = string.Copy("hello"); Console.WriteLine(ReferenceEquals(a, c)); // False:Copy 强制新实例 Console.WriteLine(a == c); // True:内容比较 string d = string.Intern(c); Console.WriteLine(ReferenceEquals(a, d)); // True:Intern 找回池中实例

string.Intern 能把运行期动态生成的字符串也放进池里找回共享——但要慎用:池里的字符串生存期相当于全程序,进池容易出池难,大量动态字符串驻留等于制造不会释放的内存。它是给"反复出现的高频常量字符串"(协议关键字、缓存键模板)准备的,不是通用优化。

循环拼接的账

不可变 + 每次修改都新分配 = 循环拼接是灾难。算一笔账:

var sw = System.Diagnostics.Stopwatch.StartNew(); string s = ""; for (int i = 0; i < 30_000; i++) s += "x"; // 第 n 次拼接要拷贝 n 个字符 sw.Stop(); Console.WriteLine($"{s.Length} 字符,耗时 {sw.ElapsedMilliseconds} ms");

第 n 次拼接分配一个长度为 n 的新串并整体拷贝,总拷贝量是 1+2+…+n,平方级增长。三万次拼接在现代机器上要数百毫秒,并且制造了三万个垃圾对象给 GC。StringBuilder 走的是另一条路:

var sb = new System.Text.StringBuilder(); for (int i = 0; i < 30_000; i++) sb.Append("x"); string result = sb.ToString(); // 只在最后分配一次

StringBuilder 内部维护字符缓冲区,装满按倍数扩容(摊还常数级),ToString 时才复制成 string。同样规模耗时通常在 1 毫秒以内,快两个数量级。

图 string 拼接与 StringBuilder 的分配对比

图 string 拼接与 StringBuilder 的分配对比

StringBuilder 不是越多越好

反过来滥用 StringBuilder 也很常见——两三个变量拼一句话,写上四五行 StringBuilder,可读性全无。少量拼接用插值字符串更清晰:

string msg = $"订单 {orderId} 共 {count} 件,合计 {total:F2} 元";

编译器把它翻译成 string.Format 调用(新版本翻译成更高效的插值处理器),一次分配,完全够用。经验法则:循环内拼接或长度不可预知用 StringBuilder;固定几段用插值;跨组件传模板用 FormattableString 捕获——最后这个冷门类型能保留格式化结构延迟求值,是日志库做结构化日志的基础。

其他高频操作的机制备注:Substring 会分配新串(旧的引用类型切片无法共享内存,高性能场景用 ReadOnlySpan<char> 切视图零分配,第四章性能节展开);string.Empty"" 经驻留后是同一实例,二者没有性能差异,选团队惯例即可;比较时 == 先查引用相等再逐字符比,遇到已驻留的常量字符串几乎是免费操作。

编码:一个必须建立的意识

string 内部是 UTF-16 编码,每个字符两个字节。这意味着 s.Length 数的是 UTF-16 码元不是"字符个数"——表情符号等增补平面字符会占两个码元,Length 会多算。文件与网络 IO 时必须显式指定编码(推荐 UTF-8 无 BOM),不指定的重载在 Windows 上可能读出系统默认代码页,中文环境下经典乱码问题多半源于此。把"字符串是 UTF-16 的对象,字节流是另一回事,转换必须显式"刻进意识,能省掉一整类 bug。

最后补一个真实排查案例收束本节。某报表服务每分钟把上万行日志拼成大文本写文件,上线一周后内存曲线呈锯齿状陡涨:开发者在循环外建了 StringBuilder,却在循环内先做字符串插值再追加——每行都先分配一个临时字符串,分配量翻倍,0 代回收随之狂飙。修复方式是把格式化延迟到缓冲区内部一次写入,让中间字符串彻底消失。这个案例的教训不是背 API,而是养成一个反射:看到循环里的字符串操作,先数一数这一圈分配了几次。不可变性决定了每次拼接都有价格,价格乘以循环次数就是你的 GC 曲线。

本节要点回顾

  • 三特性联动:不可变使内容判等与共享安全,驻留是共享的字面量落地形式;
  • 循环拼接是平方级灾难:StringBuilder 摊还常数级,两三个变量拼接用插值即可;
  • Intern 慎用:池近似全程序生存期,只适合高频重复常量;
  • Length 是 UTF-16 码元数:增补平面字符占两个码元;
  • 编码显式指定:IO 层不写编码参数是乱 bugs 之源。

第一章到此完成。带着内存模型进入第二章:类、属性、方法这些面向对象构件,在编译产物与运行时里各自是什么形状。


作者与出处
原作者: 灏天文库
来源:灏天文库
整理: 灏天文库整理
由灏天文库平台收录,内容或由平台用户上传,仅供学习交流
发布者: 作者: 灏天文库 转发
评论区 (0)
U