让我们来聊聊C#开发中那个让人头疼的老朋友——内存泄漏。就像家里漏水的水龙头,看似不起眼,但日积月累能把整个房子泡坏。下面我就用几个典型案例,手把手教你排查和解决这些问题。
一、事件订阅引发的"记忆纠缠"
事件处理是最常见的泄漏场景之一。想象你订阅了杂志却从不退订,邮箱迟早会爆炸。看这段WPF示例:
// 技术栈:.NET Framework 4.8 + WPF
public class EventLeakDemo
{
public void SetupEvent()
{
var publisher = new EventPublisher();
var subscriber = new EventSubscriber();
// 危险操作:普通事件绑定
publisher.MyEvent += subscriber.HandleEvent;
// 安全做法:弱事件模式(需实现IWeakEventListener)
// WeakEventManager<EventPublisher, EventArgs>
// .AddHandler(publisher, "MyEvent", subscriber.HandleEvent);
}
}
public class EventPublisher
{
public event EventHandler MyEvent;
}
public class EventSubscriber
{
public void HandleEvent(object sender, EventArgs e)
{
Console.WriteLine("Event handled");
}
}
这里即使subscriber对象不再使用,由于publisher持有它的引用,GC也无法回收。解决方法有三种:
- 显式取消订阅(-=)
- 使用WeakEventManager
- 让订阅者实现IDisposable
二、静态成员的"永生诅咒"
静态变量就像长生不老的吸血鬼,会一直存活在内存中。特别是当它们引用实例对象时:
// 技术栈:.NET 6 Console App
public static class StaticLeak
{
private static List<byte[]> _cache = new();
public static void AddToCache(byte[] data)
{
// 每次调用都会使传入的byte[]获得永生
_cache.Add(data);
// 解决方案1:定期清理
if (_cache.Count > 1000)
_cache.Clear();
// 解决方案2:使用WeakReference
// _cache.Add(new WeakReference(data));
}
}
我曾经遇到过一个案例:开发者用静态字典缓存用户数据,结果服务器运行一周后内存暴涨到32GB。关键是要记住:静态集合是内存的"黑洞",要么限制大小,要么用弱引用。
三、Timer的"僵尸唤醒"
System.Timers.Timer和System.Threading.Timer都是隐形杀手:
// 技术栈:.NET Core 3.1
public class TimerLeak
{
private Timer _timer;
public void StartTimer()
{
_timer = new Timer(_ =>
{
Console.WriteLine(DateTime.Now);
}, null, 1000, 1000);
// 必须显式Dispose!否则即使对象销毁,timer仍会持续触发
}
// 正确做法:实现IDisposable
public void Dispose()
{
_timer?.Dispose();
}
}
更隐蔽的是事件+Timer的组合拳:
public class DoubleKill
{
private Timer _timer;
public DoubleKill(EventPublisher publisher)
{
_timer = new Timer(_ =>
{
publisher.RaiseEvent();
}, null, 1000, 1000);
publisher.MyEvent += OnEvent;
}
private void OnEvent(object sender, EventArgs e) { }
}
这样不仅timer本身泄漏,还会导致包含它的对象无法释放。记住:任何实现了IDisposable的组件,都要配套使用using或手动Dispose。
四、非托管资源的"幽灵附体"
当使用文件句柄、数据库连接等非托管资源时:
// 技术栈:.NET 5 + SQL Server
public class UnmanagedLeak
{
public void LeakFileHandles()
{
var file = File.Open("data.txt", FileMode.Open);
// 忘记file.Dispose()会导致句柄泄漏
// 直到进程结束才会释放
// 正确姿势1:using语句
using var safeFile = File.Open("safe.txt", FileMode.Open);
// 正确姿势2:try-finally
FileStream anotherFile = null;
try
{
anotherFile = File.Open("another.txt", FileMode.Open);
}
finally
{
anotherFile?.Dispose();
}
}
}
我曾经调试过一个ASP.NET Core应用,每次请求都打开几个文件但未关闭,运行三天后服务器报"Too many open files"错误。关键点:
- 实现IDisposable的类90%都需要手动释放
- 使用JetBrains DotMemory或VS诊断工具可以快速定位
五、LINQ查询的"延迟陷阱"
某些LINQ操作会意外保持对象引用:
// 技术栈:.NET 7
public class LinqLeak
{
private IEnumerable<object> _leakyQuery;
public void SetupQuery(List<object> data)
{
// 危险:延迟执行会捕获整个data集合
_leakyQuery = data.Where(x => x != null);
// 安全:立即执行.ToList()
var safeQuery = data.Where(x => x != null).ToList();
}
}
特别是在使用Entity Framework时:
public class EfLeak
{
private DbContext _context = new MyDbContext();
public IEnumerable<User> GetUsers()
{
// 每次迭代都会连接数据库,并保持_context状态
return _context.Users.AsEnumerable();
// 正确做法1:立即加载
// return _context.Users.ToList();
// 正确做法2:分离上下文
// using var tempContext = new MyDbContext();
// return tempContext.Users.ToList();
}
}
六、解决方案大合集
根据多年填坑经验,总结出这套组合拳:
诊断工具:
- Visual Studio诊断工具集
- dotMemory/dotTrace
- PerfView
编码规范:
// 安全模板 public class SafeClass : IDisposable { private bool _disposed; ~SafeClass() => Dispose(false); public void Dispose() { Dispose(true); GC.SuppressFinalize(this); } protected virtual void Dispose(bool disposing) { if(_disposed) return; if(disposing) { // 释放托管资源 _timer?.Dispose(); _event?.Dispose(); } // 释放非托管资源 CloseHandle(_fileHandle); _disposed = true; } }架构设计:
- 对于缓存:采用WeakReference或MemoryCache
- 对于事件:使用WeakEvent模式
- 对于资源:遵循Dispose模式
七、实战中的血泪教训
去年我们有个WPF项目出现内存泄漏,症状是:
- 用户每打开一个文档窗口,内存增加200MB
- 关闭窗口后内存只释放50MB
- 使用10次后程序崩溃
最终发现是:
- 窗口订阅了全局静态服务的事件
- 窗口内部使用了5个Timer做动画
- 有个LINQ查询捕获了整个文档模型
解决方案:
- 改用WeakEventManager
- 为窗口实现IDisposable
- 在Closed事件中Dispose所有组件
- 对大数据查询立即执行.ToList()
改造后内存占用稳定在±50MB波动,就像给程序做了"肾脏透析"。
八、防泄漏 checklist
最后送大家我的自查清单: ✅ 所有IDisposable对象是否都有using或Dispose? ✅ 静态集合是否有限制或使用弱引用? ✅ 事件订阅是否有对应的取消订阅? ✅ Timer是否随宿主对象一起销毁? ✅ LINQ查询是否意外捕获大对象? ✅ 缓存是否有过期策略? ✅ 非托管资源是否双释放(托管+非托管)?
记住:内存泄漏就像程序界的"慢性病",早期发现成本低,晚期调试要人命。建议在新功能开发完成后,立即用诊断工具做内存测试,别等上线后才追悔莫及。
评论