.NET 性能优化与 Profiling:定位瓶颈与榨干性能

写在前面

这是 .NET 性能系列的第三篇(前两篇是 Dump 诊断、高并发编程)。前两篇讲"出问题怎么查"和"怎么扛住流量",这篇讲怎么跑得更快——如何用工具定位性能瓶颈,以及优化手段。

性能优化最容易犯的错是"凭感觉优化"。本文的核心是:先测量,再优化。掌握工具链比记住一堆技巧重要。

版本说明:基于 .NET 8 LTS。NativeAOT、InterpolatedStringHandler 等特性在 .NET 8 上均适用。


一、性能优化的黄金法则

1.1 测量优先

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
性能优化第一定律:
  "过早优化是万恶之源" —— Donald Knuth

正确的流程:
  1. 建立基线(当前性能是多少)
  2. 用工具定位瓶颈(慢在哪、为什么慢)
  3. 针对瓶颈优化
  4. 测量优化效果(验证真的变快了)
  5. 回归测试(没破坏功能)

错误的做法:
  ✗ 凭直觉优化("我觉得这里慢")
  ✗ 优化不重要的代码(占总时间 1% 的地方优化到极致没用)
  ✗ 没有基线就改(不知道改完是快了还是慢了)
  ✗ 牺牲可读性换微优化(得不偿失)

1.2 帕累托法则

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
80% 的时间花在 20% 的代码上

优化策略:
  找到那 20%(热点路径)→ 集中优化
  其余 80% 的代码 → 保持简洁可读

热点路径通常是:
  - 循环体
  - 高频调用的方法
  - I/O 操作(数据库、HTTP、磁盘)
  - 序列化/反序列化

1.3 优化的层级

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
优化收益从大到小:

  1. 架构优化       — 加缓存、异步化、读写分离、消息队列(收益 10x~100x)
  2. 算法优化       — O(n²) → O(n log n)(收益 10x)
  3. I/O 优化       — 减少 DB 往返、批量、连接复用(收益 5x)
  4. 实现优化       - LINQ 改循环、避免分配(收益 2x)
  5. 微优化         — struct、Span、内联(收益 1.2x)

  从上层往下做,微优化放最后。
  架构不对,微优化再多是杯水车薪。

1.4 性能问题的分类

动手前先判断"是哪一类问题",这决定了用什么工具、查什么方向。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
按资源维度分类:

  CPU 密集型
    特征:CPU 持续 > 80%,吞吐上不去
    场景:复杂计算、正则滥用、低效算法
    看指标:cpu-usage 高
    工具:dotnet-trace(火焰图找热点函数)

  内存/GC 密集型
    特征:% Time in GC 高、内存持续增长
    场景:过度分配、内存泄漏、LOH 碎片
    看指标:time-in-gc、gc-heap-size、gen-2-gc-count
    工具:dotnet-gcdump / dotnet-dump

  I/O 密集型
    特征:CPU 不高但响应慢
    场景:数据库慢查询、外部 API、磁盘读写
    看指标:cpu-usage 低 + 响应慢
    工具:APM 链路追踪、数据库慢日志

  锁竞争
    特征:多核机器但只有一个核跑满,其他在等锁
    场景:全局锁、lock 持有时间过长
    看指标:contention rate 高
    工具:dotnet-stack(看线程在等什么)
1
2
3
4
5
6
7
8
9
按症状快速排查:

  症状                可能原因                  排查方向
  ─────────────────────────────────────────────────────────────
  启动慢              依赖加载、JIT、初始化      启动日志、模块加载
  请求响应慢          DB 查询、外部 API、计算     APM 追踪、慢日志
  内存持续增长        泄漏、缓存无上限           gcdump 分析、GC 日志
  CPU 飙升            死循环、异常、高频计算      火焰图、线程栈
  间歇性卡顿          GC、定时任务、线程池耗尽    GC 日志、线程池监控

先分清类别,再选工具——避免拿着锤子到处找钉子。


二、性能分析工具链

