第 2 章:String 的底层实现与优化

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

第 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 面试要点

  1. String 为什么是不可变的?

    • 线程安全、哈希缓存、安全性、支持字符串驻留
  2. 字符串驻留池是什么?

    • CLR 维护的字符串缓存,相同字面量共享实例
    • 驻留的字符串不会被 GC 回收
  3. StringBuilder 的内部结构?

    • 链表结构的 char[] 缓冲区,ToString 时合并
  4. 什么时候用 StringBuilder?

    • 循环拼接、大量拼接(> 5 次)
  5. 如何高性能处理字符串?

    • ReadOnlySpan<char> 切片、string.Create、避免 Substring

练习

  1. 使用 ReferenceEquals 验证字符串驻留行为
  2. 用 BenchmarkDotNet 对比 +StringBuilderstring.Create 的性能
  3. 使用 ReadOnlySpan<char> 实现一个零分配的 URL 解析器
  4. 对比 StringComparison.OrdinalStringComparison.CurrentCulture 的性能

← 上一章:值类型与引用类型 | 下一章:集合类型的底层数据结构 →

评论

登录 后发表评论

暂无评论