前言 经常在代码中看到有人将 null
赋值给引用类型,来达到让 GC 提前回收的目的,这样做真的有用吗?今天我们就来研究一下。 为了方便讲解,来一段测试代码,提前将 test1=null
,然后调用 GC.Collect()
看看是否能提前回收。
平台:.net5
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 public class Program { static void Main (string [] args ) { ProcessRequest(); } static void ProcessRequest () { var test1 = new Test() { a = 10 }; Console.WriteLine($"query.a={test1.a} " ); var test2 = new Test() { a = 11 }; Console.WriteLine($"query.a={test2.a} " ); test1 = null ; var test3 = new Test() { a = 12 }; Console.WriteLine($"query.a={test3.a} " ); GC.Collect(); Console.WriteLine("垃圾回收啦!" ); Console.ReadLine(); } } public class Test { public int a; }
接下来我们从 Debug
和 Release
两种模式下观察。
一:Debug 模式 要找到这个答案,我们用 windbg 附加一下,找到 test1
然后用 !gcroot
查看下引用即可。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 0:000> !clrstack -a OS Thread Id: 0x4dd0 (0) Child SP IP Call Site 0057F2A4 79863539 System.Console.ReadLine() [/_/src/System.Console/src/System/Console.cs @ 463] 0057F2AC 04c405d1 ConsoleApp1.Program.ProcessRequest() [D:\net5\ConsoleApp2\ConsoleApp1\Program.cs @ 37] LOCALS: 0x0057F2D4 = 0x00000000 0x0057F2D0 = 0x0283cd54 0x0057F2CC = 0x0283cd90 0:000> !dumpheap -type Test Address MT Size 0283a7c0 04c39008 12 0283cd54 04c39008 12 0283cd90 04c39008 12 0:000> !gcroot 0283a7c0 Thread 4dd0: 0057F2AC 04C405D1 ConsoleApp1.Program.ProcessRequest() [D:\net5\ConsoleApp2\ConsoleApp1\Program.cs @ 37] ebp+14: 0057f2c8 -> 0283A7C0 ConsoleApp1.Test Found 1 unique roots (run '!gcroot -all' to see all roots).
是不是很惊讶,test1 虽被赋 null,但并没有被 GC.Collection
所回收,原因在于 test1 被栈中的 ebp+14
位置所持有?那这个位置是咋回事?我们反编译下代码看看,简化后如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 0:000> !U 04C405D1 Normal JIT generated code ConsoleApp1.Program.ProcessRequest() ilAddr is 0268205C pImport is 052FB030 Begin 04C40488, size 154 D:\net5\ConsoleApp2\ConsoleApp1\Program.cs @ 22: 04c404aa b90890c304 mov ecx,4C39008h (MT: ConsoleApp1.Test) 04c404af e8182c9afb call 005e30cc (JitHelp: CORINFO_HELP_NEWSFAST) 04c404b4 8945ec mov dword ptr [ebp-14h],eax 04c404b7 8b4dec mov ecx,dword ptr [ebp-14h] 04c404ba ff152890c304 call dword ptr ds:[4C39028h] (ConsoleApp1.Test..ctor(), mdToken: 06000004) 04c404c0 8b4dec mov ecx,dword ptr [ebp-14h] 04c404c3 c741040a000000 mov dword ptr [ecx+4],0Ah 04c404ca 8b4dec mov ecx,dword ptr [ebp-14h] 04c404cd 894df8 mov dword ptr [ebp-8],ecx D:\net5\ConsoleApp2\ConsoleApp1\Program.cs @ 29: 04c4055c 33c9 xor ecx,ecx 04c4055e 894df8 mov dword ptr [ebp-8],ecx
虽然 !gcroot
上显示的是 ebp+14
,反向就是 ebp-14
,仔细看上面的汇编代码,可以发现 test1 实例被放在了 ebp-14
和 ebp-8
两个栈位置,而 test1=null
只是抹去了 ebp-8
的栈单元,所以它能被回收的时机只能是等 ProcessRequest()
方法销毁之后,这也就是 Debug 模式下的 方法作用域 ,应该是为了 Debug 调试用的,从 gcinfo
上也可以看出来,ebp-14
是禁止被GC跟踪的内部用途的栈单元。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 0:000> !U -gcinfo 04C405D1 Normal JIT generated code ConsoleApp1.Program.ProcessRequest() ilAddr is 0268205C pImport is 052FCA58 Begin 04C40488, size 154 D:\net5\ConsoleApp2\ConsoleApp1\Program.cs @ 21: [EBP-08H] an untracked local [EBP-0CH] an untracked local [EBP-10H] an untracked local [EBP-14H] an untracked local [EBP-18H] an untracked local [EBP-1CH] an untracked local [EBP-20H] an untracked local [EBP-24H] an untracked local [EBP-28H] an untracked local [EBP-2CH] an untracked local [EBP-30H] an untracked local
二:Release 模式 大家或许都知道 Release
是一种高度优化的激进模式,我也很好奇在这种模式下 compile
或者 JIT
会做出怎么样的优化。
1. 编译器层面的优化 要寻找这个答案,我们用 ILSpy 打开生成的 IL代码,简化后如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 .method private hidebysig static void ProcessRequest () cil managed { // Method begins at RVA 0x2058 // Code size 144 (0x90) .maxstack 3 .locals init ( [0] class ConsoleApp1.Test test1, [1] class ConsoleApp1.Test test2, [2] class ConsoleApp1.Test test3 ) IL_0050: ldnull IL_0051: stloc.0 } // end of method Program::ProcessRequest
从 idnull
上来看,没有做任何优化,居然直接翻译了,哎。。。
2. JIT优化 查看 JIT 层面的优化,只能看最终的 汇编代码
和 托管堆
啦。
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 0:000> !dumpheap -type Test Address MT Size 02eaab38 02634b10 12 02ead344 02634b10 12 02ead380 02634b10 12 Statistics: MT Count TotalSize Class Name 02634b10 3 36 ConsoleApp1.Test Total 3 objects 0:000> !U /d 0262549d Normal JIT generated code ConsoleApp1.Program.ProcessRequest() ilAddr is 025B2058 pImport is 04AFB108 Begin 02625370, size 131 D:\net5\ConsoleApp2\ConsoleApp1\Program.cs @ 22: 02625370 55 push ebp 02625371 8bec mov ebp,esp 0262538a b9104b6302 mov ecx,2634B10h (MT: ConsoleApp1.Test) 0262538f e83cddfefd call 006130d0 (JitHelp: CORINFO_HELP_NEWSFAST) 02625394 8945f0 mov dword ptr [ebp-10h],eax 02625397 8b4df0 mov ecx,dword ptr [ebp-10h] 0262539a e871f9ffff call 02624d10 0262539f 8b4df0 mov ecx,dword ptr [ebp-10h] 026253a2 c741040a000000 mov dword ptr [ecx+4],0Ah 026253a9 8b4df0 mov ecx,dword ptr [ebp-10h] 026253ac 894dfc mov dword ptr [ebp-4],ecx D:\net5\ConsoleApp2\ConsoleApp1\Program.cs @ 29: 02625430 33c9 xor ecx,ecx 02625432 894dfc mov dword ptr [ebp-4],ecx
从汇编代码看,Release
模式下也是采用双栈保存的,也就是 方法级作用域
。
三:可以得出结论了吗? 至少在 .NET5
平台,Release
和 Debug
模式下的 test1 = null
; 是没有任何区别的,其实这里有个问题 , .NET5
下没区别,不代表其他平台下也没有问题,毕竟不同的 JIT
会作用不同的抉择,接下来我们将同样的代码搬到 .NET Framework 4.5
下看看情况。
NET Framework 4.5 平台 Debug 模式 我们直接看托管代码
1 2 3 4 5 6 7 8 9 0:006> !dumpheap -type Test Address MT Size 02564bfc 00754ddc 12 02564c70 00754ddc 12 Statistics: MT Count TotalSize Class Name 00754ddc 2 24 ConsoleApp2.Test Total 2 objects
居然是 2 个了,那为什么会这样呢?我们还是看下汇编。
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 0:000> !U /d 023509a6 Normal JIT generated code ConsoleApp2.Program.ProcessRequest() Begin 02350880, size 187 D:\net5\ConsoleApp2\ConsoleApp2\Program.cs @ 21: 023508b1 b9dc4da200 mov ecx,0A24DDCh (MT: ConsoleApp2.Test) 023508b6 e839286cfe call 00a130f4 (JitHelp: CORINFO_HELP_NEWSFAST) 023508bb 8945ec mov dword ptr [ebp-14h],eax 023508be 8b4dec mov ecx,dword ptr [ebp-14h] 023508c1 ff15fc4da200 call dword ptr ds:[0A24DFCh] (ConsoleApp2.Test..ctor(), mdToken: 06000004) 023508c7 8b45ec mov eax,dword ptr [ebp-14h] 023508ca c740040a000000 mov dword ptr [eax+4],0Ah 023508d1 8b45ec mov eax,dword ptr [ebp-14h] 023508d4 8945f8 mov dword ptr [ebp-8],eax D:\net5\ConsoleApp2\ConsoleApp2\Program.cs @ 28: 0235097b 33d2 xor edx,edx 0235097d 8955f8 mov dword ptr [ebp-8],edx 0:000> dp ebp-14h L1 0019f4e8 02472358 0:000> !do 02472358 Name: ConsoleApp2.Test MethodTable: 00a24ddc EEClass: 00a21330 Size: 12(0xc) bytes File: D:\net5\ConsoleApp2\ConsoleApp2\bin\Debug\ConsoleApp2.exe Fields: MT Field Offset Type VT Attr Value Name 637342a8 4000001 4 System.Int32 1 instance 10 a 0:000> dp 0019f4e8 L1 0019f4e8 02472358 0:000> !do 02472358 Free Object Size: 24(0x18) bytes
大家可以仔细看看输出内容,虽然也是两个 栈位置
存放着 test1
,但GC做了不同的处理,它无视 ebp-14
还牵引着 test1
的事实 ,直接将它标记为 free
,这就有点意思了。
Release 模式 我们直接用 !dumpheap -type Test
看托管堆。
1 2 3 4 5 6 0:006> !dumpheap -type Test Address MT Size Statistics: MT Count TotalSize Class Name Total 0 objects
居然发现,不仅 test1
没有了,test2
,test3
都没有了。。。这就是所谓的 激进式回收
。
四:结论 1. .NET5 平台下 Release 和 Debug 模式下设置 test1=null 没有任何效果。
2. .NET Framework 4.5 平台下 Debug 模式下有效果,可以起到 提前回收 的目的。 Release模式下无效果,GC会自动激进的回收所有后续未使用到的引用对象。
3. 个人结论 总的来说,为了更好的平台兼容性,如果想提前回收,设置 test1 = null; 是有一定效果的。