NET 記憶體與資源管理
如果你寫過一段時間的 .NET,遲早會遇到這種情況:畫面明明關掉了、物件理論上也不會再用了,但記憶體卻一直往上漲,最後不是效能變差,就是直接 OutOfMemory。
多數人在這個階段會以為是「GC 沒有正常工作」,但實際上,問題幾乎都不是 GC,而是我們自己還在無意中握著物件的參考。
這篇文章不會從 GC 演算法開始講,而是用你在專案中一定遇過的場景,帶你理解 .NET 記憶體與資源管理真正的運作方式。
為什麼「我沒用了」,GC 卻不回收?
在 .NET 裡,GC 判斷一個物件能不能被回收,其實只看一件事:還有沒有任何地方可以一路找到它。只要有參考存在,哪怕你主觀上覺得「這東西早就沒用了」,GC 都不會動它。
這也是為什麼記憶體問題通常不是來自複雜的演算法,而是來自一些看起來很正常、甚至每天都在寫的語法。
最常見的三個來源,就是 event、static,以及 lambda。
Event:畫面關了,物件卻還被留在清單裡
很多人第一次寫 event,都把它當成「通知機制」。某個物件發生事情,就通知所有關心的人,這個理解沒有錯,但問題在於:通知名單本身是有記憶的。
想像你在寫一個畫面,裡面有個 Button:
class Button
{
public event Action Clicked;
}
class Page
{
public Page(Button button)
{
button.Clicked += OnButtonClicked; // 註冊
}
void OnButtonClicked() { }
}在你心中的流程可能是:使用者離開畫面,Page 就沒人用了,接下來交給 GC 處理。但實際上,Button 裡的 Clicked event 還記得 OnButtonClicked,而 OnButtonClicked 又是 Page 的方法。
關係圖:Button -> event -> Page
也就是說,只要 Button 還活著,Page 就一定活著。這跟畫面有沒有顯示、你還會不會再用到 Page,一點關係都沒有。
這就是為什麼在實務上,event 幾乎一定要有對應的 unsubscribe。不是因為語法要求,而是因為不退訂,就等於主動告訴系統「請幫我留著這個物件」。
static:你以為是暫存,其實是終身合約
static 之所以危險,並不是因為它不好,而是因為它的生命週期太長了。只要應用程式還在跑,static 就一定存在。
例如下面這種寫法,在專案裡非常常見:
class UserCache
{
public static List<User> Users = new();
}很多人會直覺地把它當成 cache,但從 GC 的角度來看,這其實是在宣告:所有 User 都要活到程式結束為止。因為 static 本身就是 GC Root,只要它還在,底下的物件永遠不會被回收。
關係圖:static Users -> User
這也是為什麼你會看到一些服務跑久了之後,記憶體只升不降。不是 GC 壞掉,而是你用 static 告訴 GC「這些我都還要」。
如果真的需要快取,就必須明確處理生命週期,而不是單純用 static 逃避管理成本。
lambda:一行看似無害的程式,其實會拖住整個物件
lambda 很容易讓人誤會它只是「一小段程式碼」,但在 .NET 裡,lambda 實際上是一個物件,而且它可能會把外部的整個物件一起抓進來。
例如這段程式:
Task.Run(() =>
{
Console.WriteLine(this.Name);
});如果這段程式寫在 Page 或 ViewModel 裡,那麼這個 lambda 其實會隱含地參考 this。只要 Task 還在跑,整個 Page 就不能被回收。
這在背景工作、長時間 Task、或被集中管理的 Task 清單中特別容易出問題。你可能只是想非同步載資料,但實際效果卻是:畫面關了,背後的物件卻全部被拖住。
Finalize:為什麼加了反而更容易出事
Finalize() 是為了解決什麼問題?
在 .NET 很早期的設計中:
- 有 unmanaged resource(檔案、socket、native handle)
- 如果開發者忘了清理,系統至少要有「最後補救機制」 這就是 Finalize 的由來。
很多人第一次看到 Finalize,會以為它是「保證清理資源」的機制。實際上,它只是最後的補救措施,而且代價很高。
當一個物件有 Finalize 時,GC 在第一次發現它不可達時,並不會立刻回收,而是先把它丟進 Finalization Queue,等待專門的 Finalizer Thread 處理。等 Finalize 跑完之後,還要再等下一次 GC,物件才會真的消失。
為什麼 Finalize 不能立刻執行?
因為 Finalize:
- 會執行任意程式碼
- 可能呼叫外部資源
- 不能在 GC 執行緒中亂跑
所以 CLR 的設計是:
- 先標記物件需要 Finalize
- 把它放進 Finalization Queue
- 由專門的 Finalizer Thread 執行
- 下一次 GC 才能真的回收
這代表什麼?代表只要你加了 Finalize,這個物件一定會比其他物件多活至少一輪 GC。如果你大量建立這類物件,清理速度就很容易追不上建立速度,最後不是記憶體暴增,就是資源耗盡。
因此,在應用程式層級,把 Finalize 當成主要清理方式,幾乎一定是錯的。
錯誤示範:
class NativeFile
{
~NativeFile()
{
CloseHandle();
}
}Dispose:真正可控、可靠的資源管理方式
Dispose 的價值,在於它是你主動做的選擇。你明確告訴系統:這個物件現在用完了,請立刻釋放它持有的資源。
GC 不會幫你呼叫 Dispose,因為 GC 只負責記憶體,它不知道什麼時候該關檔案、關連線、或釋放 handle。Dispose其實就是個一般的method,在GC眼裡Dispose就是個一般的方法。
那為什麼要 implement IDisposable?
因為 IDisposable 是一個「合約」,它不是自動清理機制。 它在告訴使用者:
- 這個物件用完一定要清
- 可以用 using / await using
class FileService : IDisposable
{
private FileStream _stream;
public FileService(string path)
{
_stream = new FileStream(path, FileMode.Open);
}
public void Dispose()
{
_stream.Dispose();
}
}使用端:
using var service = new FileService("data.txt");當你看到 using 時,你可以很有信心地知道:這段程式結束時,資源一定會被釋放。不依賴 GC,不需要 Finalize。
什麼時候才真的需要 Finalize?
幾乎只有在寫 framework 或底層 library,包 unmanaged resource,而且不能完全信任使用者時,才需要 Finalize 作為保險。
對大多數應用程式開發者來說,理解它的成本與副作用,比實際使用它來得重要。
結語:心智模型
GC 並不是萬能的垃圾清潔工,它只會回收沒人再碰得到的記憶體。資源要不要釋放、什麼時候釋放,永遠是開發者的責任。
只要你能意識到 reference 的存在,理解 event、static、lambda 的生命週期,再搭配正確使用 Dispose,大部分的記憶體問題,其實都可以在還沒發生之前就避免掉。
最後總結:
- Dispose:你負責的清理
- IDisposable:提醒與規範
- Finalize:最後補救,不是策略
References
這篇文章是我在讀這本書時遇到的問題,用AI協助總結成重點。