一、 String 的不变性与 StringBuilder 的最佳实践

1. 什么时候该用 StringBuilder?

以前我习惯在任何地方都直接用 +$符号去拼接字符串,直到在处理大量流水数据导出或者高频日志拼接时,发现程序直接卡死,内存占用飙升。

核心原因在于 C# 中的string具有不可变性。每次执行 str += "abc",系统其实并没有在原文本后面追加,而是在堆内存里重新开辟空间创建了一个新字符串,并把老字符串丢弃变成内存垃圾。

因此,划分使用场景的界限非常明确:

  • 用 String 的场景:少量的字符串拼接(如 3 次以内),或者纯粹的常量文本组合。此时编译器会自动优化,性能反而最好。
  • 用 StringBuilder 的场景:处于 for/foreach 循环内部的拼接,或者需要高频、动态组装大量文本的场景。

2. StringBuilder 的核心优势

换成 StringBuilder 后,它之所以能解决卡顿问题,是因为它在内部机制中维护了一个可变的字符缓冲区(char 数组)。所有追加操作(Append)都是直接在这个数组上进行修改,避免了频繁创建新对象和销毁旧对象造成的 GC(垃圾回收)压力。

3. 必须注意的隐形坑点:预估容量

虽然换成了 StringBuilder,但如果不注意初始化的细节,依然会踩到性能坑。

StringBuilder 在默认实例化时,官方给的初始容量其实只有 16 个字符。一旦追加的内容超过这个长度,它内部就会自动扩容(通常是容量翻倍)。而扩容的本质,依然是在堆上开辟更大的新数组,并把老数据拷贝过去。

如果我们要拼接一个几千字的大文本,它在中途会连续触发七八次自动扩容,产生不必要的内存拷贝开销。

避坑写法:
只要能大致预估最终文本的长度,初始化时一定要显式指定容量,一步到位:

1
2
// 显式指定 1024 字符容量,彻底规避中途高频触发内部自动扩容的开销
StringBuilder sb = new StringBuilder(1024);

4. 反思:String 既然有缺陷,为什么还要设计成不可变?

这个问题当时也促使我查阅了官方设计文档。发现不可变性虽然在频繁拼接时是个坑,但在整体架构上反而是 CLR 的经典优化:

  • 字符串驻留机制:正因为不可变,相同字面量的字符串在内存里只需要存一份(驻留池),大家都指向同一个地址,极大地节省了空间。
  • 天生线程安全:多个线程同时读取同一个字符串,不需要加任何锁,因为谁也改不了它,绝对安全。
  • 哈希值缓存:它的 HashCode 在创建时就固定并缓存了,这让它在作为Dictionary的 Key 时,查找效率奇快无比。

二、 DateTime 的时区陷阱与转换安全

除了字符串,时间处理也是项目里的“重灾区”。

1. DateTime.Now 的时区硬编码

以前写代码习惯了到处用 DateTime.Now 获取当前时间。但在写一些涉及高频日志记录,或者准备把系统部署到云端 Docker 容器里时,这个习惯会带来巨大隐患:

  • DateTime.Now 每次调用都会去查询操作系统的本地时区设置,并进行时区转换,它的性能开销比 DateTime.UtcNow 大得多。
  • 更严重的是,云端服务器或数据库默认通常是 UTC(格林威治时间),直接用 Now 拿到的时间会和国内产生 8 个小时的时间差。

最佳实践: 项目代码内部的逻辑计算、数据库存储、接口传输,一律统一使用 DateTime.UtcNow。只有在最终前端或者控制台需要展示给国内用户看的时候,再通过 .ToLocalTime() 转成本地时间。

2. 拒绝使用 Parse 赌运气

在处理外部输入或者解析配置文件里的日期时,直接用 DateTime.Parse() 是一颗定时炸弹。万一字符串为空、或者格式稍微带了点乱码,程序直接抛出异常导致闪退。

1
2
3
4
5
6
7
8
// 危险:随时可能崩溃
DateTime dt = DateTime.Parse(inputStr);

// 安全:利用 TryParse 筑起类型转换的防火墙
if (DateTime.TryParse(inputStr, out DateTime result))
{
// 转换成功,放心使用 result
}

三、 高并发下的伪随机数与高精度计算

1. 为什么 Random 出来的数字全是一样的?

在写批量生成测试数据或者验证码的循环时,我曾经遇到过随机数一模一样的怪事:

1
2
3
4
5
for (int i = 0; i < 10; i++)
{
Random rand = new Random(); // 错误:在循环内部重复实例化
Console.WriteLine(rand.Next(1, 100));
}

原理解析:Random 是伪随机数生成器,它依赖一个“种子(Seed)”来计算随机序列。默认不传参时,它拿系统当前时间当种子。 因为现代 CPU 执行速度太快了,这 10 次循环几乎是在同一微秒内瞬间完成的,导致每次实例化拿到的时间种子完全相同,算出来的随机数自然也就一模一样。

解决办法:

  • 如果是老版本 .NET,必须把 Random 拿到循环外面实例化,大家复用同一个实例。
  • 如果是 .NET 6+,官方提供了一个更优雅、且线程安全的属性,直接调用即可:Random.Shared.Next(1, 100)

2. 精密计算决不能碰 double

在处理 PLC 称重传感器数据、或者涉及金额账目时,绝对不能贪图省事使用 doublefloat

因为计算机底层采用二进制存储,像0.1这种十进制数在二进制里是无限循环小数,计算时会有无法避免的精度截断。你以为 0.1 + 0.2 等于 0.3,计算机算出来的可能是 0.30000000000000004

而且 Math.Round() 默认用的是“银行家舍入法”(四舍六入五成双,例如 2.5 会变成 2,3.5 会变成 4),不是我们直观理解的四舍五入。

结论:只要涉及高精度对接和账目,必须强制使用 decimal 类型。

四、 个人复盘心得

通过这次对常用类底层机制的梳理,我自己最大的心得以总结为三点:

  1. 对内存保持敬畏:很多时候程序卡顿、内存泄漏,不是什么高大上的架构问题,往往就是我们在循环里随意用 + 拼接字符串、或者频繁实例化Random导致的琐碎垃圾。高频用 StringBuilder,高频时间用 UtcNow,应该写进肌肉记忆。
  2. 永远不要相信外部输入:无论是 Console.ReadLine() 拿到的文本,还是接口传过来的日期字符串,永远不要直接去 Parse。多写一行 TryParse,系统就少产生一个线上崩溃的 Bug。
  3. 看清底层再下手:写代码不能只看表面。明白 String 为什么不可变、明白 double 为什么会丢失精度,才能真正做到心里有底,给出最稳妥的解法。