C# 高性能写法(二):String
1. 🔤 选择合适的字符串构造方式
string 是不可变类型。每次修改都会产生新的字符串,并复制需要保留的内容。优化字符串构造的重点不是统一改用某个 API,而是根据数据规模和输出方式选择合适的方法。
| 场景 | 推荐写法 |
|---|---|
| 少量、固定数量的值 | 字符串插值或 + |
| 使用分隔符连接已有集合 | string.Join |
| 循环中逐步追加大量内容 | StringBuilder |
| 已知最终长度并需要直接填充 | string.Create |
| 结果写入已有缓冲区 | TryFormat |
1.1 少量拼接优先使用插值
string message = $"用户 {userId} 的角色是 {role}";
现代 C# 编译器和 .NET 运行时能够高效处理常见的字符串插值。只拼接几个值时,为此创建 StringBuilder 通常不会更快,反而增加代码和对象创建。
1.2 集合连接优先使用 string.Join
string csv = string.Join(",", values);
string.Join 已经处理了分隔符位置和最终字符串构造。不要为了连接已有集合,手动编写循环并在每轮处理“是否为第一个元素”。
1.3 循环追加使用 StringBuilder
循环中执行 result += value 会反复创建并复制字符串。输出由许多片段逐步组成时,可以改用 StringBuilder:
var builder = new StringBuilder(capacity: 256);
foreach (var item in items)
{
builder.Append(item.Name);
builder.AppendLine();
}
string result = builder.ToString();
合理的初始容量能减少内部缓冲区扩容,但不应随意指定一个远大于常见输出的数字。StringBuilder 也不是零分配:它需要内部缓冲区,ToString() 还会创建最终字符串。
1.4 string.Create 与 TryFormat
需要指定格式提供程序并直接生成最终字符串时,可以使用 string.Create:
string message = string.Create(
CultureInfo.InvariantCulture,
$"订单 {orderId},金额 {amount:F2}");
如果下游能够直接消费字符缓冲区,使用 TryFormat 可以避免创建独立字符串:
Span<char> buffer = stackalloc char[32];
if (amount.TryFormat(
buffer,
out int written,
"F2",
CultureInfo.InvariantCulture))
{
Console.WriteLine(buffer[..written]);
}
只有调用链能继续接收 ReadOnlySpan<char> 时,TryFormat 才能真正避免最终字符串分配。如果紧接着调用 ToString(),仍然会创建字符串。
2. ⚖️ 比较字符串时避免额外分配
2.1 不要先转换大小写
下面的写法会创建两个临时字符串,而且比较规则依赖当前区域性:
bool equals = left.ToLower() == right.ToLower();
内部标识符、协议字段和文件扩展名通常应直接指定序号比较规则:
bool equals = left.Equals(
right,
StringComparison.OrdinalIgnoreCase);
大小写敏感时使用 Ordinal,忽略大小写时使用 OrdinalIgnoreCase。只有面向用户的自然语言文本,才应根据业务选择 CurrentCulture 或 InvariantCulture 等区域性规则。
2.2 字典使用 StringComparer
字符串作为字典键时,不要在每次读写前调用 ToLower():
var users = new Dictionary<string, User>(
StringComparer.OrdinalIgnoreCase);
比较器会统一控制相等判断和哈希计算,避免规范化字符串产生的分配,也不会出现“比较时忽略大小写,但哈希规则不一致”的问题。
2.3 单字符操作使用 char 重载
bool hasSeparator = text.Contains(':');
int commaIndex = text.IndexOf(',');
bool isRooted = path.StartsWith('/');
搜索单个字符时使用 char 重载,语义更明确,也能让运行时走针对单字符的实现。代码分析规则 CA1847 也会建议用 Contains(char) 替代只包含一个字符的 Contains(string)。
3. 🔎 减少重复扫描和中间字符串
3.1 避免不必要的 Split 与 Substring
Split 会创建结果数组和多个子字符串,Substring 也会创建新字符串。热点解析代码可以先定位分隔符,再通过 Span 直接解析:
ReadOnlySpan<char> text = "user:10086";
int separator = text.IndexOf(':');
bool success = int.TryParse(
text[(separator + 1)..],
out int userId);
Span 适合解析和切片,但不应为了“零分配”强行进入所有普通业务代码。需要跨异步边界或长期保存数据时,还要改用 Memory<T> 或明确创建字符串。
3.2 避免连续 Replace 与 Trim
连续调用 Replace、Trim、ToLower 等方法,会多次扫描输入并产生中间字符串。位于热点路径且输入较大时,可以考虑一次扫描完成过滤、大小写判断和输出。
但自定义单次扫描通常会增加代码复杂度。输入很短、调用频率低时,保留清晰的内置 API 更合理;是否值得合并,应同时测量耗时和分配量。
3.3 使用 SearchValues 复用查找集合
SearchValues<T> 适合反复使用同一组字符或字节进行搜索:
private static readonly SearchValues<char> SlugCharacters =
SearchValues.Create(
"abcdefghijklmnopqrstuvwxyz0123456789-_");
static bool IsValidSlug(ReadOnlySpan<char> value) =>
!value.IsEmpty && value.IndexOfAnyExcept(SlugCharacters) < 0;
SearchValues 应创建一次并重复使用。如果每次调用都重新创建,初始化成本可能抵消搜索收益。只搜索一个字符时使用 IndexOf;只执行一次且字符很少时,直接使用相应的 IndexOfAny 重载通常更清晰。
4. 🧩 正则表达式只用于真正需要的场景
固定前缀、后缀、字符查找和简单分隔符解析,优先使用 StartsWith、EndsWith、IndexOf 或 SearchValues。这些 API 的意图更直接,也省去了正则解析和匹配状态机的成本。
同一个正则会反复使用时,可以通过 GeneratedRegex 在编译期生成实现:
public static partial class Validators
{
[GeneratedRegex("^[a-z0-9_-]+$", RegexOptions.CultureInvariant)]
public static partial Regex SlugRegex();
}
面对外部输入时,还要关注灾难性回溯。模式兼容时可以考虑 RegexOptions.NonBacktracking;否则至少设置合理的超时时间。不要仅为了追求速度而改变正则语义,必须使用真实输入验证结果。
5. ⚡ UTF-8 数据尽量不要先转成 string
.NET 字符串使用 UTF-16,而网络协议、JSON 和许多文件格式使用 UTF-8。如果数据原本就是 UTF-8,并且下游 API 也支持字节输入,在中间转换成 string 会增加解码、编码和内存分配。
固定的 ASCII 或 UTF-8 内容可以直接使用 UTF-8 字面量:
ReadOnlySpan<byte> authorizationPrefix = "AUTH "u8;
JSON 需要直接得到 UTF-8 数据时,可以使用:
byte[] json = JsonSerializer.SerializeToUtf8Bytes(value);
高吞吐协议处理还可以使用 Utf8JsonReader、Utf8.TryWrite 以及接受 ReadOnlySpan<byte> 的解析 API。关键原则是让数据尽可能以原有编码在调用链中传递,避免 byte[] → string → byte[] 的往返转换。
6. 🧱 避免为了消费结果再创建完整字符串
6.1 使用 StringBuilder.GetChunks
如果下游能够分块接收字符,可以直接枚举 StringBuilder 的内部块,而不是先调用 ToString():
foreach (ReadOnlyMemory<char> chunk in builder.GetChunks())
{
writer.Write(chunk.Span);
}
枚举期间不能继续修改 StringBuilder。如果调用方最终必须得到 string,直接调用 ToString() 更清晰。
6.2 复用 CompositeFormat
动态复合格式字符串会被高频重复使用时,可以缓存解析结果:
private static readonly CompositeFormat OrderFormat =
CompositeFormat.Parse("订单 {0},金额 {1:F2}");
string message = string.Format(
CultureInfo.InvariantCulture,
OrderFormat,
orderId,
amount);
普通的编译期字符串插值不需要这样处理。CompositeFormat 主要适合格式模板来自配置、资源或其他运行时数据,并且同一个模板会重复使用的场景。
6.3 高性能日志避免提前构造字符串
日志级别关闭时,提前执行插值或格式化属于无效工作。使用结构化日志参数、LoggerMessage 或 [LoggerMessage] 源生成器,可以把格式化推迟到确认需要写日志之后。
不要在调用日志 API 前手动执行 string.Format、ToString() 或复杂插值。如果某个参数计算成本较高,还应先判断对应日志级别是否启用。
7. ⚠️ 谨慎使用驻留、缓存和对象池
7.1 string.Intern 不是通用去重方案
string.Intern 适合数量有限、重复率很高且生命周期明确的值。对用户输入、请求参数或其他无界数据使用驻留,会让大量字符串长期占用内存。
7.2 字符串缓存必须有边界
缓存格式化结果可以减少重复计算,但缓存键持续增长时,内存成本可能高于重新生成字符串。需要同时设计容量、过期策略和并发行为。
7.3 不要默认池化 StringBuilder
池化可以减少高频大对象构造,但也会带来重置、线程安全和大缓冲区长期保留等问题。只有基准测试确认 StringBuilder 分配是实际瓶颈时,才值得引入对象池。
8. ✅ 选择顺序
优化字符串代码时,可以按以下顺序判断:
- 是否已有语义直接的内置 API,例如插值、
Join、IndexOf或StartsWith。 - 是否因为大小写转换、切片、拆分或编码转换产生了不必要的字符串。
- 是否在循环中重复追加、扫描或解析相同格式。
- 调用链能否直接消费 Span、UTF-8 字节或
StringBuilder分块。 - 优化后的代码是否使用真实数据证明耗时或分配有所下降。
字符串优化的收益通常来自少分配、少复制、少扫描,而不是把所有代码改成更底层的写法。低频业务路径优先保证可读性,热点路径再根据基准结果逐步替换。