C#内存泄漏:Event内存泄漏

前言

内存泄漏是指:当一块内存被分配后,被丢弃,没有任何实例指针指向这块内存, 并且这块内存不会被GC视为垃圾进行回收。这块内存会一直存在,直到程序退出。

C#是托管型代码,其内存的分配和释放都是由CLR负责,当一块内存没有任何实例引用时,GC会负责将其回收。既然没有任何实例引用的内存会被GC回收,那么内存泄漏是如何发生的?

内存泄漏示例

为了演示内存泄漏是如何发生的,我们来看一段代码

class Program 
{
    static event Action TestEvent;
    static void Main(string[] args)
    {
        var memory = new TestAction();
        TestEvent += memory.Run;
        OnTestEvent();
        memory = null;
        //强制垃圾回收
        GC.Collect(GC.MaxGeneration);
        Console.WriteLine("GC.Collect");
        //测试是否回收成功
        OnTestEvent();
        Console.ReadLine();
    }
    public static void OnTestEvent() {
        if (TestEvent != null) TestEvent();
        else Console.WriteLine("Test Event is null");
    }

    class TestAction 
    {
        public void Run() {
            Console.WriteLine("TestAction Run.");
        }
    }
}

该例子中,memory.run订阅了TestEvent事件,引发事件后,会在屏幕上看到 TestAction Run。当memory =null 后,memory原来指向的内存就没有任何实例再引用该块内存了,这样的内存就是待回收的内存。

GC.Collect(GC.MaxGeneration)语句会强制执行一次垃圾回收,再次引发事件,发现屏幕上还是会显示TestAction Run。该内存没有被GC回收,这就是内纯泄漏。这是由TestEvent+=memory.Run语句引起的,当GC.Collect执行的时候,当他看到该块内存还有TestEvent引用,就不会进行回收。

但是该内存已经是“无法到达”的了,即无法调用该块内存,只有在引发事件的时候,才能执行该内存的Run方法。这显然不是我想要的效果,当memory = null执行时,我希望该内存在GC执行时被回收,并且当TestEvent被引发时,Run方法不会执行,因为我已经把该内存“解放”了。

这里有一个问题,就是C#中如何“释放”一块内存。像C和C++这样的语言,内存的声明和释放都是开发人员负责的,一旦内存new了出来,就要delete,不然就会造成内存泄漏。这更灵活,也更麻烦,一不小心就会泄漏,忘记释放、线程异常而没有执行释放的代码...有手动分配内存的语言就有自动分配和释放的语言。

最开始使用垃圾回收的语言是LISP,之后被用在Java和C#等托管语言中。像C#,CLR负责内存的释放,当程序执行一段时间后,CLR检测到垃圾内存已经值得进行一次垃圾回收时,会执行垃圾回收。

至于如何判定一块内存是否为垃圾内存,比较著名的是计数法,即有一个实例引用了该内存后,就在该内存的计数上+1,改实例取消了对该内存的引用,计数就-1,当计数为0时,就被判定为垃圾。该种方法的问题是对循环引用束手无策,如A的某个字段引用了B,而B的某个字段引用了A,这样A和B的技术都不会降到0。

CLR改用的方法是类似“标记引用法”(我自己的命名):在执行GC时,会挂起全部线程,并将托管堆中所有的内存都打上垃圾的标记,之后遍历所有可到达的实例,这些实例如果引用了托管堆的内存,就将该内存的标记由垃圾变为被引用。

当遇到A和B相互引用的时候,如果没有其他实例引用A或者B,虽然A和B相互引用,但是A和B都是不可到达的,即没办法引用A或者B,则A和B都会被判定为垃圾而被回收。讲解了这么一大堆,目的就是要说,在C#中,你想要释放一块内存,你只要让该块内存没有任何实例引用他,就可以了。

那么当执行memory = null后,除了对TestEvent的订阅,没有任何实例再引用了该块内存,那么为什么订阅事件会阻止内存的释放?

我们来看看TestEvent+=memory.Run()这句话都干了什么。我们利用IL反编译上面的dll,可以看到

IL_0000:  nop
IL_0001:  newobj     instance void EventLeakMemory.Program/TestAction::.ctor()
IL_0006:  stloc.0
IL_0007:  ldloc.0
IL_0008:  ldftn      instance void EventLeakMemory.Program/TestAction::Run()
IL_000e:  newobj     instance void [mscorlib]System.Action::.ctor(object, native int)
IL_0013:  call       void EventLeakMemory.Program::add_TestEvent(class [mscorlib]System.Action)
...//其他部分

关键在5-7行。第5和6行,声明了一个System.Action型的委托,参数为TestAction.Run方法,第七行,执行了Program.add_TestEvent方法,参数是上面声明的委托。

