常见的内存泄露方式
2024-03-17 17:33:41

本以为带有GC功能的 C# 不会有内存泄露问题,原来我天真了,其实 C# 也会有内存泄露问题,只是不像 C/C++ 那么有负担。

常见泄露方式

在 C/C++ 语言中,内存泄露是因为分配了内存后未释放,同时没有指针指向它导致的。
而在 C# 中,内存泄露是因为内存被引用了,但引用这块内存的实例却从不会被访问到,从而导致 GC 无法回收它。

未取消事件订阅

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
28
29
30
31
32
33
34
35
36
37
38
39
40
public class Publisher
{
public event EventHandler MyEvent;

public void DoSomething()
{
MyEvent(this, EventArgs.Empty);
}
}

public class Subscriber
{
public void Subscribe(Publisher publisher)
{
publisher.MyEvent += HandleEvent;
}

private void HandleEvent(object sender, EventArgs e)
{
Console.WriteLine("Event handled by Subscriber");
}
}

public class Program
{
private static void Foo(Publisher publisher)
{
var subscriber = new Subscriber();
subscriber.Subscribe(publisher);
publisher.DoSomething();
}

public static void Main(string[] args)
{
var publisher = new Publisher();
Foo(publisher);
publisher.DoSomething();
Console.ReadLine();
}
}

不出意外的话,事件处理器会被调用两次。因为 Publisher 实例的 MyEvent 一直持有对 Subscriber 的引用,导致内存泄露。
最好的解决方式是手动用-=操作及时取消订阅,这是最好的方式,没有之一。

使用弱事件模式

弱事件模式(Weak Event Pattern)是一种在事件订阅中使用弱引用的设计模式。它旨在解决常见的事件订阅导致的潜在内存泄漏问题。
订阅者对象使用弱引用来订阅事件。通过使用弱引用,订阅者对象可以在没有其他强引用时被垃圾回收,从而避免潜在的内存泄漏问题。当订阅者对象被垃圾回收时,事件发布者不再持有对其的引用,因此不会阻止其被回收。

1
2
3
4
5
6
public void Subscribe(Publisher publisher)
{
//publisher.MyEvent += HandleEvent;

WeakEventManager<Publisher, EventArgs>.AddHandler(publisher, nameof(publisher.MyEvent), HandleEvent);
}

WeakEventManager 是基础。.NET 内置了一些弱事件类如 WeakEventManager<TEventSource,TEventArgs>PropertyChangedEventManager 等等。
再次运行程序,还是会有两次输出,这是因为 GC 没有及时回收 Subscriber,这可能会导致各种意想不到的副作用。所以在使用弱事件时需要注意这个问题。

lambda函数

lambda函数适合作为事件处理器吗?

1
2
3
4
publisher.MyEvent += (object sender, EventArgs args) =>
{
Console.WriteLine(this._name);
};

结果是 GC 之后处理器仍然会被调用。本质上这个处理器还是强引用,且无法被取消。
而如果在这个函数中有任何对类实例的引用,那么也会造成内存泄露。

1
2
3
4
publisher.MyEvent += (object sender, EventArgs args) =>
{
Console.WriteLine(this.name); // 引用了类的字段
};

所以为了避免内存泄露,还是要为实例指定一个变量并稍后取消订阅:

1
2
3
4
5
6
7
EventHandler handler = delegate (object sender, EventArgs e)
{
//some code
};

publisher.MyEvent += handler;
publisher.MyEvent -= handler;

静态引用

1
2
3
4
5
6
7
8
9
public class MyClass
{
private static List<MyClass> _instances = new List<MyClass>();

public MyClass()
{
_instances.Add(this);
}
}

在这段代码中,MyClass类的构造函数会将每个新创建的实例添加到一个静态的List<MyClass>集合中。由于List<MyClass>是静态的,它会一直保留对所有实例的引用。这将导致内存泄漏,因为这些对象无法被释放。
为了解决这个问题,可以通过添加一个析构函数或者手动释放资源的方法来从_instances集合中移除实例。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class MyClass
{
private static List<MyClass> _instances = new List<MyClass>();

public MyClass()
{
_instances.Add(this);
}

~MyClass()
{
_instances.Remove(this);
}
}

计时器

1
2
3
4
5
6
7
8
9
10
11
12
13
public class MyClass
{
public MyClass()
{
Timer timer = new Timer(HandleTick);
timer.Change(TimeSpan.FromSeconds(5), TimeSpan.FromSeconds(5));
}

private void HandleTick(object state)
{
// do something
}
}

由于没有在适当的时候停止或销毁 Timer,它将一直保持对 MyClass 实例的引用,导致内存泄漏。

总结

内存泄漏的本质是引用没有被正确释放,导致对象无法被垃圾回收器回收。在 C# 中,垃圾回收器负责自动管理内存,它会跟踪对象之间的引用关系,并在对象不再被引用时,自动回收它们所占用的内存。
当一个对象不再被引用时,垃圾回收器会将其标记为可回收。但如果该对象仍然被其他对象引用,或者存在循环引用,垃圾回收器无法判断该对象是否仍然需要保留。这就导致了一个内存泄漏的情况,即对象无法被垃圾回收器正确释放,从而持续占用内存。
为了避免内存泄漏,需要及时释放对象的引用。这可以通过手动解除对象之间的引用、及时取消注册事件处理器、释放非托管资源、将静态变量设置为弱引用等方式来实现。这样,垃圾回收器就能够正确地判断对象是否可以被回收,并及时释放对象所占用的内存。

参考资料

可能会导致.NET内存泄露的8种行为
5 Techniques to avoid Memory Leaks by Events in C# .NET you should know