.NET String 深度解析:存储布局、UTF-16编码与不可变性
2026/9/11 2:06:07 网站建设 项目流程

".NET的String类:存储、编码、不可变性"这个标题,如果你去搜,大概率会翻到一堆面试八股文。但我在实际写代码和排查线上问题的时候发现,很多三年五年的开发者也未必能把这三个词背后的东西讲清楚——string到底是值类型还是引用类型?为什么它内部用UTF-16而不是UTF-8?中文在UTF-8里占3个字节这件事,跟String内部存储到底有没有关系?不可变性到底换来了什么,又让我们付出了什么?

这篇文章不打算按教科书的方式来写。我尽量用平时在社区里跟人聊天、带新人的口吻,把String类的存储布局、编码方案、不可变性的设计逻辑,以及它们在实际项目中引发的一连串问题讲透。适合刚入手C#的人建立正确认知,也适合已经有几年经验、想把这块补完整的开发者。读完之后你再看那些"String拼接性能""Substring内存泄露""字符串乱码"之类的讨论,基本能自己判断对错了。

1. String的"身份"问题:引用类型,却长着值类型的脸

1.1 类型定义与"值语义"的错觉

先回答那个几乎每个 .NET 面试官都会问的问题:string 是值类型还是引用类型?

答案非常明确:string 是引用类型。它在 C# 里的定义是public sealed class String,继承自System.Object,没有任何 struct 的痕迹。但为什么很多初学者甚至部分老手会犹豫?因为它表现得实在太像一个值类型了:

string a = "hello"; string b = a; b = "world"; Console.WriteLine(a); // hello Console.WriteLine(b); // world

这段代码让人误以为 string 是值类型,因为修改 b 没有影响 a。但真相是:b = "world"不是修改了 b 指向的字符串对象(字符串对象根本没法修改),而是让 b 指向了另一个新建的字符串对象。a 还稳稳地指着原来的 "hello"。

这个"错觉"恰恰来自于我们这篇文章的核心主题之一:不可变性。因为字符串内容不可变,所以任何"修改"实际都是重新分配、重新赋值。看起来就像值类型的复制语义一样。

1.2 参数传递:引用传递到底意味着什么

另一个容易翻车的地方是参数传递。

void Change(string s) { s = "changed"; } string original = "original"; Change(original); Console.WriteLine(original); // 还是 original

很多人据此认为 string 是按值传递的,甚至有人认为 string 是值类型。正确的理解是:C# 的默认参数传递方式是按值传递,但这个"值"对于引用类型来说是引用的副本Change方法内部的s和外部original指向同一个字符串对象;但给s赋新值得不到外面,因为改的是副本。

这个身份问题不是纯粹的理论。在写自己的类时,你会遇到一个很现实的选择:一个属性到底该设计成 string 还是其他类型?如果你设计返回一个可变对象,调用方改了属性就把你内部状态污染了;而 string 不用担心这个,因为它根本改不了。这就是"活得像个值类型"的宝贵之处——传递引用不用害怕内容被篡改。

2. 存储真相:托管堆上的对象头、长度与内联字符数据

2.1 一个字符串对象在堆上到底长什么样

理解了"string是引用类型",下一个问题是:它里面的字符数据到底存在哪里?

在 .NET Framework 的老实现里,String 类内部会持有一个char[]数组的引用,字符数据存在这个单独分配的数组里。但从 .NET Core 2.1 开始,CLR 把字符数据直接内联到了 String 对象本身——也就是说,你在托管堆上分配一个 string 对象,拿到的是一整块连续内存:对象头(同步块索引 + 方法表指针)、字符串长度字段(int m_stringLength),然后紧跟着一串 UTF-16 的字符数据,最后还有一个不会计入 Length 的 NULL 终止符。

用一个 unsafe 代码片段就能看到这个布局:

