GC是高并发C#应用的隐性瓶颈,尤其在Gen 0频繁满、对象晋升Gen 2或进入LOH时引发STW暂停,拖垮吞吐与P99延迟;需用ArrayPool、struct、Span等手段减少分配,并基于压测数据精准优化。

GC 是高并发 C# 应用的隐性瓶颈,不是“会不会卡”,而是“什么时候卡、卡多狠”——尤其在 Gen 0 频繁满、对象快速晋升到 Gen 2 或 LOH(大对象堆)时,STW(Stop-The-World)暂停会直接拖垮吞吐和 P99 延迟。
为什么高并发下 GC 压力特别大
高并发场景(如每秒数万请求的 Web API 或实时行情服务)往往伴随高频对象创建:临时 List、string 拼接、JSON 序列化缓冲区、DTO 实例等。这些对象多数“朝生夕死”,本该在 Gen 0 快速回收,但一旦分配速率超过 GC 回收节奏,就会引发:
- Gen 0 频繁触发(毫秒级暂停叠加成可观延迟)
- 短生命周期对象意外晋升到 Gen 1/Gen 2(比如因引用逃逸或池未复用)
- 大量 ≥85,000 字节的对象落入 LOH → 清理后不压缩 → 内存碎片 + 长时间 Full GC 风险
- 线程池线程被 GC 暂停阻塞 → 请求堆积 → 进一步加剧 GC 压力(恶性循环)
用 ArrayPool 和 MemoryPool 替代 new byte[] / new T[n]
这是最立竿见影的优化点。每次 new byte[4096] 都是堆分配;而池化能复用缓冲区,避免 Gen 0 泛滥。
var pool = ArrayPool.Shared; byte[] buffer = pool.Rent(8192); // 复用已有数组,无分配 try { // 使用 buffer... } finally { pool.Return(buffer); // 归还,不保证清零!需手动 Array.Clear() 或用 Span 安全写入 }
- 默认
ArrayPool已线程安全,适合大多数场景.Shared - 归还前务必清空敏感数据(如密码、token),
pool.Return(buffer, clearArray: true)可选 - 不要对同一块 buffer 多次
Return(),也不要在归还后继续读写 - 若需自定义大小策略或最大容量,用
ArrayPool.Create(minimumBufferSize, maximumRetainedBuffers)
用 struct 替代小 class,避开堆分配
不是所有“对象”都该是 class。坐标、范围、简单 DTO 等轻量数据结构,用 struct 能彻底消除 GC 压力,且栈分配/拷贝成本极低。
public struct OrderKey
{
public long UserId;
public int OrderId;
public ushort ShardId;
}
// ✅ 栈上分配,无 GC 开销
var key = new OrderKey { UserId = 123, OrderId = 456 };
// ❌ 每次 new 都是堆分配 + GC 候选
public class OrderKeyClass { public long UserId; public int OrderId; }
- 结构体大小建议 ≤ 16 字节(.NET 推荐),过大拷贝开销反超 GC 成本
- 避免在
struct中持有引用类型字段(如string),否则仍会触发堆分配 - 禁止在
struct中实现IDisposable—— 它没有终结器语义,且using对 struct 无意义 - 警惕装箱:把
struct当作object或接口传参会触发堆分配(如Console.WriteLine(myStruct))
别让 string 和 LINQ 成为 GC 黑洞
string 不可变 + LINQ 延迟执行 + 中间集合生成,三者叠加极易在循环中制造“隐形分配洪流”。
- 拼接用
StringBuilder,尤其在循环内:sb.Clear()复用实例,而非反复new StringBuilder() - 避免
.Select(...).ToList()这类链式调用 —— 每次都新建List;改用预分配 +for循环填充 - 用
Span/ReadOnlySpan解析字符串(如 HTTP header、日志行),完全避免string.Substring()的分配 - 序列化优先用
System.Text.Json的Utf8JsonWriter+Span输出,而非JsonSerializer.SerializeToString()
真正难的不是知道该用对象池或 struct,而是判断“这个对象到底该不该池化”——比如一个 MemoryStream,池化它能省 GC,但若内部缓冲区大小波动极大,池反而造成内存浪费或争用。优化必须基于真实压测数据(用 dotnet-trace 抓 GC 事件、看 Gen 0/1/2 分配率),而不是凭感觉替换。










