一、内存泄漏那些事儿

最近团队里有个同事愁眉苦脸地跑来问我:"为什么我的服务跑着跑着内存就炸了?" 这让我想起自己刚工作时,也曾经被内存泄漏折磨得死去活来。今天咱们就用.NET Core这个技术栈,好好聊聊怎么揪出这些"内存小偷"。

内存泄漏就像家里漏水的水龙头,刚开始可能只是几滴水,但时间一长整个房子都能被淹了。在.NET Core里,最常见的情况是对象被意外地"挂"在了某个地方,垃圾回收器(GC)以为它们还有用,结果内存就这样被慢慢吃光了。

二、实战前的准备工作

工欲善其事必先利其器,我们先准备几个趁手的工具:

  1. Visual Studio诊断工具:内置的内存分析功能相当好用
  2. dotMemory:JetBrains家的专业内存分析工具
  3. PerfView:微软官方出品的性能分析神器

这里我推荐先用Visual Studio自带的工具,毕竟开箱即用。打开诊断窗口的快捷键是Alt+F2,选择"内存使用量"就能看到实时内存曲线。

三、典型泄漏场景与复现

咱们通过几个典型例子来看看内存是怎么溜走的:

示例1:事件订阅不解除(.NET Core 6.0)

public class EventLeaker
{
    public event EventHandler SomethingHappened;
    
    public void DoWork()
    {
        for(int i=0; i<10000; i++)
        {
            var worker = new Worker();
            // 这里订阅事件但从未取消
            SomethingHappened += worker.HandleEvent;
        }
    }
}

public class Worker
{
    public void HandleEvent(object sender, EventArgs e)
    {
        Console.WriteLine("Event handled");
    }
}

这个例子中,每次调用DoWork()都会创建1万个Worker实例,但由于事件订阅的存在,这些实例永远不能被GC回收。解决方法很简单,在不需要的时候取消订阅:

SomethingHappened -= worker.HandleEvent;

示例2:静态集合滥用(.NET Core 7.0)

public static class CacheManager
{
    // 静态字典会一直持有引用
    private static readonly Dictionary<string, object> _cache = new();
    
    public static void AddToCache(string key, object value)
    {
        _cache[key] = value;
    }
    
    // 但往往我们忘记写清理方法
}

这种缓存实现最大的问题是只进不出。正确的做法应该:

  1. 设置缓存过期时间
  2. 使用WeakReference来持有对象
  3. 或者直接使用MemoryCache这个官方类

示例3:Timer不释放(.NET Core 8.0预览版)

public class TimerLeak
{
    private Timer _timer;
    
    public void Start()
    {
        _timer = new Timer(_ => 
        {
            Console.WriteLine("Tick");
        }, null, 0, 1000);
    }
}

Timer会保持其所属对象的活性。如果不调用Dispose(),相关资源就会一直存在。正确的做法是实现IDisposable:

public void Dispose()
{
    _timer?.Dispose();
}

四、诊断与修复实战

现在假设我们已经发现内存异常增长,该怎么定位问题?这里分享我的标准排查流程:

  1. 抓取内存快照:在内存高位和低位时各抓一个dump文件
  2. 比较对象增量:看看哪些对象异常增长
  3. 分析引用链:找到是谁在持有这些对象
  4. 重现验证:修复后再次验证内存曲线

举个实际案例,上周我们发现有个API内存持续增长。通过dotMemory分析发现是大量的MemoryStream没被释放。最后发现是有人写了这样的代码:

public async Task<byte[]> ProcessFile(IFormFile file)
{
    using var ms = new MemoryStream();
    await file.CopyToAsync(ms);
    return ms.ToArray(); // 这里实际上使using失效了!
}

问题出在返回的是数组,而MemoryStream已经被释放了。正确的做法应该是先复制数据再释放:

var data = ms.ToArray();
return data;

五、预防胜于治疗

根据我的经验,预防内存泄漏有几个黄金法则:

  1. 实现IDisposable接口:对任何持有非托管资源的类
  2. 避免长生命周期对象引用短生命周期对象:比如静态集合
  3. 善用using语句:特别是对文件、数据库连接等
  4. 定期代码审查:重点关注事件、静态变量、缓存等
  5. 自动化测试:可以编写内存压力测试

.NET Core其实提供了很好的内存管理机制,但再好的工具也架不住错误的使用方式。我建议在项目初期就建立内存监控机制,比如在Kubernetes中设置内存限制和自动重启策略。

六、高级技巧与工具

对于更复杂的场景,我们可以使用一些进阶方法:

  1. GC.AddMemoryPressure:当使用大量非托管内存时通知GC
  2. DiagnosticSource:监听GC事件
  3. 创建内存压力测试:像这样:
[Fact]
public void MemoryTest()
{
    var start = GC.GetTotalMemory(true);
    
    // 执行被测代码
    
    var end = GC.GetTotalMemory(true);
    Assert.True(end - start < 1000000, "内存增长超过1MB");
}

七、总结与建议

处理内存泄漏就像侦探破案,需要耐心和系统的方法。根据我的经验,90%的内存泄漏都集中在以下几个场景:

  1. 未取消的事件订阅
  2. 静态集合的滥用
  3. 未正确释放的资源
  4. 缓存失控
  5. 第三方库的配置不当

最后给三个实用建议:

  1. 小步快跑:每次修改后都检查内存
  2. 善用工具:不要靠猜
  3. 建立基线:知道正常的内存使用量是多少

记住,内存问题往往不会立即暴露,但一旦爆发就是大问题。希望这篇文章能帮你少踩些坑!