.NET 有世界级的诊断工具链(dotnet-* 全家桶),覆盖从监控到采样到内存分析。

1
2
3
4
5
6
7
工具             用途                      频率
──────────────────────────────────────────────────────
dotnet-counters  实时指标监控(CPU/GC/线程池)  实时
dotnet-trace     CPU 采样、事件追踪、火焰图     录制
dotnet-stack     实时线程栈快照               实时
dotnet-dump      内存/线程/锁分析(离线)      抓取
dotnet-gcdump    托管堆分析(轻量)           抓取

2.1 dotnet-counters — 实时指标

第一步永远是看指标,判断瓶颈类型(CPU?内存?GC?线程池?)。

1
2
3
4
5
6
7
8
9
# 安装
dotnet tool install -g dotnet-counters

# 实时监控(默认 System.Runtime)
dotnet-counters monitor -p 12345

# 指定监控的计数器
dotnet-counters monitor -p 12345 \
  System.Runtime[cpu-usage,gc-heap-size,gen-0-gc-count,gen-1-gc-count,gen-2-gc-count,time-in-gc,threadpool-queue-length]
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
关键指标解读:

  指标                     含义              正常             异常/告警
  ───────────────────────────────────────────────────────────────────────
  cpu-usage                CPU 使用率        < 60%            > 80% 持续
  time-in-gc               GC 占 CPU 比例    < 10%            > 20%
  alloc-rate               分配速率          稳定             持续高位
  gen-0-gc-count           Gen 0 GC 次数     频繁(正常)       —
  gen-1-gc-count           Gen 1 GC 次数     偶尔             频繁
  gen-2-gc-count           Gen 2 GC 次数     很少(几分钟一次)  频繁
  gc-heap-size             GC 堆大小         稳定             持续增长(泄漏)
  threadpool-queue-length  线程池队列        < 10             > 100
  exception-count          异常总数          极少             持续增长
  working-set              内存占用          稳定             持续增长

诊断思路:
  cpu-usage 高 + time-in-gc 低    → 真正的 CPU 计算(算法/循环)
  cpu-usage 不高但卡               → I/O 等待或锁竞争
  time-in-gc 高(> 20%)          → 分配过多,要减少分配
  threadpool-queue-length 高      → 线程阻塞(同步 I/O / 死锁)

2.2 dotnet-trace — CPU 采样与火焰图

dotnet-counters 告诉你"有问题",dotnet-trace 告诉你"问题在哪行代码"。

1
2
3
4
5
6
7
8
9
# 安装
dotnet tool install -g dotnet-trace

# CPU 采样,录 30 秒,输出 speedscope 格式
dotnet-trace collect -p 12345 --profile cpu-sampling --format Speedscope -d 30
# 输出:xxx.speedscope.json

# 也可以用 nettrace 格式(PerfView 打开)
dotnet-trace collect -p 12345 -d 30

录完生成的 .speedscope.json 文件,上传到 https://speedscope.app 即可看火焰图。

高级:直接指定 ETW Provider

--profile cpu-sampling 是预设组合。需要更细的事件(比如只看 GC 分配),可以直接指定 ETW Provider 关键字:

1
2
3
4
5
6
7
8
9
# 只收集 GC 事件(找 GC 热点)
dotnet-trace collect -p 12345 \
  --providers Microsoft-DotNETCore-SampleProfiler,Microsoft-Windows-DotNETRuntime:0x1:4 \
  -d 30

# 专门收集堆分配(0x10000,找分配热点最有用)
dotnet-trace collect -p 12345 \
  --providers Microsoft-Windows-DotNETRuntime:0x10000:4 \
  -d 30
1
2
3
4
5
6
7
8
DotNETRuntime 常用关键字(掩码):
  0x1       — GC(垃圾回收事件)
  0x4       — JIT(即时编译)
  0x10000   — GC Heap Allocation(堆分配,找分配热点最有用)
  0x20000   — GC Heap Survive and Movement(对象存活/移动)

