第 2 章:String 的底层实现与优化
jerry2026年4月9日C# 10 次阅读 约 15 分钟

第 2 章:String 的底层实现与优化
深入理解 String 的不可变性原理、字符串驻留池机制、StringBuilder 的工作方式,以及高性能字符串处理。
2.1 String 的内存结构
System.String 在 CLR 中的内存布局:
┌──────────────────────────────────────────┐
│ 对象头 (Object Header) 8 bytes │
│ 方法表指针 (Method Table) 8 bytes │
│ _stringLength (int) 4 bytes │
│ _firstChar (char) 2 bytes │ ← 字符数据从这里开始
│ ... 后续字符 ... │
│ '\0' (null terminator) 2 bytes │ ← 为了与 C/C++ 互操作
└──────────────────────────────────────────┘
关键点:
- String 是引用类型,但大小不固定(字符数据内联在对象中)
- 内部使用 UTF-16 编码,每个
char占 2 字节 - 末尾有一个
\0终止符(为了 P/Invoke 兼容) Length属性直接读取_stringLength字段,O(1)
string s = "Hello";
// 对象大小 = 对象头(8) + 方法表(8) + 长度(4) + 字符数据(5*2) + 终止符(2) + 对齐填充
// 实际约 32-40 bytes(取决于对齐)
2.2 不可变性(Immutability)
String 一旦创建就不能修改。所有看起来"修改"字符串的操作都会创建新的 String 对象:
string s = "Hello";
s += " World";
// 实际过程:
// 1. 创建新的 String 对象 "Hello World"(11 个字符)
// 2. s 指向新对象
// 3. 旧的 "Hello" 对象等待 GC 回收
为什么设计为不可变?
- 线程安全:多个线程可以安全地共享同一个 String 对象
- 哈希缓存:String 的哈希值可以缓存,用作 Dictionary 的 key 非常高效
- 安全性:防止字符串被意外修改(如密码、连接字符串)
- 字符串驻留:不可变才能安全地共享实例
不可变的代价
// 差:每次拼接都创建新对象
string result = "";
for (int i = 0; i < 10000; i++)
{
result += i.ToString(); // 每次循环创建一个新 String
}
// 创建了约 10000 个临时 String 对象,O(n²) 复杂度
// 好:使用 StringBuilder
var sb = new StringBuilder();
for (int i = 0; i < 10000; i++)
{
sb.Append(i);
}
string result = sb.ToString(); // 只创建一个最终的 String
2.3 字符串驻留池(String Interning)
CLR 维护一个字符串驻留池(Intern Pool),相同内容的字符串字面量共享同一个实例:
string a = "Hello";
string b = "Hello";
Console.WriteLine(ReferenceEquals(a, b)); // True!同一个对象
// 运行时创建的字符串不会自动驻留
string c = new string(new char[] { 'H', 'e', 'l', 'l', 'o' });
Console.WriteLine(ReferenceEquals(a, c)); // False
// 手动驻留
string d = string.Intern(c);
Console.WriteLine(ReferenceEquals(a, d)); // True
// 检查是否已驻留(不创建新的驻留)
string e = string.IsInterned(c); // 返回驻留的实例或 null
驻留池的特点
- 编译时字符串字面量自动驻留
- 驻留的字符串永远不会被 GC 回收(生命周期 = 应用程序生命周期)
- 过度使用
string.Intern()会导致内存泄漏 - 适合:大量重复的、长期存在的字符串(如配置键名、枚举字符串)
// 适合驻留的场景
var config = new Dictionary<string, string>();
foreach (var line in configLines)
{
var parts = line.Split('=');
var key = string.Intern(parts[0].Trim()); // key 会大量重复
config[key] = parts[1].Trim();
}
2.4 StringBuilder 的工作原理
StringBuilder 内部使用链表结构的 char[] 缓冲区:
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ chunk 3 │ → │ chunk 2 │ → │ chunk 1 │
│ char[16] │ │ char[16] │ │ char[16] │
│ "orld!" │ │ "llo, W" │ │ "He" │
└──────────────┘ └──────────────┘ └──────────────┘
// 指定初始容量(避免扩容)
var sb = new StringBuilder(256);
sb.Append("Hello");
sb.Append(", ");
sb.Append("World!");
sb.AppendLine();
sb.AppendFormat("Count: {0}", 42);
// 插入和替换
sb.Insert(0, ">>> ");
sb.Replace("World", ".NET");
string result = sb.ToString(); // 将所有 chunk 合并为一个 String
StringBuilder vs string 拼接的选择
| 场景 | 推荐 |
|---|---|
| 2-3 个字符串拼接 | string.Concat 或 + |
| 已知所有部分 | string.Concat 或字符串插值 |
| 循环中拼接 | StringBuilder |
| 大量拼接(> 5 次) | StringBuilder |
| 格式化输出 | 字符串插值 $"" |
// .NET 6+ 字符串插值优化
// 编译器会自动使用 DefaultInterpolatedStringHandler,减少分配
string name = "Alice";
int age = 30;
string s = $"Name: {name}, Age: {age}";
// 编译器优化后,不会创建中间字符串
2.5 高性能字符串处理
string.Create(.NET Core 2.1+)
直接在目标 String 的内存上写入,避免中间分配:
// 创建一个 10 个字符的字符串,直接写入
string result = string.Create(10, 42, (span, state) =>
{
// span 是目标 String 的内部缓冲区
"Value: ".AsSpan().CopyTo(span);
state.TryFormat(span[7..], out _);
});
ReadOnlySpan<char> 切片
string path = "/api/v1/users/123";
// 传统方式:Substring 创建新字符串
string segment = path.Substring(8, 5); // "users" — 堆分配
// 高性能方式:Span 切片,零分配
ReadOnlySpan<char> span = path.AsSpan(8, 5); // "users" — 栈上,无分配
// 常见操作
ReadOnlySpan<char> trimmed = path.AsSpan().Trim('/');
bool startsWith = path.AsSpan().StartsWith("/api");
int index = path.AsSpan().IndexOf('/');
SearchValues(.NET 8+)
// 预编译搜索值,使用 SIMD 加速
private static readonly SearchValues<char> Vowels =
SearchValues.Create("aeiouAEIOU");
bool containsVowel = "Hello".AsSpan().ContainsAny(Vowels);
int firstVowel = "Hello".AsSpan().IndexOfAny(Vowels);
2.6 字符串比较
// 序数比较(逐字节比较,最快)
string.Equals(a, b, StringComparison.Ordinal);
string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
// 文化敏感比较(考虑语言规则,较慢)
string.Equals(a, b, StringComparison.CurrentCulture);
string.Equals(a, b, StringComparison.InvariantCulture);
// Dictionary 的 key 比较
var dict = new Dictionary<string, int>(StringComparer.OrdinalIgnoreCase);
选择建议:
- 内部标识符、路径、协议头 →
Ordinal/OrdinalIgnoreCase - 用户可见的文本排序 →
CurrentCulture - 跨文化的持久化数据 →
InvariantCulture
2.7 面试要点
-
String 为什么是不可变的?
- 线程安全、哈希缓存、安全性、支持字符串驻留
-
字符串驻留池是什么?
- CLR 维护的字符串缓存,相同字面量共享实例
- 驻留的字符串不会被 GC 回收
-
StringBuilder 的内部结构?
- 链表结构的 char[] 缓冲区,ToString 时合并
-
什么时候用 StringBuilder?
- 循环拼接、大量拼接(> 5 次)
-
如何高性能处理字符串?
ReadOnlySpan<char>切片、string.Create、避免 Substring
练习
- 使用
ReferenceEquals验证字符串驻留行为 - 用 BenchmarkDotNet 对比
+、StringBuilder、string.Create的性能 - 使用
ReadOnlySpan<char>实现一个零分配的 URL 解析器 - 对比
StringComparison.Ordinal和StringComparison.CurrentCulture的性能
评论
登录 后发表评论
暂无评论