GC 是高并发 C# 应用的隐性瓶颈,不是“会不会卡”,而是“什么时候卡、卡多狠”——尤其在 Gen 0 频繁满、对象快速晋升到 Gen 2 或 LOH(大对象堆)时,STW(Stop-The-World)暂停会直接拖垮吞吐和 P99 延迟。
为什么高并发下 GC 压力特别大
高并发场景(如每秒数万请求的 Web API 或实时行情服务)往往伴随高频对象创建:临时
List<t></t>、
string拼接、JSON 序列化缓冲区、DTO 实例等。这些对象多数“朝生夕死”,本该在 Gen 0 快速回收,但一旦分配速率超过 GC 回收节奏,就会引发: Gen 0 频繁触发(毫秒级暂停叠加成可观延迟) 短生命周期对象意外晋升到 Gen 1/Gen 2(比如因引用逃逸或池未复用) 大量 ≥85,000 字节的对象落入 LOH → 清理后不压缩 → 内存碎片 + 长时间 Full GC 风险 线程池线程被 GC 暂停阻塞 → 请求堆积 → 进一步加剧 GC 压力(恶性循环)
用 ArrayPool<t></t>
和 MemoryPool<t></t>
替代 new byte[] / new T[n]
这是最立竿见影的优化点。每次
new byte[4096]都是堆分配;而池化能复用缓冲区,避免 Gen 0 泛滥。
var pool = ArrayPool<byte>.Shared;
byte[] buffer = pool.Rent(8192); // 复用已有数组,无分配
try
{
// 使用 buffer...
}
finally
{
pool.Return(buffer); // 归还,不保证清零!需手动 Array.Clear() 或用 Span<T> 安全写入
}
默认 ArrayPool<t>.Shared</t>已线程安全,适合大多数场景 归还前务必清空敏感数据(如密码、token),
pool.Return(buffer, clearArray: true)可选 不要对同一块 buffer 多次
Return(),也不要在归还后继续读写 若需自定义大小策略或最大容量,用
ArrayPool<t>.Create(minimumBufferSize, maximumRetainedBuffers)</t>
用 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 };
<p>// ❌ 每次 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<t></t>;改用预分配 +
for循环填充 用
Span<char></char>/
ReadOnlySpan<char></char>解析字符串(如 HTTP header、日志行),完全避免
string.Substring()的分配 序列化优先用
System.Text.Json的
Utf8JsonWriter+
Span<byte></byte>输出,而非
JsonSerializer.SerializeToString()
真正难的不是知道该用对象池或 struct,而是判断“这个对象到底该不该池化”——比如一个
MemoryStream,池化它能省 GC,但若内部缓冲区大小波动极大,池反而造成内存浪费或争用。优化必须基于真实压测数据(用
dotnet-trace抓 GC 事件、看 Gen 0/1/2 分配率),而不是凭感觉替换。
