想必有些朋友也经常使用事件,可是不多解除事件挂钩,程序也没有据说过内存泄漏之类的问题。幸运的是,在某些状况下,的确不会出问题,不少年前作的项目就跑得好好的,包括我也是,虽然如此,但也不能一直心存侥幸,总得搞清楚这类内存泄漏的神秘事件是怎么发生的吧,咱们今天能够作一个实验来再次验证下。ide
能够,为了验证这个问题,我一度怀疑本身代码写错了,甚至照着书上(网上)例子写也没法重现事件引发内存泄漏的问题,难道教科书说错了么?测试
首先来看看个人代码,先准备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 ()); } }
而后,在主程序里面写下面的方法:this
static void TestInitEvent(A a) { var b = new B(100 * 1024 * 1024); a.ToDoSomething += b.PrintA; }
这里将初始化一个 100M的B的实例对象b,而后让对象a的事件ToDoSomething 挂钩在b的方法PrintA 上。日常状况下,b是方法内部的局部变量,在方法外就是不可访问的,但因为b对象的方法挂钩在了方法参数 a 对象的事件上,因此在这里对象 b的生命周期并无结束,这能够稍后由对象 a发起事件,b的 PrintA 方法被调用获得证明。spa
PS:有朋友问为什么不在这里写取消挂钩的代码,我这里是研究使用的,实际项目代码通常不会这么写。调试
为了监测当前测试耗费了多少内存,准备一个方法 getWorkingSet,代码以下:code
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(); }
下面,开始在主程序里面开始写以下测试代码:orm
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),再次看下结果:blog
---------当前进程名称: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对象是否还可以继续处理对象a 发起的事件,继续上面主程序代码:
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 --------------------------------------------------------
此次的确印证了前面的说明,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可以有效工做,好比像本文测试的例子这样,不然,调用GC非但没有效果,可能还会引发反作用,好比引发整个应用程序的暂停业务处理。
使用事件的时候若是不在使用完以后解除事件挂钩,有可能发生内存泄漏,
GC内存回收的时机的确具备不肯定性,因此GC不是救命稻草,最佳的作法仍是用完事件当即解除事件挂钩。
若是你忘记了这个事情,也请必定不要忘记发布程序的时候,使用Release编译模式!