格式:Provider:关键字:级别(4 = Informational)
排查分配问题,0x10000 最有用

2.3 dotnet-stack — 实时线程栈

不抓 dump,直接看所有线程当前在执行什么。适合排查"瞬时卡顿"。

1
2
3
4
5
6
7
8
# 安装
dotnet tool install -g dotnet-stack

# 打印所有线程栈(一次性快照)
dotnet-stack report -p 12345

# 持续轮询(每秒一次,看趋势)
dotnet-stack report -p 12345 --duration 00:00:30

2.4 内存分析(dotnet-dump / dotnet-gcdump)

内存泄漏和分配分析详见 《.NET Dump 诊断》 那篇,这里只列要点:

1
2
3
4
5
6
7
8
# 轻量堆 dump,看分配和引用
dotnet-gcdump collect -p 12345

# 完整 dump,深入分析
dotnet-dump collect -p 12345
dotnet-dump analyze xxx.dmp
> dumpheap -stat       # 按类型统计
> gcroot <地址>         # 引用链

三、火焰图:定位 CPU 热点

火焰图是性能分析最有力的工具,但很多人不会读。专门讲一下。

3.1 什么是火焰图

1
2
3
4
5
6
7
8
火焰图把采样数据可视化:
  - 横轴:函数调用栈(展开成"火焰")
  - 纵轴:调用深度(底层是调用者,顶层是被调用者)
  - 宽度:该函数占用 CPU 的比例(越宽 = 越慢 = 优化重点)

读法核心:
  看最宽的"墙"——那是最耗时的调用栈
  从下往上读,找到最宽的那一层,就是瓶颈函数

3.2 火焰图示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
┌──────────────────────────────────────────────────────────┐
│                          [Idle]                            │  ← 空闲
├──────────────────────────────────────────────────────────┤
│        Run()                                              │
│        ├────────────────────────────┬─────────────────┤  │
│        │ ProcessRequest()           │ LogRequest()    │  │
│        │ ├───────────────┬────────┐ │ ├──────────┐    │  │
│        │ │ ParseJson()   │ Query()│ │ │ Write()  │    │  │
│        │ │ ███████████   │ ██████│ │ │ ██       │    │  │
│        │ │  (很宽=慢)     │        │ │ │          │    │  │
└────────┴───────────────┴────────┘─┴─┴──────────┘────┘
                                                              ↑ 宽度=CPU占比
   读图:ParseJson() 那块最宽 → 它是瓶颈 → 优化它

3.3 两种视角

1
2
3
4
5
6
7
8
speedscope 提供三种视图:

1. Time Order(时间顺序)— 按采样时间排列,看某个时刻在干嘛
2. Left Heavy(左重)    — 按调用栈聚合,最常用,找最宽的块
3. Sandwich(三明治)    — 按函数汇总,看每个函数的总耗时和调用次数

  找瓶颈:用 Left Heavy,看最宽的栈
  对比函数:用 Sandwich,按 Total Time 排序

3.4 常见火焰图形态

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
宽且平的"平台"    → 某个函数本身耗时(CPU 密集计算)
  → 优化算法或实现

窄而深的"塔"      → 调用链很长,但每层不耗时
  → 通常不是瓶颈,看塔顶

突然变宽的"瓶颈层" → 某一层突然占满,下面层层都宽
  → 那一层就是热点

大片 [Idle]       → CPU 空闲,瓶颈在 I/O 或锁(不在 CPU)
  → 改用 dotnet-stack 看线程在等什么

四、BenchmarkDotNet — 微基准测试

想优化具体方法、对比两种实现,不要用 Stopwatch 手写——会测不准。用 BenchmarkDotNet。

4.1 为什么不能用 Stopwatch

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// 错误:手写基准
var sw = Stopwatch.StartNew();
for (int i = 0; i < 1000; i++)
    MethodA();
sw.Stop();
Console.WriteLine(sw.ElapsedMilliseconds);

