你的位置:首页 > ASP.net教程

[ASP.net教程]Release编译模式下,事件是否会引起内存泄漏问题初步研究


记:不常发生的事件内存泄漏现象

想必有些朋友可能也常常使用事件,但是很少解除事件挂钩程序也没有听说过内存泄漏之类的问题。幸运的是,在某些情况下,的确不会出问题,很多年前做的项目就跑得好好的,我们静态可以做一个实验来再次验证下。为了验证这个问题,我一度怀疑自己代码写错了,甚至照着书上(网上)例子写也无法重现事件引起内存泄漏的问题,难道教科书说错了么?

首先来看看我的代码,先准备2个类,一个发起事件,一个处理事件:

 class A  {    public event EventHandler ToDoSomething ;    public A()    {    }    public void RaiseEvent()    {      ToDoSomething(this, new EventArgs());    }    public void DelEvent()    {      ToDoSomething = null;    }    public void Print(string msg)    {      Console.WriteLine("A:{0}", msg);          }  }  class B  {    byte[] data = null;        public B(int size)    {      data = new byte[size];      for (int i = 0; i < size ; i++)        data[i] = 0;    }    public void PrintA(object sender, EventArgs e)    {      ((A)sender).Print("sender:"+ sender.GetType ());    }  }

然后,在主程序里面写下面的方法:

 static void TestInitEvent(A a)    {      var b = new B(100 * 1024 * 1024);      a.ToDoSomething += b.PrintA;    }

 这里将初始化一个 100M的对象B的实例b,然后让对象a的事件挂钩在b的方法PrintA 上。平常情况下,b是方法内部的局部变量,但由于b对象的方法挂钩在了方法参数 a 对象的事件上,所以在这里对象 b的生命周期没有结束,这可以稍后由对象 a发起事件,b的 PrintA 方法被调用得到证实。