也就是说+=操作符相当于执行了Add_TestEvent(new Action(memory.Run)),就是这个new Action包含了对memory指向的内存的引用。而这个引用在CLR看来是可达的,可以通过引发事件来调用该内存。

解决办法

我们已经找到了内存泄漏的元凶,就是订阅事件时,隐式声明的匿名委托对内存的引用。最简单的解决办法是手动取消订阅事件,只要TestEvent -= memory.Run就可以了。

但如何实现一个不需要手动取消订阅的事件?该问题的解决办法是使用一种和普通的引用不同的方式来引用方法的实例对象:该引用不会影响垃圾回收,不会在GC时被判定为对该内存的引用,也就是“弱引用”。C#中,绝大部分的类型都是强引用。如何实现弱引用?来看一个例子:

static void Main(string[] args){
    var obj = new object();
    var gcHandle = GCHandle.Alloc(obj, GCHandleType.Weak);
    Console.WriteLine("gcHandle.Target == null is :{0}", gcHandle.Target == null);
    obj = null;
    GC.Collect();
    Console.WriteLine("GC.Collect");
    Console.WriteLine("gcHandle.Target == null is :{0}", gcHandle.Target == null);
    Console.ReadLine();
}

当执行GC。Collect后,gcHandle.Target == null 由false 变成了true。

这个gcHandle就是obj的一个弱引用。这个类的详细介绍见 GCHandle 。

比较关键的是GCHandle.Alloc方法的第二个参数,该参数接受一个枚举类型。

我使用的是GCHandleType.Weak,表明该引用是个弱引用。利用这个方法,就可以封装一个自己的WeakReference类,代码如下:

public class WeakReference<TDelegate> : IEquatable<Delegate> {
    private GCHandle _handle;
    public WeakReference(Delegate obj) {
        if (obj == null) return;
        _handle = GCHandle.Alloc(obj, GCHandleType.Weak);
    }
    /// <summary>
   /// 引用的目标是否还存活(没有被GC回收)
    /// </summary>
    public bool IsAlive {
        get { return _handle != default(GCHandle) && _handle.Target != null; }
    }

    /// <summary>
    /// 引用的目标
    /// </summary>
    public TDelegate Target {
        get {
            if (_handle == default(GCHandle)) return default(TDelegate);
            return (TDelegate)_handle.Target;
        }
    }

    /// <summary>
    /// 实现接口,方便与委托的比较
    /// </summary>
    /// <param name="other"></param>
    /// <returns></returns>
    public bool Equals(Delegate other) {
        return _handle != default(GCHandle) && other != null &&
            ((Delegate)_handle.Target).Method.Equals(other.Method);
    }
    /// <summary>
    /// 释放弱引用
    /// </summary>
    ~WeakReference() {
        _handle.Free();
    }
}

我实现了IEquatable<Delegate>接口,该接口能方便的比较WeakReference实例和委托是否指一个方法。利用该类,就可以写一个自己的弱事件封装器。

public class WeakEventManager {
    private readonly List<WeakReference<Delegate>> _delegateList;
    public WeakEventManager() {
        _delegateList = new List<WeakReference<Delegate>>();
    }

    /// <summary>
    /// 订阅
    /// </summary>
    public void AddHandler(Delegate handler) {
        if (handler != null)
            _delegateList.Add(new WeakReference<Delegate>(handler));
    }

    /// <summary>
    /// 取消订阅
    /// </summary>
    public void RemoveHandler(Delegate handler) {
        if (handler == null) return;
        //由于我实现了IEquatable<Delegate>,这里能够很方便的比较
        var sameHandler = _delegateList.FirstOrDefault(e => e.Equals(handler));
        if (sameHandler != null)
            _delegateList.Remove(sameHandler);
    }

    /// <summary>
    /// 引发事件
    /// </summary>
    public void Raise(object sender, EventArgs e) {
        foreach (var d in _delegateList.ToList()) {
            if (d.IsAlive)
                d.Target.DynamicInvoke(sender, e);
            else
                _delegateList.Remove(d);
        }
    }
}

最后,就可以像下面这样定义自己的事件了

public class TestEventClass {
    private WeakEventManager<Action<object, EventArgs>> _testEvent = new WeakEventManager<Action<object, EventArgs>>();
    public event Action<object, EventArgs> TestEvent {
        add { _testEvent.AddHandler(value); }
        remove { _testEvent.RemoveHandler(value); }
    }
    protected virtual void OnEvent(EventArgs e) {
        _testEvent.Raise(this, e);
    }
}
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,313评论 6 496
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,369评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 159,916评论 0 349
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,333评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,425评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,481评论 1 292
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,491评论 3 412
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,268评论 0 269
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,719评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,004评论 2 328
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,179评论 1 342
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,832评论 4 337
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,510评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,153评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,402评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,045评论 2 365
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,071评论 2 352

推荐阅读更多精彩内容