C#内存泄漏问题排查案例实践

使用程序逻辑排查 - 白盒方式

适用于排查过程中能够随时查看并修改程序源码的场景。
是使用程序代码排查的方式确定关于程序编写过程中存在的对象引用链问题、资源释放问题。并对问题代码进行修复并观察后续运行状态的方法。

针对已发布版本程序的运行中状态排查 - 黑盒方式。

适用于程序已经运行于生产环境,不能随时停止且没有指定版本源码的场景。可通过附加调试进程,获取现场堆场景对程序运行状态做分析。

准备工具

  1. Procdump
  2. dotMemory
  3. .NET Memory Profile
  4. 目标调试可执行文件

C# 与 C++的内存释放模式对比
C++的内存释放模式,使用引用计数器。

1
2
3
4
5
6
7
{   //作用域开始
MyClass myClass1;
//使用对象
} // 作用域结束,C++调用对象的析构函数

MyClass* myClass2 = new MyClass();
delete myClass2; //回收对象,调用析构函数

而在.NET编程中,退出作用域并不销毁一个对象。.NET不使用对象的引用计数方法,在每个.NET的宿主进程中,运行时都会预先分配一个叫托管堆的特殊内存堆。它的作用和传统的操作系统堆相似:为对象和数据存储分配内存。例如开发人员使用new操作符时:
MyClass myClass2 = new MyClass();
.NET都会从托管堆中分配内存。所以严谨的情况下,最好在析构函数中对资源进行手动释放。

规范对象使用的方法

  1. 客户端应当限定对象的使用范围。
    使用using语句自动生成一个使用Dispose方法的try/finally块。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class MyClass : IDisposable
    {
    public void SomeMethod() {
    Console.WriteLine("SomeMehtod invoked");
    }

    public void Dispose()
    {
    throw new NotImplementedException();
    }
    }

以下两种调用方式是等价的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
MyClass myClass= new MyClass();
using (myClass)
{
myClass.SomeMethod();
}
----------------------------------------------------------
try
{
myClass.SomeMethod();
}
finally {
if (myClass != null) {
IDisposable disposable = myClass as IDisposable;
disposable.Dispose();
}
}
  1. 使用“确定性终结”。
    .NET无需显示的释放对象所占有的内存。理论上讲,开发者将对象的释放完全交给CLR进行处理也就意味着对象的释放是在某个不确定的时刻进行,通常是在托管堆资源耗尽时,但如果是数据库长连接的这种宝贵资源,是应该在以业务为导向的前提下进行创建和释放。

基于此,我们可以借鉴某些系统对象的使用方式。例如File对象和数据库连接对象,他们有一个共同的使用方式是操作之前先Open(),使用完成之后调用Close(),两种操作配对使用。这种方式叫做开/闭模式。

  1. 使用析构函数。
    在可能使用显式释放对象的场景中手动释放对象,把能不交给垃圾回收器回收的对象自己手动释放掉。

使用.NET Memory Profile查找托管堆上的大内存对象

测试代码如下:

1
2
3
4
5
6
7
List<string> list = new List<string>();
for (int i = 0; i < int.MaxValue; i++)
{
string s = string.Join(",", Enumerable.Range(1000000, 10000000));
Console.WriteLine(s);
list.Add(s);
}

使用.NET Memory Profile附加目标调试进程,运行之后在Overview中观察到System.String[]的存活bytes很多,选中此项查看选项Show Type details,可以观察到对象所在的函数Main,此方法可以粗略定位程序中的在托管堆连续创建对象的函数。下图可以看到持有

String[]对象的Main函数,是在Program这个文件中。

利用堆转储文件对可执行文件进行分析

首先利用procdump生成目标可执行文件的堆转储文件*.dmp,将procdump路径加入系统环境变量,在命令行中执行:

1
2
3
4
5
6
7
procdump.exe TestMemoryLeak -m 1024 -ma D:\dotnet\

参数解释:
TestMemoryLeak 目标调试进程名
-m 1024 在目标进程内存占用超过1024m时记录堆转储文件
-ma 指定包含所有进程内存信息,默认转储格式仅包括线程和句柄信息
D:\dotnet\ 堆转储文件的保存路径

上述命令执行后,当程序的内存使用超过1024M时,会在指定的路径下生成扩展名为.dmp的堆转储文件。

也可以在Windows的任务管理器中,右键对应的进程,选择“创建转储文件”。生成的文件将存放 在%用户%/AppData/Local路径中。

将生成的dmp文件导入到dotMemory中,即可直观的展现出堆现场的状态。

汇总页已给予诊断信息:A large number of strings with the same value is inefficient from the point of memory usage. 内存中保存了大量重复的字符串,可根据这些字符串中存储的数据对有问题的程序逻辑进行定位。

通过对dmp文件的分析可以找到现场占用存储大的对象类别。

另外dotMemory也支持通过Visual Studio插件进行调试分析。

使用dotMemoryUnit对方法体进行独立测试

若排查过程中定位到程序中可能存在内存泄漏的方法体,可以单独建立Nunit测试项目编写单元测试逻辑对独立方法进行检测:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
using JetBrains.dotMemoryUnit;

dotMemory.Check(memory => test());

void test()
{
List<string> list = new List<string>();
for (int i = 0; i < int.MaxValue; i++)
{
string s = string.Join(",", Enumerable.Range(1000000, 10000000));
Console.WriteLine(s);

list.Add(s);
}

Console.ReadLine();
}

检测结果将以单元测试结果的方式直接在Visual Studio中展现。

其他

  1. 注册事件后记得退出或销毁程序之前进行事件反注册
  2. 在频繁使用大块的byte组时,例如缓冲区场景时使用可重用的对象池。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    class BufferPool<T>
    {
    private readonly Func<T> _factoryMethod;
    private ConcurrentQueue<T> _queue = new ConcurrentQueue<T>();

    public BufferPool(Func<T> factoryMethod)
    {
    _factoryMethod = factoryMethod;
    }

    public void Allocate(int count)
    {
    for (int i = 0; i < count; i++)
    _queue.Enqueue(_factoryMethod());
    }

    public T Dequeue()
    {
    T buffer;
    return !_queue.TryDequeue(out buffer) ? _factoryMethod() : buffer;
    }

    public void Enqueue(T buffer)
    {
    _queue.Enqueue(buffer);
    }
    }

使用方法:

1
2
3
4
5
6
var myPool = new BufferPool<byte[]>(() => new byte[65535]);
myPool.Allocate(1000);

var buffer = myPool.Dequeue();
// .. do something here ..
myPool.Enqueue(buffer);
  1. 作为补救措施可以使用类似linux环境下的Supervisor的进程监控工具,当进程挂掉时可以立即拉起,之后通过分析系统日志程序退出方式定位问题。

附件:
链接: https://pan.baidu.com/s/1nrTnJ406AntM5PbF0kmuJw?pwd=diu8 提取码: diu8 复制这段内容后打开百度网盘手机App,操作更方便哦