你是否经历过这样的深夜告警:服务刚上线时内存占用 200MB,三天后涨到 2GB,一周后 OOM 被 Kubernetes 杀掉重启?日志里没有任何 Exception,只有一行冰冷的 Killed process 1234 (dotnet) total-vm:3245678kB。
内存问题是 .NET 线上排障中最隐蔽、最考验基本功的一类。GC 帮你管理了内存,但并没有帮你消除内存泄漏。托管世界里的泄漏往往不是"忘记 free",而是"忘记解除引用"——一条看不见的引用链,让 GC 以为对象还活着。
本文用三个真实案例,完整记录从现象发现、指标观测、Dump 抓取到根因定位的全过程。所有案例均来自船舶管理系统(PMS)生产环境,已做脱敏处理。
一、先搞清楚:.NET 里的"内存"到底指什么
在排障之前,我们必须区分几个容易混淆的概念。很多人一看到内存高就喊"内存泄漏",但内存高 ≠ 泄漏。
1.1 三层内存模型
┌─────────────────────────────────────────┐
│ 进程工作集 (Working Set) │
│ 操作系统看到的物理内存占用 │
│ ┌─────────────────────────────────────┐ │
│ │ .NET 托管堆 (Managed Heap) │ │
│ │ GC 管理的内存,含 Gen0/Gen1/Gen2/LOH │ │
│ └─────────────────────────────────────┘ │
│ ┌─────────────────────────────────────┐ │
│ │ 非托管内存 (Native Heap) │ │
│ │ P/Invoke、文件句柄、Socket缓冲区、 │ │
│ │ 第三方原生库(如 SQLite、图像库) │ │
│ └─────────────────────────────────────┘ │
│ ┌─────────────────────────────────────┐ │
│ │ 线程栈 + JIT 代码缓存 │ │
│ └─────────────────────────────────────┘ │
└─────────────────────────────────────────┘
关键认知:
- 托管内存高:可能是正常的内存缓存、Gen2 堆积,也可能是泄漏
- 非托管内存高:常见于未 Dispose 的 SafeHandle、原生库泄漏、GCHandle.Alloc 未释放
- 工作集高但托管堆低:问题在非托管层,用 dotnet-counters 一看便知
1.2 GC 代龄机制速览
Gen0 (小对象,短期) ──回收──→ 存活晋升 ──→ Gen1
Gen1 (缓冲区) ──回收──→ 存活晋升 ──→ Gen2
Gen2 (长生命周期) ──回收──→ 全GC,代价最大
LOH (≥85,000字节) ──不压缩──→ 与Gen2一起回收,易产生碎片
| Gen0 | 高频(每几秒) | <1ms | 局部变量、临时LINQ结果 |
| Gen1 | 中频 | 1-5ms | 请求作用域服务、短生命周期DTO |
| Gen2 | 低频(但可能频繁触发) | 10-100ms+ | 单例服务、缓存、静态列表 |
| LOH | 随Gen2 | 碎片相关 | 大数组、大字符串、Memory |
💬 互动一下: 你的服务有没有遇到过 Gen2 GC 频率突然升高但 CPU 不高的情况?想想看,什么操作会让大量对象意外进入 Gen2?
二、诊断工具链:从"肉眼看"到"精准定位"
工欲善其事,必先利其器。.NET 提供了一套强大的诊断工具,全部是跨平台命令行工具,在 Linux 容器里也能用。
2.1 dotnet-counters:实时指标观测
安装:
dotnet tool install –global dotnet-counters
观测核心指标:
# 查看所有 .NET 进程
dotnet-counters ps
# 实时监控 GC 和线程池指标
dotnet-counters monitor -p <PID> \\
System.Runtime \\
Microsoft.AspNetCore.Hosting
# 重点关注这些指标:
# gc-heap-size — 托管堆总大小
# gen-0-gc-count / gen-1-gc-count / gen-2-gc-count
# gen-2-size / loh-size — Gen2 和 LOH 大小
# time-in-gc — GC 时间占比(健康值 < 5%)
# threadpool-thread-count / threadpool-queue-length
# working-set — 进程工作集
# alloc-rate — 分配速率(MB/秒)
判读经验:
| gen-2-size 持续增长不下降 | Gen2 内存泄漏 |
| loh-size 持续增长 | 大对象泄漏或碎片 |
| time-in-gc > 20% | GC 压力过大,分配速率过高 |
| working-set 高但 gc-heap-size 低 | 非托管内存泄漏 |
| alloc-rate > 100MB/s | 代码中有高频大对象分配 |
2.2 dotnet-dump:抓取和分析内存转储
# 安装
dotnet tool install –global dotnet-dump
# 抓取 Dump(Linux 容器内需要 SYS_PTRACE 权限)
dotnet-dump collect -p <PID> –type Full -o /tmp/crash.dmp
# 分析 Dump
dotnet-dump analyze /tmp/crash.dmp
进入交互式分析后,最常用的命令:
> eeheap -gc # 查看各代堆大小和段信息
> dumpheap -stat # 统计托管堆上所有类型的对象数量和总大小
> dumpheap -mt <MT> # 列出某类型的所有对象
> gcroot <address> # 查找对象的引用根(最关键的命令!)
> dumpobj <address> # 查看对象详情
> clrthreads # 查看所有托管线程
> threadpool # 查看线程池状态
> finalizequeue # 查看终结化队列(排查非托管泄漏)
> gchandles # 查看 GCHandle(排查 Pinned 对象泄漏)
2.3 dotnet-trace:性能追踪
# 抓取 CPU 采样追踪(30秒)
dotnet-trace collect -p <PID> –profile cpu-sampling –duration 00:00:30
# 抓取 GC 详细事件
dotnet-trace collect -p <PID> \\
–providers Microsoft-Windows-DotNETRuntime:4:4 \\
–duration 00:00:30
采集的 .nettrace 文件可以用 PerfView 或 Visual Studio 打开,看到每一次 GC 的触发原因、暂停时间和各代大小变化。
2.4 生产环境快速抓取脚本
在 Kubernetes Pod 中抓取 Dump 的完整流程:
# 在 Dockerfile 中预装诊断工具
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base
RUN apt-get update && apt-get install -y curl && \\
curl -sSL https://dot.net/v1/dotnet-install.sh | bash /dev/stdin –tool-path /tools && \\
/tools/dotnet tool install –global dotnet-counters –tool-path /tools && \\
/tools/dotnet tool install –global dotnet-dump –tool-path /tools
ENV PATH="${PATH}:/tools"
# 进入 Pod
kubectl exec -it pms-api-7d8f9c6b5-x2k4n — bash
# 找到进程
dotnet-counters ps
# 监控确认问题
dotnet-counters monitor -p 1 –counters System.Runtime
# 另一个终端抓取 Dump
dotnet-dump collect -p 1 –type Full -o /tmp/memory_$(date +%Y%m%d_%H%M%S).dmp
# 拷贝到本地分析
kubectl cp pms-api-7d8f9c6b5-x2k4n:/tmp/memory_20260824_030000.dmp ./memory.dmp
💬 互动一下: 如果你的 Pod 没有安装 dotnet-dump,且生产环境不能随意改镜像,你会怎么在不重启进程的情况下抓取内存 Dump?(提示:考虑 gcore 或 /proc/<pid>/mem 的方案。)
三、案例一:事件订阅未解绑——静态事件的"锚定效应"
3.1 现象
PMS 系统的船岸同步服务在岸端部署,负责接收船舶端通过卫星链路上报的数据。上线两周后,运维发现 Pod 内存从 300MB 稳步增长到 1.8GB,然后 OOMKilled。重启后恢复,但增长曲线如时钟般精确。
内存变化:
Day 0 ─── 312MB (部署后初始值)
Day 2 ─── 580MB
Day 5 ─── 1.1GB
Day 8 ─── 1.5GB
Day 10 ─── 1.8GB ⚠️ 接近 limit 2GB
Day 11 ─── OOMKilled, 重启
用 dotnet-counters 观测:
gen-2-size: 1,456 MB ← 异常!
loh-size: 128 MB
time-in-gc: 8.2%
working-set: 1,812 MB
gc-heap-size: 1,590 MB ← 主要问题在托管堆
Gen2 持续增长,说明有长生命周期对象不断被"锚定"在 Gen2 上,GC 无法回收。
3.2 定位
抓取 Dump 后分析:
> dumpheap -stat
Statistics:
MT Count TotalSize Class Name
00007f… 85432 683,456,000 Pms.Sync.Models.ShipDataPackage
00007f… 85432 204,936,800 Pms.Sync.Handlers.SyncEventHandler
00007f… 85431 102,517,200 Pms.Sync.Models.SyncContext
00007f… 12 576,000 System.Collections.Generic.List<Object>
…
85,432 个 ShipDataPackage 对象! 这是每次同步请求的数据包,正常情况下请求结束后就应该被回收。让我找一个对象看看谁在引用它:
> dumpheap -mt 00007f…
Address MT Size
000001a2b3c4d5e 00007f… 8000
…
> gcroot 000001a2b3c4d5e
Thread 1234:
-> 000001a1b2c3d4e5 Pms.Sync.Eventing.SyncEventBroker (静态字段)
-> 000001a1b2c3d4f0 System.Collections.Generic.List<SyncEventHandler>
-> 000001a2b3c3d4e0 Pms.Sync.Handlers.SyncEventHandler
-> 000001a2b3c4d5e0 Pms.Sync.Models.ShipDataPackage
根因找到了! 引用链是:
静态 SyncEventBroker
→ List<SyncEventHandler> (事件订阅者列表)
→ SyncEventHandler (每次请求创建的处理器)
→ ShipDataPackage (请求数据)
3.3 问题代码
// 静态事件代理——全局单例,生命周期与进程一致
public static class SyncEventBroker
{
// 静态事件——所有订阅者都会被根引用锚定
public static event EventHandler<SyncCompletedEventArgs> SyncCompleted;
public static void RaiseSyncCompleted(ShipDataPackage package)
{
SyncCompleted?.Invoke(null, new SyncCompletedEventArgs(package));
}
}
// 同步处理器——每次请求创建一个实例(Scoped)
public class SyncHandler
{
private readonly ShipDataPackage _package;
public SyncHandler(ShipDataPackage package)
{
_package = package;
// ❌ 致命问题:订阅静态事件但从未取消订阅!
// 每个请求都会创建一个 SyncHandler 实例,
// 每个实例都订阅了静态事件,
// 静态事件的 InvocationList 持有所有 SyncHandler 引用,
// SyncHandler 又持有 ShipDataPackage,
// 导致所有请求数据永远无法被 GC 回收!
SyncEventBroker.SyncCompleted += OnSyncCompleted;
}
private void OnSyncCompleted(object sender, SyncCompletedEventArgs e)
{
// 处理同步完成逻辑
if (e.Package.ShipId == _package.ShipId)
{
// …
}
}
}
泄漏机制图解:
请求1: SyncHandler_1 订阅 → 持有 Package_1
请求2: SyncHandler_2 订阅 → 持有 Package_2
请求3: SyncHandler_3 订阅 → 持有 Package_3
…
请求85432: SyncHandler_85432 订阅 → 持有 Package_85432
静态事件 InvocationList:
[SyncHandler_1, SyncHandler_2, …, SyncHandler_85432]
↓ 全部被锚定,无法 GC
3.4 修复方案
方案一:实现 IDisposable,在请求结束时取消订阅(推荐)
public class SyncHandler : IDisposable
{
private readonly ShipDataPackage _package;
private bool _disposed;
public SyncHandler(ShipDataPackage package)
{
_package = package;
SyncEventBroker.SyncCompleted += OnSyncCompleted;
}
private void OnSyncCompleted(object sender, SyncCompletedEventArgs e)
{
if (_disposed) return;
// …
}
public void Dispose()
{
if (!_disposed)
{
SyncEventBroker.SyncCompleted -= OnSyncCompleted;
_disposed = true;
}
}
}
// 在 DI 中注册为 Scoped,框架会自动 Dispose
services.AddScoped<SyncHandler>();
方案二:使用弱引用事件模式(WeakEventManager)
// .NET 内置的弱事件管理器——订阅者不会被强引用锚定
public class SyncEventBroker
{
private readonly WeakEventManager _weakEventManager = new();
public event EventHandler<SyncCompletedEventArgs> SyncCompleted
{
add => _weakEventManager.AddEventHandler(value);
remove => _weakEventManager.RemoveEventHandler(value);
}
public void RaiseSyncCompleted(ShipDataPackage package)
{
_weakEventManager.HandleEvent(this,
new SyncCompletedEventArgs(package), nameof(SyncCompleted));
}
}
⚠️ WeakEventManager 在 Microsoft.Maui 包中,服务端项目可自行实现轻量版,核心思路是用 WeakReference 持有订阅者。
方案三:改用 MediatR 的 INotification(最彻底)
// 用 MediatR 的发布/订阅替代静态事件
// 处理器注册为 Scoped,请求结束自动释放,不存在泄漏
public class SyncCompletedNotification : INotification
{
public ShipDataPackage Package { get; init; }
}
public class SyncCompletedHandler : INotificationHandler<SyncCompletedNotification>
{
public async Task Handle(SyncCompletedNotification notification,
CancellationToken cancellationToken)
{
// 处理逻辑,Scoped 生命周期,请求结束自动回收
}
}
3.5 验证
修复后部署,dotnet-counters 观测:
gen-2-size: 85 MB ← 稳定
loh-size: 12 MB
time-in-gc: 1.2%
working-set: 298 MB ← 稳定在 300MB 左右
内存增长曲线变为锯齿形(Gen0/Gen1 回收后下降),而非持续上升。问题解决。
教训: 静态事件是 .NET 中最常见的内存泄漏源之一。任何订阅静态事件的对象,其生命周期都会被"提升"到与进程一致。除非手动取消订阅,否则永远无法回收。
四、案例二:Timer 不停止——后台任务的"定时炸弹"
4.1 现象
PMS 系统有一个定时报表生成服务,每 5 分钟生成一次船舶设备运行状态报表。上线后发现内存缓慢增长,同时 Gen2 GC 间隔越来越短,最终 GC 停顿达到 300ms+,影响 API 响应时间。
这个问题的特殊之处在于:内存增长非常缓慢(每天约 50MB),不容易在测试环境复现,但在生产环境跑了一个月后累积到 1.5GB。
4.2 定位
dotnet-counters 显示:
gen-2-size: 1,240 MB
loh-size: 340 MB ← LOH 也偏高
time-in-gc: 12.5% ← GC 占比过高
alloc-rate: 45 MB/s ← 持续分配
Dump 分析:
> dumpheap -stat
MT Count TotalSize Class Name
00007f… 8642 412,672,000 Pms.Reports.Models.DeviceStatusReport
00007f… 8640 207,360,000 Pms.Reports.Services.ReportGenerator
00007f… 8640 82,944,000 System.Threading.Timer
00007f… 8640 41,472,000 System.Threading.TimerHolder
00007f… 8642 345,680,000 System.Byte[]
…
8,640 个 Timer 对象! 每 5 分钟一个,一个月 = 30 天 × 24 小时 × 12 次 = 8,640 次。数字完全吻合。
> gcroot 000001a2b3c4d5e0
Thread 5678:
-> 000001a1b2c3d4e5 Pms.Reports.Services.ReportScheduler (单例)
-> 000001a1b2c3d4f0 System.Collections.Generic.List<ReportGenerator>
-> 000001a1b2c3d4e0 Pms.Reports.Services.ReportGenerator
-> 000001a1b2c3d4d0 System.Threading.Timer
4.3 问题代码
// 报表调度器——单例服务
public class ReportScheduler : IHostedService
{
private readonly IServiceScopeFactory _scopeFactory;
private Timer _timer;
// ❌ 问题一:用 List 持有每次创建的 generator,从未清理
private readonly List<ReportGenerator> _activeGenerators = new();
public Task StartAsync(CancellationToken cancellationToken)
{
_timer = new Timer(GenerateReport, null,
TimeSpan.FromMinutes(5), TimeSpan.FromMinutes(5));
return Task.CompletedTask;
}
private void GenerateReport(object state)
{
// ❌ 问题二:每次触发都创建新的 scope 和 generator,
// 但生成完成后 scope 没有被 Dispose!
var scope = _scopeFactory.CreateScope();
var generator = ActivatorUtilities.CreateInstance<ReportGenerator>(
scope.ServiceProvider);
_activeGenerators.Add(generator); // 加入列表,永不移除
// 同步执行——阻塞 Timer 线程池线程
generator.Generate();
// scope.Dispose() 从未调用!
}
public Task StopAsync(CancellationToken cancellationToken)
{
_timer?.Change(Timeout.Infinite, 0);
return Task.CompletedTask;
}
}
public class ReportGenerator
{
private readonly IPmsRepository _repository;
private byte[] _reportBuffer; // 报表数据缓冲区(大对象,进 LOH)
public ReportGenerator(IPmsRepository repository)
{
_repository = repository;
_reportBuffer = new byte[1024 * 1024]; // 1MB 缓冲区 → LOH
}
public void Generate()
{
// 从数据库加载设备状态,生成报表
var devices = _repository.GetAll<DeviceInfo>().ToList();
foreach (var device in devices)
{
// 填充报表数据到 _reportBuffer
}
}
}
双重泄漏:
4.4 修复方案
public class ReportScheduler : BackgroundService
{
private readonly IServiceScopeFactory _scopeFactory;
private readonly ILogger<ReportScheduler> _logger;
public ReportScheduler(
IServiceScopeFactory scopeFactory,
ILogger<ReportScheduler> logger)
{
_scopeFactory = scopeFactory;
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
// 使用 PeriodicTimer(.NET 6+),比 Timer 更安全
using var timer = new PeriodicTimer(TimeSpan.FromMinutes(5));
while (await timer.WaitForNextTickAsync(stoppingToken))
{
try
{
// ✅ using 确保 scope 被 Dispose,所有 Scoped 服务释放
using var scope = _scopeFactory.CreateScope();
var generator = scope.ServiceProvider
.GetRequiredService<ReportGenerator>();
await generator.GenerateAsync(stoppingToken);
}
catch (OperationCanceledException)
{
break;
}
catch (Exception ex)
{
_logger.LogError(ex, "报表生成失败");
}
}
}
}
public class ReportGenerator
{
private readonly IPmsRepository _repository;
// ✅ 使用 ArrayPool 租借缓冲区,避免 LOH 分配
private static readonly ArrayPool<byte> BufferPool =
ArrayPool<byte>.Shared;
public ReportGenerator(IPmsRepository repository)
{
_repository = repository;
}
public async Task GenerateAsync(CancellationToken ct)
{
var buffer = BufferPool.Rent(1024 * 1024);
try
{
var devices = await _repository.GetAll<DeviceInfo>()
.ToListAsync(ct);
// 使用 buffer 生成报表
}
finally
{
// ✅ 归还缓冲区,不产生 GC 压力
BufferPool.Return(buffer);
}
}
}
4.5 验证
gen-2-size: 62 MB
loh-size: 8 MB ← ArrayPool 效果显著
time-in-gc: 0.8%
working-set: 245 MB
教训: Timer/PeriodicTimer 回调中创建的 DI Scope 必须 Dispose。大对象缓冲区优先使用 ArrayPool<T>.Shared,避免 LOH 碎片。使用 BackgroundService + PeriodicTimer 替代手动 Timer,生命周期由框架管理。
五、案例三:非托管内存泄漏——被忽略的 SafeHandle
5.1 现象
这个案例最为棘手。PMS 系统的证书与电子签章模块负责对船岸同步的报文进行数字签名和验签。上线后发现内存增长,但与前两个案例不同:
dotnet-counters 观测:
working-set: 2.1 GB ← 进程内存很高
gc-heap-size: 180 MB ← 但托管堆很小!
gen-2-size: 45 MB
loh-size: 12 MB
托管堆只有 180MB,但进程工作集高达 2.1GB! 这意味着泄漏发生在非托管层。
5.2 定位
首先确认非托管内存来源。.NET 进程的非托管内存可能来自:
查看 GCHandle:
> gchandles
Handles:
Strong Handles: 45
Pinned Handles: 12
Weak Long Handles: 8
Weak Short Handles: 23
Ref Counted Handles: 8642 ← 异常!正常应该很少
8,642 个 Ref Counted Handles!这类 Handle 通常用于 COM Interop 或 Marshal 分配。
查看终结化队列:
> finalizequeue
Heap 0:
Free-Threaded Youngest GC enqueued: 0
Free-Threaded Old GC enqueued: 0
Normal Youngest GC enqueued: 0
Normal Old GC enqueued: 0
…
Heap 1:
…
Normal Old GC enqueued: 8642 ← 8642 个对象在终结化队列上!
8,642 个对象等待终结化但一直没被终结。查看具体类型:
> dumpheap -stat -finalizable
MT Count TotalSize Class Name
00007f… 8642 138,272,000 Pms.Security.NativeSignature
00007f… 8642 69,136,000 System.Runtime.InteropServices.SafeHandle
00007f… 8642 34,568,000 Microsoft.Win32.SafeHandles.SafeHandleZeroOrMinusOneIsInvalid
5.3 问题代码
// 电子签章模块——封装了原生签名库
public class NativeSignature : IDisposable
{
private IntPtr _nativeContext; // 原生库上下文指针
private bool _disposed;
public NativeSignature()
{
// 调用原生库创建签名上下文
// 每个上下文在原生侧占用约 200KB 内存
_nativeContext = NativeMethods.CreateSignatureContext();
}
public byte[] Sign(byte[] data)
{
var signature = new byte[256];
NativeMethods.SignData(_nativeContext, data, data.Length,
signature, ref signature.Length);
return signature;
}
// ❌ 问题一:实现了 IDisposable 但调用方从未 Dispose
// 这些对象进入终结化队列等待 Finalize
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
~NativeSignature()
{
Dispose(false);
}
protected virtual void Dispose(bool disposing)
{
if (!_disposed)
{
// ❌ 问题二:终结器中访问了 _nativeContext,
// 但终结器执行顺序不保证,
// 如果原生库已被卸载或句柄已无效,
// 可能导致 AccessViolation 或静默失败!
NativeMethods.DestroySignatureContext(_nativeContext);
_disposed = true;
}
}
}
internal static class NativeMethods
{
[DllImport("PmsSignatureLib", EntryPoint = "CreateSigContext")]
public static extern IntPtr CreateSignatureContext();
[DllImport("PmsSignatureLib", EntryPoint = "DestroySigContext")]
public static extern int DestroySignatureContext(IntPtr context);
[DllImport("PmsSignatureLib", EntryPoint = "SignData")]
public static extern int SignData(IntPtr context, byte[] data,
int dataLen, byte[] signature, ref int signatureLen);
}
// 调用方——签名服务
public class SignatureService
{
// ❌ 问题三:每次签名都创建新的 NativeSignature,
// 但没有 using,也没有手动 Dispose
public byte[] SignData(byte[] data)
{
var signer = new NativeSignature();
return signer.Sign(data);
// signer.Dispose() 从未调用!
// 每个 signer 泄漏 200KB 原生内存
}
}
为什么 Finalizer 没回收?
关键在于:Finalizer 线程是单线程的。如果某些 Finalizer 执行缓慢或阻塞,后续对象的 Finalizer 就会排队等待。在这个案例中,DestroySignatureContext 原生方法偶尔需要等待 USB Key 硬件响应(最长 10 秒),导致 Finalizer 线程被阻塞,8642 个对象的终结化全部排队。
5.4 修复方案
// ✅ 使用 SafeHandle 替代 IntPtr——.NET 推荐的原生资源管理方式
public class SignatureContextHandle : SafeHandleZeroOrMinusOneIsInvalid
{
private SignatureContextHandle() : base(ownsHandle: true) { }
protected override bool ReleaseHandle()
{
// SafeHandle 的 ReleaseHandle 由关键执行区域(CER)保护,
// 保证在异步异常(如 ThreadAbortException)时也能执行
return NativeMethods.DestroySignatureContext(handle) == 0;
}
}
public class NativeSignature : IDisposable
{
private readonly SignatureContextHandle _handle;
private bool _disposed;
public NativeSignature()
{
_handle = new SignatureContextHandle();
var context = NativeMethods.CreateSignatureContext();
if (context == IntPtr.Zero)
throw new InvalidOperationException("创建签名上下文失败");
// 使用 SetHandle 设置 SafeHandle 的底层句柄
_handle.SetHandle(context);
}
public byte[] Sign(byte[] data)
{
var signature = new byte[256];
int sigLen = signature.Length;
NativeMethods.SignData(_handle.DangerousGetHandle(),
data, data.Length, signature, ref sigLen);
return signature.Take(sigLen).ToArray();
}
public void Dispose()
{
if (!_disposed)
{
// ✅ SafeHandle.Dispose 会触发 ReleaseHandle
// 且 SafeHandle 使用引用计数,线程安全
_handle.Dispose();
_disposed = true;
}
}
}
// 调用方——确保 Dispose
public class SignatureService
{
public byte[] SignData(byte[] data)
{
// ✅ using 确保 Dispose 被调用
using var signer = new NativeSignature();
return signer.Sign(data);
}
}
// 更好的方案:注册为单例,复用签名上下文
// 原生库通常支持多线程安全签名
services.AddSingleton<NativeSignature>(sp =>
{
var signer = new NativeSignature();
return signer;
});
// 如果原生库不是线程安全的,使用池化
public class SignaturePool : IDisposable
{
private readonly ConcurrentBag<NativeSignature> _pool = new();
private int _maxSize;
public NativeSignature Rent()
{
if (_pool.TryTake(out var signer))
return signer;
return new NativeSignature();
}
public void Return(NativeSignature signer)
{
if (_pool.Count < _maxSize)
_pool.Add(signer);
else
signer.Dispose();
}
}
5.5 验证
working-set: 340 MB ← 恢复正常
gc-heap-size: 160 MB
Ref Counted Handles: 3 ← 正常
Finalizer queue length: 0 ← 清空
教训:
六、常见内存泄漏模式速查表
| 静态事件未解绑 | Gen2 持续增长 | gcroot 链经过静态事件 | WeakEventManager 或显式 -= |
| 静态集合只增不减 | Gen2 增长 | 静态 List/Dict 持有大量对象 | 设置容量上限或用缓存策略 |
| Timer 未停止 | 缓慢增长 | Timer/TimerHolder 对象累积 | PeriodicTimer + using |
| DI Scope 未释放 | Scoped 服务泄漏 | DbContext/Repository 对象累积 | using var scope = … |
| 未 Dispose 的非托管资源 | 工作集高、托管堆低 | Ref Counted Handles 多 | SafeHandle + using |
| 大对象频繁分配 | LOH 增长、碎片 | loh-size 高 | ArrayPool<T> / MemoryPool<T> |
| 闭包捕获长生命周期对象 | Gen2 增长 | 匿名方法对象持有大对象 | 注意闭包捕获范围 |
| 异步事件处理器 | 任务对象泄漏 | Task/Action 累积 | CancellationToken + IAsyncDisposable |
| GCHandle.Alloc 未释放 | 非托管内存增长 | Pinned/Normal Handles 多 | using 包装 GCHandle |
| 日志/遥测累积 | 内存缓慢增长 | 日志缓冲区对象多 | 设置批量刷新和上限 |
七、GC 性能优化实战技巧
7.1 减少 Gen2 GC 频率
Gen2 GC 是最昂贵的,一次 Full GC 可能暂停应用 10-100ms+。减少 Gen2 GC 的核心是让对象尽快在 Gen0/Gen1 死亡,避免不必要的晋升。
// ❌ 不好:在热路径上创建大对象,直接进入 LOH 和 Gen2
public async Task<byte[]> ExportReportAsync(ReportQuery query)
{
var allData = await _dbContext.Reports
.Where(r => r.Date >= query.StartDate)
.ToListAsync(); // 可能返回数万行
// 大字符串拼接 → LOH
var csv = new StringBuilder();
foreach (var row in allData)
{
csv.AppendLine($"{row.Id},{row.Name},{row.Value}");
}
return Encoding.UTF8.GetBytes(csv.ToString());
}
// ✅ 好:流式处理 + ArrayPool,避免大对象
public async Task ExportReportAsync(ReportQuery query, Stream output)
{
var buffer = ArrayPool<byte>.Shared.Rent(81920);
try
{
// 分批查询,每次 1000 条
await foreach (var batch in QueryBatchesAsync(query, batchSize: 1000))
{
var csv = string.Join("\\n",
batch.Select(r => $"{r.Id},{r.Name},{r.Value}"));
var bytes = Encoding.UTF8.GetBytes(csv, buffer);
await output.WriteAsync(buffer.AsMemory(0, bytes));
}
}
finally
{
ArrayPool<byte>.Shared.Return(buffer);
}
}
7.2 服务器 GC vs 工作站 GC
<!– 服务器 GC:多线程并行回收,适合高吞吐服务 –>
<!– 默认在 ASP.NET Core 中启用 –>
<ServerGarbageCollection>true</ServerGarbageCollection>
<!– 工作站 GC:单线程回收,暂停时间短但吞吐低 –>
<!– 适合桌面应用或容器内存限制严格的场景 –>
<ServerGarbageCollection>false</ServerGarbageCollection>
<!– 并发 GC:在 Gen2 回收时并发执行,减少暂停时间 –>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
7.3 容器内存限制配置
在 Kubernetes 中运行 .NET 应用时,GC 需要感知容器内存限制:
# .NET 8+ 默认支持 cgroup 内存限制
# 但在某些环境下可能需要显式配置
ENV DOTNET_GCHeapCount=4
ENV DOTNET_GCHighMemoryPct=0x60 # 96%,留给非托管内存
resources:
requests:
memory: "512Mi"
cpu: "500m"
limits:
memory: "1Gi" # GC 会感知这个限制
cpu: "1000m"
7.4 监控告警阈值建议
// 基于 dotnet-counters 指标的告警规则
var alertRules = new
{
// 托管堆超过 limit 的 70%
GcHeapSizeRatio = 0.70,
// GC 时间占比超过 10%
TimeInGcThreshold = 0.10,
// Gen2 GC 频率 > 1次/分钟
Gen2GcRatePerMinute = 1,
// LOH 大小超过托管堆的 30%
LohRatioThreshold = 0.30,
// 工作集与托管堆差异超过 500MB(可能的非托管泄漏)
WorkingSetGapMB = 500
};
八、.NET 8 新特性:DMA 和 DATASG
.NET 8 引入了两个重要的 GC 改进,对生产排障很有帮助:
8.1 Dynamic Memory Adjustment (DMA)
GC 现在可以根据应用的内存需求动态调整堆大小,在容器环境中特别有用:
// .NET 8 中,GC 会自动响应容器内存限制的变化
// 无需手动配置,升级即可受益
8.2 DATASG (Dynamic Adaptation To Application Sizes and Generations)
GC 根据应用的分配模式动态调整各代的预算,减少不必要的 Gen2 GC:
升级前(.NET 6):
Gen0 GC: 1200次/小时
Gen1 GC: 200次/小时
Gen2 GC: 15次/小时 ← 频繁
GC暂停总计: 2.3秒/分钟
升级后(.NET 8 with DATASG):
Gen0 GC: 1100次/小时
Gen1 GC: 150次/小时
Gen2 GC: 3次/小时 ← 降低80%
GC暂停总计: 0.4秒/分钟
九、Checklist:内存问题排查流程
Step 1:确认问题
□ dotnet-counters 观测 working-set vs gc-heap-size
□ 判断是托管泄漏还是非托管泄漏
□ 记录内存增长速率和复现时间
Step 2:托管内存泄漏
□ dotnet-dump collect –type Full 抓取 Dump
□ dumpheap -stat 找最大对象类型
□ gcroot <address> 追踪引用链
□ 重点检查:静态事件、静态集合、未释放的 Scope、Timer
□ 检查 finalizequeue 和 gchandles
Step 3:非托管内存泄漏
□ gchandles 查看 Ref Counted/Pinned Handles
□ finalizequeue 查看终结化队列
□ 检查 P/Invoke 调用是否都有对应的释放
□ 检查 SafeHandle 是否正确实现 ReleaseHandle
□ 必要时使用 jemalloc/tcmalloc 的 profiling 定位
Step 4:GC 性能问题
□ dotnet-counters 查看 time-in-gc
□ dotnet-trace 抓取 GC 事件
□ 分析 alloc-rate 找高频分配点
□ 检查 LOH 大小和碎片
□ 考虑 ArrayPool/MemoryPool 优化
Step 5:验证修复
□ 部署修复版本
□ 持续观察 24-48 小时
□ 确认内存曲线为锯齿形而非持续上升
□ Gen2 GC 频率和暂停时间恢复正常
十、写在最后
内存泄漏排障的核心方法论可以总结为三句话:
GC 帮你管理了内存分配和回收,但没有帮你管理引用关系。好的 .NET 开发者不仅要会写业务代码,更要理解对象在内存中的生命周期——知道它们在哪里出生、在哪里老去、在哪里被回收,以及更重要的——为什么有些对象永远死不了。
💬 互动一下: 你在生产环境遇到过最诡异的内存问题是什么?是托管泄漏还是非托管泄漏?花了多久定位到根因?欢迎在评论区分享你的排障故事。