// 问题:
// ✗ 没预热(JIT 第一次执行慢)
// ✗ 没考虑 GC 时机(GC 恰好发生在 MethodA 里)
// ✗ 编译器可能优化掉"没用的"循环
// ✗ 没考虑 CPU 频率波动、其他进程干扰
// ✗ 样本太少,没有统计意义

4.2 基本用法

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// 安装
// dotnet add package BenchmarkDotNet

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;

[MemoryDiagnoser]   // 报告内存分配和 GC 次数
public class StringBenchmarks
{
    [Benchmark(Baseline = true)]
    public string StringConcat()
    {
        var s = "";
        for (int i = 0; i < 100; i++)
            s += i.ToString();
        return s;
    }

    [Benchmark]
    public string StringBuilder()
    {
        var sb = new StringBuilder();
        for (int i = 100; i > 0; i--)
            sb.Append(i);
        return sb.ToString();
    }
}

// 入口
var summary = BenchmarkRunner.Run<StringBenchmarks>();

4.3 读懂报告

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
| Method         | Mean     | Error    | StdDev   | Ratio | Allocated |
|--------------- |---------:|---------:|---------:|------:|----------:|
| StringConcat   | 15.32 us | 0.312 us | 0.456 us |  1.00 |    6000 B |
| StringBuilder  |  2.14 us | 0.043 us | 0.061 us |  0.14 |     320 B |

解读:
  Mean      平均耗时
  StdDev    标准差(越小越稳定)
  Ratio     相对基线的倍数(0.14 = 比 StringConcat 快 7 倍)
  Allocated 分配的内存(6000B vs 320B,差异巨大)

  结论:StringBuilder 比 += 快 7 倍,内存少 18 倍

4.4 常用特性

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
[SimpleJob(RuntimeMoniker.Net80)]      // 指定运行时
[MemoryDiagnoser]                       // 内存/GC 诊断
[ThreadingDiagnoser]                    // 线程池诊断
public class MyBenchmarks
{
    [Params(100, 1000, 10000)]          // 多种参数
    public int N { get; set; }

    [ParamsAllValues]                   // 所有值
    public StringComparison SC { get; set; }

    [Benchmark]
    public void Method() { /* 用 N */ }

    [Benchmark]
    [Arguments(1, "test")]              // 直接传参
    public void WithArgs(int a, string b) { }
}
1
2
3
4
5
6
7
8
BenchmarkDotNet 的保证:
  ✓ 预热(JIT 编译、CPU 升频)
  ✓ 多轮采样,统计置信区间
  ✓ 隔离 GC 干扰
  ✓ 防止编译器"优化掉"测试代码
  ✓ 报告 Mean / Median / StdDev / Allocated / Gen0/1/2
  
  这是工业级的基准测试,结果可信

五、GC 调优

GC(垃圾回收)是 .NET 性能的核心。理解 GC 才能理解"为什么要减少分配"。

5.1 GC 基础

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
.NET 的 GC 是分代的:
  Gen 0 — 短生命周期对象,回收最频繁、最快
  Gen 1 — 缓冲区
  Gen 2 — 长生命周期对象,回收最慢(全堆扫描)
  LOH   — 大对象堆(≥ 85000 字节),直接进 Gen 2

回收流程:
  Gen 0 满 → 回收 Gen 0(快)
  存活的对象晋升 Gen 1
  Gen 1 满 → 回收 Gen 1(连带 Gen 0)
  存活的晋升 Gen 2
  Gen 2 满 → 完整 GC(慢,可能 STW 几十毫秒)

性能核心:减少 Gen 2 回收
  Gen 2 GC 贵,会停顿所有线程(STW)
  办法:减少分配、复用对象、避免大对象频繁分配

5.2 Workstation GC vs Server GC

