C# 常用类复盘
一、 String 的不变性与 StringBuilder 的最佳实践
1. 什么时候该用 StringBuilder?
以前我习惯在任何地方都直接用 + 或$符号去拼接字符串,直到在处理大量流水数据导出或者高频日志拼接时,发现程序直接卡死,内存占用飙升。
核心原因在于 C# 中的string具有不可变性。每次执行 str += "abc",系统其实并没有在原文本后面追加,而是在堆内存里重新开辟空间创建了一个新字符串,并把老字符串丢弃变成内存垃圾。
因此,划分使用场景的界限非常明确:
- 用 String 的场景:少量的字符串拼接(如 3 次以内),或者纯粹的常量文本组合。此时编译器会自动优化,性能反而最好。
- 用 StringBuilder 的场景:处于
for/foreach循环内部的拼接,或者需要高频、动态组装大量文本的场景。
2. StringBuilder 的核心优势
换成 StringBuilder 后,它之所以能解决卡顿问题,是因为它在内部机制中维护了一个可变的字符缓冲区(char 数组)。所有追加操作(Append)都是直接在这个数组上进行修改,避免了频繁创建新对象和销毁旧对象造成的 GC(垃圾回收)压力。
3. 必须注意的隐形坑点:预估容量
虽然换成了 StringBuilder,但如果不注意初始化的细节,依然会踩到性能坑。
StringBuilder 在默认实例化时,官方给的初始容量其实只有 16 个字符。一旦追加的内容超过这个长度,它内部就会自动扩容(通常是容量翻倍)。而扩容的本质,依然是在堆上开辟更大的新数组,并把老数据拷贝过去。
如果我们要拼接一个几千字的大文本,它在中途会连续触发七八次自动扩容,产生不必要的内存拷贝开销。
避坑写法:
只要能大致预估最终文本的长度,初始化时一定要显式指定容量,一步到位:
1 | // 显式指定 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 | // 危险:随时可能崩溃 |
三、 高并发下的伪随机数与高精度计算
1. 为什么 Random 出来的数字全是一样的?
在写批量生成测试数据或者验证码的循环时,我曾经遇到过随机数一模一样的怪事:
1 | for (int i = 0; i < 10; i++) |
原理解析:Random 是伪随机数生成器,它依赖一个“种子(Seed)”来计算随机序列。默认不传参时,它拿系统当前时间当种子。 因为现代 CPU 执行速度太快了,这 10 次循环几乎是在同一微秒内瞬间完成的,导致每次实例化拿到的时间种子完全相同,算出来的随机数自然也就一模一样。
解决办法:
- 如果是老版本 .NET,必须把 Random 拿到循环外面实例化,大家复用同一个实例。
- 如果是 .NET 6+,官方提供了一个更优雅、且线程安全的属性,直接调用即可:
Random.Shared.Next(1, 100)。
2. 精密计算决不能碰 double
在处理 PLC 称重传感器数据、或者涉及金额账目时,绝对不能贪图省事使用 double 或 float。
因为计算机底层采用二进制存储,像0.1这种十进制数在二进制里是无限循环小数,计算时会有无法避免的精度截断。你以为 0.1 + 0.2 等于 0.3,计算机算出来的可能是 0.30000000000000004。
而且 Math.Round() 默认用的是“银行家舍入法”(四舍六入五成双,例如 2.5 会变成 2,3.5 会变成 4),不是我们直观理解的四舍五入。
结论:只要涉及高精度对接和账目,必须强制使用 decimal 类型。
四、 个人复盘心得
通过这次对常用类底层机制的梳理,我自己最大的心得以总结为三点:
- 对内存保持敬畏:很多时候程序卡顿、内存泄漏,不是什么高大上的架构问题,往往就是我们在循环里随意用
+拼接字符串、或者频繁实例化Random导致的琐碎垃圾。高频用StringBuilder,高频时间用UtcNow,应该写进肌肉记忆。 - 永远不要相信外部输入:无论是
Console.ReadLine()拿到的文本,还是接口传过来的日期字符串,永远不要直接去Parse。多写一行TryParse,系统就少产生一个线上崩溃的 Bug。 - 看清底层再下手:写代码不能只看表面。明白
String为什么不可变、明白double为什么会丢失精度,才能真正做到心里有底,给出最稳妥的解法。





