過早的給方法中 引用物件 設為 null 可被 GC提前回收嗎?

一線碼農發表於2022-05-11

經常在程式碼中看到有人將 null 賦值給引用型別,來達到讓 GC 提前回收的目的,這樣做真的有用嗎?今天我們就來研究一下。

為了方便講解,來一段測試程式碼,提前將 test1=null ,然後呼叫 GC.Collect() 看看是否能提前回收。

平臺採用: .net5


    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;
    }

接下來我們從 DebugRelease 兩種模式下觀察。

一:Debug 模式

要找到這個答案,我們用 windbg 附加一下,找到 test1 然後用 !gcroot 檢視下引用即可。


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 位置所持有?那這個位置是咋回事? 我們反編譯下程式碼看看,簡化後如下:


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-14ebp-8 兩個棧位置,而 test1=null 只是抹去了 ebp-8 的棧單元,所以它能被回收的時機只能是等 ProcessRequest() 方法銷燬之後,這也就是 Debug 模式下的 方法作用域,應該是為了 Debug 除錯用的,從 gcinfo 上也可以看出來,ebp-14 是禁止被GC跟蹤的內部用途的棧單元。


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程式碼,簡化後如下:


	.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 層面的優化,只能看最終的彙編程式碼託管堆 啦。


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 平臺, ReleaseDebug 模式下的 test1 = null; 是沒有任何區別的,其實這裡有個問題 , .NET5 下沒區別,不代表其他平臺下也沒有問題,畢竟不同的 JIT 會作用不同的抉擇,接下來我們將同樣的程式碼搬到 .NET Framework 4.5 下看看情況。

1. .NET Framework 4.5 平臺

  1. Debug 模式

我們直接看託管程式碼


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 個了,那為什麼會這樣呢? 我們還是看下彙編。


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,這就有點意思了。

  1. Release 模式

我們直接用 !dumpheap -type Test 看託管堆。


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; 是有一定效果的。

圖片名稱

相關文章