1
2
3
4
5
6
7
<!-- runtimeconfig.json / .csproj -->
{
  "configProperties": {
    "System.Runtime.GC.Server": true,        // Server GC
    "System.Runtime.GC.Concurrent": true     // 后台 GC
  }
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
                  Workstation GC        Server GC
──────────────────────────────────────────────────────────
堆数量             1 个                  每核 1 个(并行回收)
线程               少                    多(每核 GC 线程)
吞吐量             低                    高
内存占用           小                    大(多堆)
适用               客户端、单核          服务器、多核、高并发

ASP.NET Core 默认 Server GC(多核服务器最优)
桌面应用用 Workstation GC
1
2
3
// 代码里判断当前 GC 模式
Console.WriteLine(GCSettings.IsServerGC);        // 是否 Server GC
Console.WriteLine(GCSettings.LatencyMode);       // 延迟模式

5.3 GC 延迟模式

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// 临时降低 GC 影响(关键路径不想被打断)
var oldMode = GCSettings.LatencyMode;
try
{
    GCSettings.LatencyMode = GCLatencyMode.SustainedLowLatency;
    // 这段代码期间,GC 尽量不做完整回收
    DoLatencySensitiveWork();
}
finally
{
    GCSettings.LatencyMode = oldMode;   // 恢复
}
1
2
3
4
5
GCLatencyMode:
  Interactive             标准(平衡吞吐和延迟)
  LowLatency              短时低延迟(Gen 2 回收被抑制,仅 Gen 0/1)
  SustainedLowLatency     持续低延迟(适合实时场景)
  Batch                   最高吞吐(不顾延迟,适合后台任务)

5.4 减少分配(GC 调优的根本)

GC 压力的根源是分配。分配越少,GC 越少。

1
2
3
4
5
6
7
8
减少分配的手段(实战角度,详见高并发文章的 Span/Pool 章节):
  ✓ Span<T> / stackalloc     — 零拷贝、栈分配
  ✓ ArrayPool<T>.Shared      — 复用数组
  ✓ ObjectPool<T>            — 复用对象
  ✓ 预分配集合容量            — new List<T>(128) 避免 resize
  ✓ struct 替代 class         — 栈分配,无 GC(适用时)
  ✓ 避免 LINQ 在热路径         — 每次都分配迭代器
  ✓ string 插值替代拼接        — C# 10+ 的 InterpolatedStringHandler
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// GC 通知:在 Gen 2 回收前收到通知(适合做平滑处理)
GC.RegisterForFullGCNotification(10, 10);
// 在后台线程轮询
Task.Run(() =>
{
    while (true)
    {
        GCNotificationStatus s = GC.WaitForFullGCApproach();
        if (s == GCNotificationStatus.Succeeded)
        {
            // Gen 2 即将发生,可做些准备(如拒绝新请求)
        }
        GC.WaitForFullGCComplete();
    }
});

六、JIT 与 AOT

.NET 默认是 JIT(即时编译),但也有 AOT(提前编译) 选项。

6.1 JIT 工作原理

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
源码 → IL(中间语言) → JIT → 机器码

JIT 的特点:
  ✓ 方法首次调用时才编译(按需)
  ✓ 基于实际运行情况优化(分层编译、PGO)
  ✓ 跨平台(IL 与 CPU 无关)
  ✗ 首次调用有编译开销(冷启动慢)
  ✗ 运行时占用内存(JIT + 优化代码)

.NET 8 的优化:
  分层编译(Tiered Compilation)— 先快速编译,热点再重新优化
  动态 PGO(Dynamic PGO)    — 根据运行 profile 优化热点

6.2 ReadyToRun(R2R)— 预编译 IL

1
2
# 发布时预编译为机器码(但仍是托管,可 JIT 兜底)
dotnet publish -c Release -r win-x64 -p:PublishReadyToRun=true
1
2
3
4
5
6
7
R2R 的特点:
  ✓ 启动更快(部分方法已编译)
  ✓ 仍是托管代码(可被 JIT 进一步优化)
  ✗ 文件更大(包含预编译代码)
  ✗ 绑定平台(win-x64 / linux-x64)

适合:启动速度敏感、可接受更大体积的场景

6.3 NativeAOT — 完全提前编译

1
2
3
4
5
6
# .NET 8 正式支持
dotnet new console
# 在 csproj 加 <PublishAot>true</PublishAot>
dotnet publish -c Release -r win-x64

# 输出:单个原生可执行文件,无需 .NET 运行时
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
NativeAOT 的特点:
  ✓ 启动极快(无 JIT)
  ✓ 内存占用小(无运行时)
  ✓ 单文件部署,无依赖
  ✓ 部署到容器/边缘设备
  ✗ 不支持反射(或需显式 trim)
  ✗ 不支持动态加载程序集
  ✗ 编译时绑定平台
  ✗ 部分库不兼容(需要 AOT 友好)

适合:
  CLI 工具、云函数(冷启动敏感)、微服务、边缘计算
不适合:
  重度反射、动态代码生成的应用
1
2
3
4
JIT vs R2R vs NativeAOT 选择:
  标准部署(服务器、长期运行)    → JIT(运行时优化最强)
  启动敏感、要兜底                → R2R
  冷启动极致、单文件、无依赖      → NativeAOT

七、常见性能反模式

实际项目中最常见的性能坑,附优化前后对比。

7.1 字符串拼接

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
// ❌ 慢:循环里 += (每次创建新字符串)
string result = "";
foreach (var item in items)
    result += item.ToString();    // O(n²),大量中间字符串

// ✅ 快:StringBuilder
var sb = new StringBuilder();
foreach (var item in items)
    sb.Append(item);
var result = sb.ToString();        // O(n)

// ✅ 更快:string.Join(C# 内部优化)
var result = string.Join(",", items);

// ✅ 现代插值(C# 10+,零分配优化)
// InterpolatedStringHandler 直接写入目标,不分配中间 string
$"Total: {count:N0} items";       // 高效

7.2 LINQ 误用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// ❌ 热路径用 LINQ(每次分配迭代器、委托)
foreach (var x in list.Where(x => x.Active).Select(x => x.Value))
    Process(x);

// ✅ 热路径用显式循环
foreach (var x in list)
{
    if (x.Active)
        Process(x.Value);
}

// LINQ 适合可读性优先的非热路径;
// 性能敏感的热路径,显式循环更快(少分配)

7.3 装箱(Boxing)

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
// ❌ 装箱:值类型转 object,堆分配
ArrayList list = new ArrayList();   // 老式集合,存 object
list.Add(42);                       // int → object,装箱!分配

// ✅ 泛型集合,无装箱
List<int> list = new List<int>();
list.Add(42);                       // 无装箱

// 隐蔽的装箱:
Console.WriteLine("Count: " + count);   // string + int → 装箱
// C# 6+ 插值优化后:
Console.WriteLine($"Count: {count}");   // 无装箱(InterpolatedStringHandler)

7.4 async 误用

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// ❌ async void(异常无法捕获,会崩溃进程)
async void DoWork() { await Task.Delay(100); throw new Exception(); }

// ✅ async Task
async Task DoWorkAsync() { await Task.Delay(100); }

// ❌ 异步里同步阻塞(死锁风险 + 线程池耗尽)
async Task BadAsync()
{
    var result = SomeSyncMethod().Result;   // .Result 阻塞!
}

// ✅ 全程异步
async Task GoodAsync()
{
    var result = await SomeMethodAsync();
}

// ❌ Task.Run 包异步方法(多此一举,浪费线程)
var x = await Task.Run(async () => await httpClient.GetStringAsync(url));

// ✅ 直接 await(HttpClient 本身是异步 I/O)
var x = await httpClient.GetStringAsync(url);

7.5 集合未预分配容量

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// ❌ 不指定容量:List 多次扩容(每次复制数组)
var list = new List<int>();
for (int i = 0; i < 10000; i++)
    list.Add(i);    // 多次 resize + 复制

// ✅ 预分配容量:一次到位
var list = new List<int>(10000);
for (int i = 0; i < 10000; i++)
    list.Add(i);    // 无 resize

// 同理:Dictionary、HashSet、StringBuilder 都支持容量
var dict = new Dictionary<string, int>(capacity: 1000);
var sb = new StringBuilder(capacity: 256);

7.6 闭包捕获

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
// ❌ 闭包捕获循环变量(分配委托 + 闭包对象)
foreach (var item in items)
{
    tasks.Add(Task.Run(() => Process(item)));   // 每次分配闭包
}

// ✅ 显式传参(无闭包分配)
foreach (var item in items)
{
    var local = item;
    tasks.Add(Task.Run(() => Process(local)));
}

// 热路径避免频繁创建 lambda / 闭包

八、减少分配实战(复习)

减少分配 = 减少 GC = 提升性能。这部分在高并发文章详细讲过,这里从性能角度快速复习。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 1. ArrayPool — 复用数组(热路径必备)
byte[] buffer = ArrayPool<byte>.Shared.Rent(4096);
try { Process(buffer); }
finally { ArrayPool<byte>.Shared.Return(buffer); }

// 2. stackalloc — 栈分配(小缓冲,零 GC)
Span<byte> stackBuf = stackalloc byte[128];

// 3. string.Create — 高效构造字符串
string result = string.Create(length, state, (span, s) =>
{
    // 直接在目标内存写入,无中间分配
    for (int i = 0; i < span.Length; i++)
        span[i] = (char)('0' + (s.Value % 10));
});

// 4. struct 而非 class(适用时,避免堆分配)
// 小型、值语义的数据用 readonly struct
public readonly struct Point { public int X; public int Y; }

// 5. 避免防御性拷贝:用 readonly struct / in 参数
static void Process(in BigStruct data) { }   // in = 按引用只读传入

九、性能优化流程总结

把前面所有内容串成一个可执行的流程:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
1. 建立基线
   dotnet-counters 看整体指标
   记录当前 QPS、P99 延迟、CPU、内存

2. 定位瓶颈类型
   CPU 高?→ CPU 密集(算法/分配)
   CPU 低但卡?→ I/O 等待 / 锁竞争
   time-in-gc 高?→ 分配过多
   threadpool-queue 长?→ 线程阻塞

3. 定位瓶颈位置
   CPU 密集 → dotnet-trace 录火焰图 → 找最宽的栈
   瞬时卡顿 → dotnet-stack 看线程栈
   内存问题 → dotnet-gcdump / dotnet-dump

4. 针对优化
   算法层 → 换数据结构 / 算法
   I/O 层 → 缓存、批量、异步、连接池
   实现层 → 减少分配、Span、避免 LINQ 热路径
   用 BenchmarkDotNet 验证优化效果

5. 验证收益
   重新测量,对比基线
   确认真的变快了(不是测量误差)

6. 回归测试
   确保功能没坏
   加上 benchmark 防止性能退化

优化决策树

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
慢在哪?
├─ CPU 高
│   ├─ 火焰图找到热点函数
│   ├─ 算法可优化?→ 换算法(收益最大)
│   ├─ 分配多?→ 减少分配(Span/Pool/容量)
│   └─ 计算密集?→ 并行化(Parallel/PLINQ)
├─ I/O 等待
│   ├─ 数据库?→ 缓存、批量、索引、连接池
│   ├─ HTTP?→ 并发(Task.WhenAll)、连接池、重试
│   └─ 全异步化(async/await 全链路)
├─ 内存/GC
│   ├─ time-in-gc 高?→ 减少分配
│   ├─ LOH 频繁?→ 复用大对象(ArrayPool)
│   └─ Server GC 开启?
└─ 锁竞争
    └─ 无锁化(Channel/并发集合/Interlocked)

十、实战案例

把前面的工具和流程串起来,看两个真实场景。

10.1 场景一:Web API 响应慢

现象:用户反馈接口响应 5 秒+,但 APM 显示数据库查询只要 100ms。

第一步:看指标判断类型

1
2
3
dotnet-counters monitor -p 12345 System.Runtime
# cpu-usage 飙到 95%,time-in-gc 正常
# → 判定:CPU 密集型(不是 I/O,不是 GC)

第二步:录火焰图找热点

1
2
dotnet-trace collect -p 12345 --profile cpu-sampling --format Speedscope -d 30
# 在 speedscope 打开,发现 80% CPU 花在某个 LINQ 查询

第三步:定位代码

1
2
3
4
5
6
// 火焰图指向这段:循环里调 .Any(),触发 N+1 查询
foreach (var order in orders)
{
    var hasDetails = _context.OrderDetails
        .Any(x => x.OrderId == order.Id);   // 每个 order 一次 DB 往返
}

第四步:修复(批量查询替代循环查询)

1
2
3
4
5
6
7
8
var orderIds = orders.Select(x => x.Id).ToList();
var hasDetailsMap = _context.OrderDetails
    .Where(x => orderIds.Contains(x.OrderId))
    .GroupBy(x => x.OrderId)
    .ToDictionary(g => g.Key, g => g.Any());   // 一次查询全部

foreach (var order in orders)
    _ = hasDetailsMap.ContainsKey(order.Id);

第五步:Benchmark 验证

1
2
3
修复前:4500 ms, 1200 MB allocated
修复后:  120 ms,   25 MB allocated
提速 37 倍,分配减少 48 倍

10.2 场景二:内存持续增长(泄漏)

现象:进程内存每小时涨 200MB,最终 OOM 重启。

第一步:确认是泄漏

1
2
3
dotnet-counters monitor -p 12345 System.Runtime
# gc-heap-size 持续增长,gen-2-gc-count 越来越频繁
# → 判定:托管内存泄漏

第二步:抓 gcdump 看谁占内存

1
2
3
dotnet-gcdump collect -p 12345
# 用 PerfView / dotMemory 打开
# 发现 50 万个 DataItem 对象,疑似泄漏

第三步:用 dump 找引用链

1
2
3
4
5
6
dotnet-dump collect -p 12345
dotnet-dump analyze xxx.dmp
> dumpheap -type DataItem
> gcroot <DataItem 地址>
# 输出引用链:
#   EventHandler[] → DataService.DataReceived → DataItem

第四步:定位根因

1
2
3
// 事件订阅了但从没取消,DataService 是长生命周期单例
service.DataReceived += OnDataReceived;
// DataConsumer 被 DataReceived 持有,永不释放

第五步:修复

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class DataConsumer : IDisposable
{
    private readonly DataService _service;
    private readonly EventHandler<DataEventArgs> _subscription;

    public DataConsumer(DataService service)
    {
        _service = service;
        _subscription = OnDataReceived;
        _service.DataReceived += _subscription;
    }

    public void Dispose()
    {
        _service.DataReceived -= _subscription;   // 释放时取消订阅
    }
}

总结:内存泄漏三件套——dumpheap -stat 找大户、gcroot 找引用链、改代码断开引用。详见 《.NET Dump 诊断》


十一、小结

本文系统讲解了 .NET 性能优化与 profiling:

  • 方法论:测量优先、帕累托、优化层级(架构 > 算法 > I/O > 实现 > 微优化)
  • 工具链:dotnet-counters(指标)、dotnet-trace(火焰图)、dotnet-stack(线程栈)、dotnet-dump/gcdump(内存)
  • 火焰图:怎么读,看最宽的栈找瓶颈
  • BenchmarkDotNet:工业级微基准,不要用 Stopwatch 手写
  • GC 调优:分代、Server GC、延迟模式、减少分配是根本
  • JIT / AOT:R2R、NativeAOT 的适用场景
  • 反模式:字符串拼接、LINQ 热路径、装箱、async 误用、容量、闭包
  • 优化流程:基线 → 定位 → 优化 → 验证 → 回归
  • 实战案例:API 慢(N+1 查询)、内存泄漏(事件订阅)端到端排查

性能优化的核心心法:先测量,再优化;先架构,再微调;先热点,再全局。

至此 .NET 性能系列三篇(Dump 诊断、高并发、性能优化)完结。