让我们来聊聊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也无法回收。解决方法有三种:

  1. 显式取消订阅(-=)
  2. 使用WeakEventManager
  3. 让订阅者实现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();
    }
}

六、解决方案大合集

根据多年填坑经验,总结出这套组合拳:

  1. 诊断工具

    • Visual Studio诊断工具集
    • dotMemory/dotTrace
    • PerfView
  2. 编码规范

    // 安全模板
    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;
        }
    }
    
  3. 架构设计

    • 对于缓存:采用WeakReference或MemoryCache
    • 对于事件:使用WeakEvent模式
    • 对于资源:遵循Dispose模式

七、实战中的血泪教训

去年我们有个WPF项目出现内存泄漏,症状是:

  • 用户每打开一个文档窗口,内存增加200MB
  • 关闭窗口后内存只释放50MB
  • 使用10次后程序崩溃

最终发现是:

  1. 窗口订阅了全局静态服务的事件
  2. 窗口内部使用了5个Timer做动画
  3. 有个LINQ查询捕获了整个文档模型

解决方案:

  1. 改用WeakEventManager
  2. 为窗口实现IDisposable
  3. 在Closed事件中Dispose所有组件
  4. 对大数据查询立即执行.ToList()

改造后内存占用稳定在±50MB波动,就像给程序做了"肾脏透析"。

八、防泄漏 checklist

最后送大家我的自查清单: ✅ 所有IDisposable对象是否都有using或Dispose? ✅ 静态集合是否有限制或使用弱引用? ✅ 事件订阅是否有对应的取消订阅? ✅ Timer是否随宿主对象一起销毁? ✅ LINQ查询是否意外捕获大对象? ✅ 缓存是否有过期策略? ✅ 非托管资源是否双释放(托管+非托管)?

记住:内存泄漏就像程序界的"慢性病",早期发现成本低,晚期调试要人命。建议在新功能开发完成后,立即用诊断工具做内存测试,别等上线后才追悔莫及。