性能先量化再优化

性能优化最忌讳拍脑袋。先跑 benchmark,用数据定位热点,再决定优化哪里。.NET 提供了 BenchmarkDotNet 这个事实标准库,它能自动统计每次方法调用的耗时、分配与 GC 次数。

没有度量就没有优化。先知道慢在哪、分配在哪,再动手。
先看这三个指标
  • 耗时方法平均与分位耗时,判断是不是热点。
  • 分配每次调用分配了多少字节。
  • GC 次数引发了几代回收。
内存管理配图:散落的半透明玻璃方块被一道柔和波浪聚拢堆叠到底部,光点在方块间隙流动
把散落的「临时对象」聚拢、复用,是减少分配的核心。

GC 分代与回收时机

.NET 的垃圾回收按对象存活时间把堆分成 0、1、2 三代。新对象进 0 代,熬过一次回收就升代。0 代回收最频繁也最便宜,2 代回收(全堆)最贵、停顿最长。优化内存,本质就是让对象尽量别活到 2 代、别引发全堆回收。

// 观察一次回收触发了哪一代
GC.CollectionCount(0);   // 0 代回收次数
GC.CollectionCount(2);  // 2 代(全堆)回收次数

高频路径上反复 new 出来的临时对象,会不断催生 0 代回收;如果它们意外被长期引用,就会一路升到 2 代,拖慢整个进程。

减少对象分配

字符串拼接是最常见的分配大户。循环里用 + 拼字符串,每次都会产生新字符串。改用 StringBuilder,或者在能预知长度时直接定长,分配立刻下降。

// 反例:循环里反复分配
string s = "";
foreach (var x in items) s += x.ToString();

// 正解:复用同一个缓冲区
var sb = new StringBuilder(items.Count);
foreach (var x in items) sb.Append(x);

装箱、闭包捕获、LINQ 链里的中间枚举,都是隐藏的分配来源。性能敏感路径上要逐个排查。

Span 与栈上读写

Span<T> 是一段连续内存的「视图」,读写它不产生新对象。处理字节数组、解析协议时,用 Span 切片可以完全避免复制。stackalloc 还能把小块数组直接放在栈上,方法结束自动释放,连 GC 都不用碰。

// 切片而不复制
ReadOnlySpan<byte> head = buffer.Slice(0, 4);
// 栈上分配,不入托管堆
Span<byte> buf = stackalloc byte[64];

对象池复用热点对象

有些对象创建成本高、用一次就丢——比如 StringBuilder、数据行、缓冲区。与其每次 new,不如放进对象池,用完归还。.NET 内置了 ObjectPool,注入到容器里,热点路径取还即可。

var pool = new ObjectPool<StringBuilder>(
    () => new StringBuilder(),
    sb => { sb.Clear(); return sb; });

var sb = pool.Get();
try { /* 使用 */ }
finally { pool.Return(sb); }
对象池要点
  • 归还前清理避免上一次的数据污染下一次。
  • 只池化创建昂贵的对象简单对象 new 更快。
  • 务必用 finally 归还异常时别漏还。