string s = "Hello"; unsafe { fixed (char* p = s) { // p 指向字符串的第一个字符 for (int i = 0; i < s.Length; i++) { Console.Write(p[i]); } Console.WriteLine(); // 在 64 位进程里,p 往前偏移 8 字节是长度字段 // 再往前 8 字节是方法表指针,再往前是同步块索引 } }

正因为现代 .NET 字符串的字符数据是内联的,整个字符串对象在内存里是紧挨着的一段连续空间。这对 CPU 缓存极其友好,也解释了为什么 string 在多数场景下比其他语言里"字符数组对象 + 数据缓冲"的两段结构性能要好。

2.2 固定开销比你想象的大

很多人以为一个字符串对象占用的内存就是"字符数 × 2字节",这是被误导了。字符串对象有固定开销。

在 64 位进程里,一个空字符串对象的固定开销大概有二十多字节:对象头 16 字节 + 长度字段 4 字节 + 终止符和对齐。也就是说,你 new 一万个只包含一个字符 A 的字符串,每个对象都要付出二十多字节的"入场费",一万个字符串光固定开销就是两百多 KB,字符数据本身反而只占 2 万个字节。

我在给项目做内存优化时遇到过类似的场景:大量短小的字符串(比如把数字一个个转成字符串后存进集合)。表面上每个字符串只有一两个字符,实际上 GC 堆上密密麻麻的小对象给 GC 带来了不小的遍历压力。所以后来在代码评审里,我特别留意"批量创建短字符串"的写法——能复用就复用,能一次拼接就不要散落成多个对象。

2.3 String.Length 统计的是 char 数量,不是"字符数"

这里顺带引出 String 内部存储的一个核心单位:char。在 .NET 里,char是 16 位的 UTF-16 code unit,string.Length返回的是字符串里 char 的数量。

对绝大多数常见字符(ASCII 字母、数字,以及中文常用汉字)来说,字符串里一个"字"确实等于一个 char。但一旦碰到超出基本多语言平面(BMP)的字符,事情就不对了。emoji 表情 "😀" 的码点是 U+1F600,超过了 16 位能表示的范围,在 UTF-16 编码下需要使用一对代理项(surrogate pair)来表示,也就是 2 个 char。所以:

string emoji = "😀"; Console.WriteLine(emoji.Length); // 输出 2,而不是 1

很多人踩过Substring(0, 1)把一个 emoji 截成半个的坑。这本质上就是"存储单位是 char,不是 Unicode 字符"造成的。后面聊编码的时候我还会从这个点切入。

3. 编码决策:String内部为什么坚持UTF-16,中文为什么在UTF-8下占3字节

3.1 三种常见编码的字节占用对比

先看一个对照表。同样是几个代表性字符,在不同编码方案下占用的字节数完全不同:

字符说明ASCIIUTF-8UTF-16
A英文字母1 字节1 字节2 字节
中文常用汉字不支持3 字节2 字节
😀emoji(非BMP)不支持4 字节4 字节(2个char)

有一个高频搜索词是:"为什么在 utf-8 编码中,中文字符通常占用的字节数比英文字符多?"答案就在 UTF-8 的编码规则里:UTF-8 是变长编码,码点在 U+0000 到 U+007F 区间的字符用 1 字节(兼容 ASCII);码点在 U+0080 到 U+07FF 用 2 字节;U+0800 到 U+FFFF 用 3 字节;超过 U+FFFF 用 4 字节。中文字符大多在 U+4E00 到 U+9FFF 范围内,落在"3 字节区",所以中文在 UTF-8 下普遍是 3 字节。英文因为码点小,始终只需要 1 字节。

而 .NET String 内部使用的 UTF-16 是另一种变长编码:码点在 BMP(U+0000~U+FFFF)内的字符用 1 个 code unit(2 字节)表示,超出 BMP 的用一对代理项(4 字节)。所以常见的中文字符在 .NET 内存里统统是 2 字节,而不是 UTF-8 里的 3 字节。

3.2 为什么 .NET 当年选 UTF-16 而不是 UTF-8

这是很多人会问的"为什么"。用现在的眼光看,UTF-8 在存储英文为主的文本时更省内存,在网络传输里也是绝对主流,那微软为什么让 String 内部用 UTF-16?

原因是历史包袱加性能取舍。Windows NT 体系从 90 年代开始就把系统内部编码定为 Unicode(早期叫 UCS-2,后来规范化为 UTF-16),Win32 API 的宽字符版本 W 系 API、COM 的 BSTR、OLE 自动化全部使用 UTF-16。.NET 的设计目标之一就是跟这些原生组件无缝互操作。如果 String 内部用 UTF-8,每次调用 Win32 函数或 COM 接口都要做编码转换,在那个年代这种转换成本是不可接受的。另外 UTF-16 对 BMP 内的字符可以 O(1) 索引(s[i]直接就是第 i 个 char),而 UTF-8 要定位第 i 个字符得扫描变长序列,这在当年的 CPU 上是很痛的。

这个选择放到今天确实有代价:哪怕你的程序只处理纯英文字符串,每个字符在内存里也要占 2 字节。但这是互操作便利、索引速度和存储密度的权衡结果。理解了这个背景,你就能明白为什么 .NET 里字符串长度、Substring、哈希计算都基于 UTF-16 的 char,而不是 UTF-8 的字节。

3.3 编码转换的隐藏成本

String 内部用 UTF-16,但你的程序几乎不可能只活在内存里。文件要落盘、JSON 要出网、请求要进数据库,这些外部边界大多用 UTF-8 或别的编码。于是每次"字符串 ↔ 字节数组"的转换都意味着一次编码翻译和一次数据复制。

string text = "你好,世界"; byte[] utf8Bytes = Encoding.UTF8.GetBytes(text); // UTF-16 -> UTF-8 string copy = Encoding.UTF8.GetString(utf8Bytes); // UTF-8 -> UTF-16

Encoding.UTF8.GetBytes会分配一个新的 byte 数组,GetString会分配一个新的 string 对象。在高频文本处理路径里,这种双向转换的分配量非常可观。我见过有团队在解析大体积文本协议时,每一条消息都走一遍Encoding.UTF8.GetString,结果是 CPU 时间大量花在编码转换和 GC 上。正确的做法是尽量复用缓冲区、用Encoding.UTF8.GetString(byte[], int, int)只转当前需要的一段,或者在更底层用ReadOnlySpan<byte>配合流处理避免一次性大分配。

4. 不可变性:拿安全换来的四样东西,以及你必须付的代价

4.1 为什么设计者如此"固执"

网上有个比喻我觉得挺贴切:字符串就像石碑上刻的字,你想改内容,只能另外刻一块新的石碑,原来的那块永远不会变。这个"固执"的设计不是没有理由的。String 的不可变性至少给整个运行时带来了四样实质回报。

第一是线程安全。多个线程同时读同一个字符串实例,不需要任何加锁,因为没人能修改它。这个保证对服务端程序尤其重要——你永远不用因为共享一个字符串而担心数据竞争。第二是引用共享的安全。大量场景里我们把同一个字符串引用传来传去,因为不可变,传递引用等于传递一份永远不会被外部篡改的数据,不需要做防御性拷贝。第三是作为集合键的可靠性。Dictionary<string, T>之所以是 .NET 里最常用的集合之一,就是因为字符串作为 key 不会在存进去之后被修改导致哈希槽错乱。第四是驻留池(Intern Pool)可以成立。正因为内容不变,CLR 才能安全地把内容相同的字符串收敛为同一个对象,后面我会细说。

4.2 为不可变付出的代价:每个"修改"都是一次分配

硬币背面是:String 有大量"看起来像是修改"的方法,ToUpper()Replace()Trim()Substring()Remove(),以及你我每天都在用的+拼接,本质上都不是修改原字符串,而是创建一个全新的字符串对象,把原字符串的内容复制一遍,再略作调整。

于是性能问题就来了。看这个经典循环:

string result = string.Empty; for (int i = 0; i < 100000; i++) { result += i.ToString(); }

每轮循环至少产生两个新对象:i.ToString()产生一个新 string,result += ...又产生一个新 string。十万次循环就是接近二十万个字符串对象不断创建、抛弃,Gen0 被反复打爆,GC 忙得不可开交。而如果用 StringBuilder,整个过程只在一个可扩容的char[]缓冲区里追加,避免了大量中间对象。这个对比在我做基准测试时差距非常明显,在小数据集上可能只有几倍差异,在几十万次拼接时能差出上百倍。

4.3 字符串驻留:不可变性带来的"共享"红利

字符串驻留是很多人听说过但没真正理解的功能。CLR 进程内维护着一张哈希表(Intern Pool),存的是字符串对象引用。当你代码里写了字符串字面量时,CLR 会把这个字面量驻留到池子里,后续再遇到内容相同的字面量,直接复用同一个对象。

string a = "abc"; string b = "abc"; Console.WriteLine(ReferenceEquals(a, b)); // True,字面量驻留 string c = new string(new[] { 'a', 'b', 'c' }); string d = new string(new[] { 'a', 'b', 'c' }); Console.WriteLine(ReferenceEquals(c, d)); // False,运行时创建的新对象 string e = "a" + "bc"; // 编译期常量折叠,结果是字面量 "abc" Console.WriteLine(ReferenceEquals(a, e)); // True

这里面有一个编译器层面的小帮手:常量折叠。编译器在编译期就能算出"a" + "bc"的结果,直接生成长量 "abc",而不是生成运行时拼接代码。这解释了那个经典的面试判断。

但驻留池不是免费的午餐。被驻留的字符串生命周期跟进程一样长,永远不会被 GC 回收。如果你对大量动态生成的字符串调用string.Intern()去重,驻留池会越来越大,反而成为内存压力。我在实际项目里只在两种情况用过 Intern:一是元数据、枚举名这种数量有限且高频重复的字符串;二是确认内存中确实存在海量重复副本,且去重收益远大于驻留池本身的开销时。普通业务场景不要轻易碰它。

5. 性能实战:从String.Concat到StringBuilder再到Span

5.1 编译器和运行时为你做的优化:能省则省

写代码的时候,很多人没意识到 C# 编译器已经替我们做了不少字符串优化。最常见的两个是常量折叠和String.Concat重载选择。

常量折叠刚才提到了。只要拼接表达式里所有操作数都是编译期常量(const stringconst char、string 字面量),编译器就把它们合并成一个字符串字面量。所以"a" + "b" + "c"在 IL 里没有一次 Concat 调用。反过来,如果拼接里有非 const 变量,编译器就会生成String.Concat调用,而且 Concat 有非常多重载,Concat(string, string)Concat(string, string, string),以及Concat(params string[])Concat(params object[])。用params string[]会多分配一个数组,用params object[]还会有值类型装箱。所以做小规模拼接时,直接写string.Concat(a, b, c, d)$"{a}{b}{c}{d}"通常比在循环里拼效率更好。

还有一个经常被误用的点是string.Format和插值字符串的取舍。在 .NET Framework 里,$"..."会被编译成string.Format调用,参数里有值类型要装箱,带着格式解析的开销。但从 .NET 6 开始,编译器会把插值字符串降级为DefaultInterpolatedStringHandler,这是一个 struct 类型的追加器,用 ref struct 的方式避免了大部分装箱和临时字符串分配。所以现在的结论是:能用插值就用插值,大多数场景下它比string.Format快,也比手写string.Concat可读性好。如果目标框架还是老版本,且拼接次数很少,两者差异也不是很大,按可读性选。

5.2 StringBuilder:预分配容量,但别滥用

StringBuilder 的内部就是一个可变的char[]缓冲区,构造时默认容量是 16 个字符,容量不够时会按约两倍扩容,把旧缓冲复制到新缓冲。这意味着反复扩容会有额外的复制开销。所以一个很重要的习惯是:只要能预估最终字符串的大致长度,就在构造时传入容量:

var sb = new StringBuilder(4096);

这样能减少扩容次数。注意 StringBuilder 的实例方法不是线程安全的,多线程同时 Append 需要外部加锁。另外它也不是万能的——如果你只是要拼接两三个短字符串,sb.Append的固定开销比直接Concat更高,这时候杀鸡用了牛刀。我在代码评审里经常看到的场景是:有人为了把一个不超过 5 段的内容拼起来,写了一个 StringBuilder,完全没必要。在字符串就只有三四次拼接时,编译器生成的Concat或插值足以高效解决。

5.3 Span 与 string.Create:现代 .NET 的零拷贝思路

如果说 StringBuilder 还是"分配一个可变的字符串缓冲区",那Span<char>带来的则是完全不一样的思路:不分配,直接在原数据上切视图

string.AsSpan()可以把一个字符串当成只读的字符区间来访问,切片Substring需要创建新字符串对象,而AsSpan(start, length)是零分配的:

string line = "2025-01-01 12:00:00 [INFO] some log message"; ReadOnlySpan<char> timeSpan = line.AsSpan(11, 8); // "12:00:00",不分配新对象

当我们只需要读取片段而不需要把片段存起来时,用 Span 切片替代 Substring 可以大幅减少临时字符串的分配。我重构过一个日志解析器:原先每条日志要 Substring 出时间、级别、消息三个字符串,每秒几万条日志,GC 压力巨大;改成 Span 切片后,只在真正需要复制某一段(比如把解析出的 ID 作为字典 key)时再 new string,整体分配量降了一个数量级。需要注意 Span 是 ref struct,不能存在堆上,也不能在异步方法里越过await保存,所以它的使用范围有边界,但在这个边界内非常强。

另一个进阶工具是string.Create<TState>()。它允许你先分配一个目标字符串,然后在回调里直接往字符串的字符缓冲区里填充内容,不需要额外的 char[] 中转。常见场景是从 UTF-8 字节数据构造字符串:与其先Encoding.UTF8.GetString得到一个完整字符串再截取,不如在Create的回调里只填充需要的那部分字符。这属于偏底层的优化,写库、写高性能网络框架时很香,业务代码里用到的机会不多,但了解它有助于理解"字符串内存是连续可写的视图"这一现代 .NET 设计。

6. 复盘三个真实事故:日志、截取与"锟斤拷"乱码

6.1 高频日志拼接引发的 GC 风暴

有一年我给一个网关服务做性能排查,现象是 CPU 不高,但 GC 频率异常高,系统吞吐始终上不去。用dotnet-counters看了一眼:

dotnet-counters monitor --process-id 12345 --counters System.Runtime

Gen 0 GC Count每秒都在往上涨,Allocated Bytes/秒惊人。进一步抓 dump 分析,发现热路径上有大量 string 对象堆积。定位代码后发现,这个服务在每次转发消息时都会构造一条包含完整消息体的大日志:

_logger.LogInformation($"Received message: {body}, headers: {headers}, latency: {sw.Elapsed}");

body是个几 KB 的字符串,headers也是个几十行的拼接结果,这行日志每秒执行上百次,等于每秒在堆上分配几 MB 的临时字符串。修法很朴素:日志降级为结构化日志,把 body 长度、关键 header 字段、延迟这些拆成结构化参数,用真正的日志框架占位符而不是拼一个大字符串;同时把非关键路径的日志级别调到 Debug,生产环境根本不打。改完 GC 次数明显回落。这个事故给我留下的经验是:日志框架的占位符{body}和直接插值$"...{body}"是有本质区别的,前者在不需要输出时可以完全跳过字符串构造,后者无论日志级别是什么,插值已经在执行前把整个字符串拼出来了。

6.2 一个"截取"引发的内存不释放

另一个案例是 .NET Framework 老服务,现象是服务跑几天后内存稳步上涨,最后被迫定期重启。dump 里看到一堆不大不小的 String 对象,再往上层找,发现一批从超大字符串里Substring出来的子串对应的字典键组织里的对象,一直引着一个很大的原始文本对象。

老资料里经常提到: .NET Framework 某些版本下Substring的内部实现会让新字符串与原始字符串共享底层char[]数组,也就是说你只是截取了 20 个字符,但它背后那个 10 MB 的大数组仍然被"短命"子串间接持有,导致 GC 无法回收。这个具体机制在不同 CLR 版本里有差异;在 .NET Core 把字符数据内联到 String 对象之后,Substring会实际复制一份独立的字符数据,不再共享底层数组。但"沿用旧框架、缓存大文本、从里面截小段并长期持有"这个模式依然危险——就算底层不共享数组,大字符串对象本身若被缓存或闭包间接引用,内存照样不释放。

这次我学到的排查思路是:遇到内存不降,不要只盯着"谁创建了这些对象",还要看"谁还在引用着它们"。用 windbg 或 dotnet-dump 查对象的 GC 根(GC root),顺着引用链往上找,十有八九能发现某个静态缓存、事件订阅或者异步状态机把大家伙兜住了。修复方案有时候只是把生命周期很长的对象换成短生命周期的,或者改成用Span<char>切片后立即处理,不要为了长期持有而截取出一堆小字符串。

6.3 "锟斤拷"乱码的编码边界问题

最后一个案例比较经典,也是很多人问"String 编码"时真正关心的实际问题:中文乱码。

那次是同事的接口,从上游系统拿回来的文件名传给文件服务,存到 Linux 服务器上再取回来,名字就变成了一堆 "锟斤拷烫烫烫"。我一看这个特征就知道,UTF-8 的字节流在某个环节被当成 GBK/GB2312 解码了——"锟斤拷"三个字就是典型的 UTF-8 汉字字节序列被 GBK 双字节解释后的产物,属于不可逆的二次转码。

问题出在两个地方:上游返回的 HTTP header 和消息体里没有明确的 charset,下游读取时用了Encoding.Default。注意 .NET Core 里的Encoding.Default默认是 UTF-8,而 .NET Framework 里的Encoding.Default是系统 ANSI(中文系统下是 GB2312/GBK)。同一套代码跑在不同运行时会得到不同的解码结果,这种"隐式默认编码"是乱码的最常见源头。

修法是全链路锁死编码:上游明确返回 UTF-8,下游用Encoding.UTF8而不是Encoding.Default读取;数据库连接串、文件读写流、FTP 传输、HTTP 请求头,只要出现文本和字节互转的边界,都要显式指定编码。乱码一旦发生,很多已经回不去了,所以正确的姿势是在所有边界提前把编码钉死。

复盘完这三个案例,你会发现它们背后其实都指向同一个认知:String 的存储、编码和不可变性不是三条独立的技术点,而是一条逻辑链。存储决定了它用什么单位去表示字符,编码决定了它对外交换字节时怎么做映射,不可变性决定了所有看上去的"修改"真实成本有多高。把这三点想明白了,很多看似突如其来的性能和 bug 问题,都会变得顺理成章。

最后再分享一个我在日常编码里养成的习惯:写任何接收或返回字符串的公共方法时,先问自己一句——这个方法必须创建新字符串吗?如果只是读取一段文本的一部分,用ReadOnlySpan<char>传递视图;如果确实要返回子串且调用方可能长期持有,那就在方法名和文档里写清楚这是一次复制。这种"先想清楚要不要分配"的思维方式,比背任何性能优化口诀都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询