为了监测当前测试耗费了多少内存,准备一个方法  getWorkingSet,代码如下:

 static void getWorkingSet()     {      using (var process = Process.GetCurrentProcess())       {        Console.WriteLine("---------当前进程名称:{0}-----------",process.ProcessName);        using (var p1 = new PerformanceCounter("Process", "Working Set - Private", process.ProcessName))        using (var p2 = new PerformanceCounter("Process", "Working Set", process.ProcessName))        {          Console.WriteLine(process.Id);          //注意除以CPU数量          Console.WriteLine("{0}{1:N} KB", "工作集(进程类)", process.WorkingSet64 / 1024);          Console.WriteLine("{0}{1:N} KB", "工作集 ", process.WorkingSet64 / 1024);          // process.PrivateMemorySize64 私有工作集 不是很准确,大概多9M           Console.WriteLine("{0}{1:N} KB", "私有工作集 ", p1.NextValue() / 1024); //p1.NextValue()          //Logger("{0};内存(专用工作集){1:N};PID:{2};程序名:{3}",           //       DateTime.Now, p1.NextValue() / 1024, process.Id.ToString(), process.ProcessName);                  }      }      Console.WriteLine("--------------------------------------------------------");      Console.WriteLine();          }

 

下面,开始在主程序里面开始写如下测试代码:

      getWorkingSet();      A a = new A();      TestInitEvent(a);      Console.WriteLine("1,按下任意键开始垃圾回收");      Console.ReadKey();      GC.Collect();      getWorkingSet();

看屏幕输出:

---------当前进程名称:ConsoleApplication1.vshost-----------4848工作集(进程类)25,260.00 KB工作集 25,260.00 KB私有工作集 8,612.00 KB--------------------------------------------------------1,按下任意键开始垃圾回收---------当前进程名称:ConsoleApplication1.vshost-----------4848工作集(进程类)135,236.00 KB工作集 135,236.00 KB私有工作集 111,256.00 KB

程序开始运行后,正好多了100M内存占用。当前程序处于IDE的调试状态下,然后,我们直接运行测试程序,不调试(Release),再次看下结果:

---------当前进程名称:ConsoleApplication1-----------7056工作集(进程类)10,344.00 KB工作集 10,344.00 KB私有工作集 7,036.00 KB--------------------------------------------------------1,按下任意键开始垃圾回收---------当前进程名称:ConsoleApplication1-----------7056工作集(进程类)121,460.00 KB工作集 121,460.00 KB私有工作集 109,668.00 KB--------------------------------------------------------

可以看到在Release 编译模式下,内存还是没法回收。

分析下上面这段测试程序,我们只是在一个单独的方法内挂钩了一个事件,并且事件还没有执行,紧接着开始垃圾回收,但结果显示没有成功。这个符合我们教科书上说的情况:对象的事件挂钩之后,如果不解除挂钩,可能造成内存泄漏。

同时,上面的结果也说明了被挂钩的对象 b 有没有被回收,那么发起事件来测试下,看b对象是否还能够继续处理别人发起的事件,继续上面主程序代码:

 Console.WriteLine("2,按下任意键,主对象发起事件");      Console.ReadKey();      a.RaiseEvent();//此处内存不能正常回收      getWorkingSet();

结果:

2,按下任意键,主对象发起事件A:sender:ConsoleApplication1.A---------当前进程名称:ConsoleApplication1-----------7056工作集(进程类)121,576.00 KB工作集 121,576.00 KB私有工作集 109,672.00 KB--------------------------------------------------------

 这说明,虽然对象 b 脱离了方法 TestInitEvent 的范围,但它依然存活,打印了一句话:A:sender:ConsoleApplication1.A

是不是GC多回收几次才能够成功呢?

我们继续在主程序上调用GC试试看:

 Console.WriteLine("3,按下任意键开始垃圾回收,之后再次发起事件");      Console.ReadKey();      GC.Collect();      a.RaiseEvent();//此处内存不能正常回收      getWorkingSet();

结果:

3,按下任意键开始垃圾回收,之后再次发起事件A:sender:ConsoleApplication1.A---------当前进程名称:ConsoleApplication1-----------7056工作集(进程类)14,424.00 KB工作集 14,424.00 KB私有工作集 2,972.00 KB--------------------------------------------------------

果然,内存被回收了,但请注意,我们在GC执行成功后,仍然调用了发起事件的方法  a.RaiseEvent();并且得到了成功执行,这说明,对象b 仍然存活,不过它内部大量无用的内存被回收了。

注意:上面这段代码的结果是我再写博客过程中,一边写一遍测试偶然发现的情况,如果是连续执行的,情况并不是这样,上面这端代码不能回收成功内存。
这说明,GC内存回收的时机,的确是不确定的。

继续,我们注销事件,解除事件挂钩,再看结果:

 Console.WriteLine("4,按下任意键开始注销事件,之后再次垃圾回收");      Console.ReadKey();      a.DelEvent();      GC.Collect();      Console.WriteLine("5,垃圾回收完成");      getWorkingSet();

结果:

4,按下任意键开始注销事件,之后再次垃圾回收5,垃圾回收完成---------当前进程名称:ConsoleApplication1-----------7056工作集(进程类)15,252.00 KB工作集 15,252.00 KB私有工作集 3,196.00 KB--------------------------------------------------------

内存没有明显变化,说明之前的内存的确成功回收了。

 

为了印证前面的猜测,我们让程序重新运行并且连续执行(Release模式),来看看执行结果:

---------当前进程名称:ConsoleApplication1-----------4280工作集(进程类)10,364.00 KB工作集 10,364.00 KB私有工作集 7,040.00 KB--------------------------------------------------------1,按下任意键开始垃圾回收---------当前进程名称:ConsoleApplication1-----------4280工作集(进程类)121,456.00 KB工作集 121,456.00 KB私有工作集 109,668.00 KB--------------------------------------------------------2,按下任意键,主对象发起事件A:sender:ConsoleApplication1.A---------当前进程名称:ConsoleApplication1-----------4280工作集(进程类)121,572.00 KB工作集 121,572.00 KB私有工作集 109,672.00 KB--------------------------------------------------------3,按下任意键开始垃圾回收,之后再次发起事件A:sender:ConsoleApplication1.A---------当前进程名称:ConsoleApplication1-----------4280工作集(进程类)121,628.00 KB工作集 121,628.00 KB私有工作集 109,672.00 KB--------------------------------------------------------4,按下任意键开始注销事件,之后再次垃圾回收5,垃圾回收完成---------当前进程名称:ConsoleApplication1-----------4280工作集(进程类)19,228.00 KB工作集 19,228.00 KB私有工作集 7,272.00 KB--------------------------------------------------------

View Code

这次的确印证了前面的说明,GC真正回收内存的时机是不确定的。

 

编译器的优化

精简下之前的测试代码,仅初始化事件对象然后就GC回收,看看结果:

getWorkingSet();      A a = new A();      TestInitEvent(a); getWorkingSet();      Console.WriteLine("4,按下任意键开始注销事件,之后再次垃圾回收");      Console.ReadKey();      a.DelEvent();      GC.Collect();      Console.WriteLine("5,垃圾回收完成");      getWorkingSet();      Console.ReadKey();

 结果:

---------当前进程名称:ConsoleApplication1-----------6576工作集(进程类)10,344.00 KB工作集 10,344.00 KB私有工作集 7,240.00 KB-----------------------------------------------------------------当前进程名称:ConsoleApplication1-----------6576工作集(进程类)121,500.00 KB工作集 121,500.00 KB私有工作集 110,292.00 KB--------------------------------------------------------4,按下任意键开始注销事件,之后再次垃圾回收5,垃圾回收完成---------当前进程名称:ConsoleApplication1-----------6576工作集(进程类)19,788.00 KB工作集 19,788.00 KB私有工作集 7,900.00 KB--------------------------------------------------------

符合预期,GC之后内存恢复到正常水平。

将上面的代码稍加修改,仅仅注释掉GC前面的一句代码:a.DelEvent();

getWorkingSet();      A a = new A();      TestInitEvent(a); getWorkingSet();      Console.WriteLine("4,按下任意键开始注销事件,之后再次垃圾回收");      Console.ReadKey();      //a.DelEvent();      GC.Collect();      Console.WriteLine("5,垃圾回收完成");      getWorkingSet();      Console.ReadKey();

再看结果:

---------当前进程名称:ConsoleApplication1-----------4424工作集(进程类)10,308.00 KB工作集 10,308.00 KB私有工作集 7,040.00 KB-----------------------------------------------------------------当前进程名称:ConsoleApplication1-----------4424工作集(进程类)121,256.00 KB工作集 121,256.00 KB私有工作集 7,592.00 KB--------------------------------------------------------4,按下任意键开始注销事件,之后再次垃圾回收5,垃圾回收完成---------当前进程名称:ConsoleApplication1-----------4424工作集(进程类)19,436.00 KB工作集 19,436.00 KB私有工作集 7,600.00 KB--------------------------------------------------------

大跌眼镜:居然没有发生大量内存占用的情况!

看来只有一个可能性:

对象a 在GC回收内存之前,没有操作事件之类的代码,因此可以非常明确对象a 之前的事件代码不再有效,相关的对象b可以在  TestInitEvent(a); 方法调用之后立刻回收,这样就看到了现在的测试结果。

如果不是 Release 编译模式优化,我们来看看在IDE调试或者Debug编译模式运行的结果(前面的代码不做任何修改):

---------当前进程名称:ConsoleApplication1.vshost-----------8260工作集(进程类)25,148.00 KB工作集 25,148.00 KB私有工作集 9,816.00 KB-----------------------------------------------------------------当前进程名称:ConsoleApplication1.vshost-----------8260工作集(进程类)136,048.00 KB工作集 136,048.00 KB私有工作集 112,888.00 KB--------------------------------------------------------4,按下任意键开始注销事件,之后再次垃圾回收5,垃圾回收完成---------当前进程名称:ConsoleApplication1.vshost-----------8260工作集(进程类)136,692.00 KB工作集 136,692.00 KB私有工作集 112,892.00 KB--------------------------------------------------------


这一次,尽管仍然调用了GC垃圾回收,但实际上根本没有立刻起到效果,内存仍然100多M。

 

最后,我们在发起事件挂钩之后,立即解除事件挂钩,再看下Debug模式下的结果,为此仅仅需要修改下面代码一个地方:

   static void TestInitEvent(A a)    {      var b = new B(100 * 1024 * 1024);      a.ToDoSomething += b.PrintA;      //      a.ToDoSomething -= b.PrintA;    }

然后看在Debug模式下的执行结果:

---------当前进程名称:ConsoleApplication1.vshost-----------8652工作集(进程类)26,344.00 KB工作集 26,344.00 KB私有工作集 9,452.00 KB-----------------------------------------------------------------当前进程名称:ConsoleApplication1.vshost-----------8652工作集(进程类)135,628.00 KB工作集 135,628.00 KB私有工作集 10,008.00 KB--------------------------------------------------------4,按下任意键开始注销事件,之后再次垃圾回收5,垃圾回收完成---------当前进程名称:ConsoleApplication1.vshost-----------8652工作集(进程类)33,768.00 KB工作集 33,768.00 KB私有工作集 10,008.00 KB--------------------------------------------------------

符合预期,内存占用量没有增加,所以此时调用GC回收内存都没有意义了。

 

总结

使用事件的时候如果不在使用完之后解除事件挂钩,的确可能发生内存泄漏,

并且GC内存回收的时机的确具有不确定性,所以GC不是救命稻草,最佳的做法还是用完事件立即解除事件挂钩。

如果你忘记了这个事情,也请一定不要忘记发布程序的时候,使用Release编译模式!