許多開發者以為 GC 包辦一切,洩漏不可能發生。事實上:
開發者仍需持續監控記憶體使用,確保應用高效穩定。
| 類型 | 用途 | 管理者 |
|---|---|---|
| 堆疊 | 區域變數、參數、返回值、臨時值 | 自動 (方法返回釋放),GC 掃描引用 |
| 非托管堆 | CLR 執行階段結構、方法表、JIT 代碼、P/Invoke/COM 配置 | 開發者/CLR 手動管理 |
| 托管堆 | 托管物件分配區 | 分代壓縮 GC (Gen 0/1/2 + LOH) |
Process/Private Bytes:進程專用總記憶體。.NET CLR Memory/# Bytes in All Heaps:托管堆總大小 (Gen 0/1/2 + LOH)。.NET CLR LocksAndThreads/# of current logical Threads:邏輯執行緒數。判斷邏輯:
預設執行緒堆疊 1 MB。若建立背景執行緒卻未正常結束 (如 Thread.Join() 等待自己),每建立一個洩漏 1 MB。
// 錯誤範例:執行緒自我 Join 導致永久阻塞
Thread t = new Thread(() => Thread.CurrentThread.Join());
t.Start(); // 堆疊永不釋放
GC 僅回收 Thread 物件,不回收 OS 執行緒堆疊。必須讓執行緒函數正常返回或呼叫 Abort() (不推薦)。
GC 看不見非托管記憶體。若托管代碼呼叫非托管 API 配置記憶體卻未釋放 → 洩漏。需逐步檢查呼叫前後記憶體變化,用傳統偵錯工具定位非托管端洩漏。
CLR 僅有一個終結器執行緒。若某終結器卡住 (如嘗試連離線資料庫不返回),後續終結器永不執行 → 非托管資源洩漏。AppDomain 卸載時 CLR 會嘗試執行所有終結器,但有超時機制,超時則強制終止進程。ASP.NET/SQL Server 宿主環境下,AppDomain 回收不代表進程結束,洩漏記憶體持續累積。
.NET 2.0 引入關鍵終結器 (Critical Finalizer),保證 AppDomain 關閉時獲得執行機會。
組件載入後僅能透過卸載 AppDomain 釋放。動態生成代碼 (System.CodeDom、XmlSerializer 多載建構函式) 若反覆生成新組件,舊組件常駐記憶體 → 非托管堆 (JIT 代碼、方法表) 與托管堆 (靜態欄位) 雙重洩漏。
解決方案:
DynamicMethod (輕量級代碼生成):MSIL 直接分配於托管堆,無引用即可被 GC 回收。XmlSerializer 洩漏:使用帶 XmlRootAttribute 建構函式會每次生成新組件 → 改用類別屬性 [XmlRoot("Name")] 或自行快取 XmlSerializer 實例。>=85,000 bytes 物件進入 LOH,不壓縮。頻繁分配/釋放大陣列、大字串 → 碎片導致記憶體使用率虛高。對策:重用緩衝區、用 StringBuilder 減少臨時大字串、CLRProfiler 視覺化分析。

長時間持有大型物件圖參考 (如遠端呼叫前建立大圖,呼叫期間發生 GC → 物件倖存升代)。對策:縮短根引用生命週期,遠端呼叫前釋放參考。
物件本應短命卻倖存升代 (Gen 1/2),導致低成本 Gen 0 GC 轉為高成本全堆掃描。常見原因:
~Foo() {} 使物件進入終結隊列,強制升代。原則:無非托管資源不寫終結器;有非托管資源用 SafeHandle 包裝 + IDisposable 模式。Thread.Sleep 等期間發生 GC → 物件升代。Cache.Add(..., callback) 中使用實例方法回呼 → Cache (根) → 委派 → Page (this) → 頁面所有臨時物件至少存活至快取過期 (如 5 分鐘) → 大量升代。修正:回呼改為 static 方法切斷 this 參考。// 問題代碼
Cache.Add(key, obj, ..., new CacheItemRemovedCallback(OnCacheItemRemoved)); // OnCacheItemRemoved 為實例方法
// 修正
Cache.Add(key, obj, ..., new CacheItemRemovedCallback(StaticOnCacheItemRemoved)); // 靜態方法
.NET 減輕記憶體管理負擔,但不代表可忽視。持續監控、理解 GC 機制、避免非預期根引用、正確處理非托管資源、善用工具分析,才能構建高效穩定的應用。良好的軟體工程實踐永遠不可或缺。