是的。而要理解其中的原因,就需要摒棄軟體開發領域最根深蒂固的誤解之一。
迷思:垃圾回收可以防止記憶體洩漏。真相:垃圾回收只能防止一種特定的記憶體問題——懸空引用。在這種情況下,程式會遺失指向某塊記憶體的所有指針,並且無法釋放該記憶體。垃圾回收無法防止的是相反的問題:程式保留了對不再需要的物件的引用。垃圾回收器偵測到了活躍的引用,並正確地放任該物件運行。開發者原本打算停止使用該物件。然而,內存卻不斷成長。其後果與傳統的記憶體洩漏相同:記憶體無限成長,最終導致記憶體溢位錯誤(OOM)和服務重啟,但其機制卻截然不同。
這種區別至關重要,因為它會改變你查找問題的位置和解決問題的方式。在 C 和 C++ 中,記憶體洩漏意味著缺少一個記憶體區塊。 free() or delete在 Java、Python、C#、Go 或 JavaScript 中,這意味著一個本應被清除但卻未被清除的參考。垃圾回收器並沒有故障,它正確地處理了程式不應該保留的參考。
垃圾收集員實際做什麼
垃圾回收器的任務是識別並回收那些無法從程式根目錄、全域變數、堆疊變數和執行緒局部狀態存取的記憶體。任何可以從根目錄存取的物件都被認為是存活的。任何無法從根目錄存取的物件都符合被回收的條件。
三種主流的垃圾回收策略對可及性的定義各不相同:
標記清除 (Java 的 HotSpot、Go、C#/.NET 等都使用這種方法)從根集合開始遍歷所有可達的物件參考。遍歷過程中未到達的物件會被清除。這種方法可以正確處理循環引用,即兩個相互指向但無法從其他任何地方存取的物件都會被收集。但它無法處理的情況是:根可達的容器(靜態容器)。 HashMap一個班級 List) 無限增長,因為條目不斷添加,卻從不刪除。
參考計數 (CPython 的主要機制)會追蹤每個物件的引用次數。當計數達到零時,物件會立即被釋放。經典的故障模式是循環引用,其中 A 引用 B,而 B 又引用 A。兩者的引用計數都至少為 1,因此永遠不會被釋放。 CPython 使用額外的循環回收器來解決這個問題,但該循環回收器在處理包含循環引用的物件時存在一些特殊情況。 __del__ 方法。
分代垃圾回收(大多數現代運行時都結合上述機制使用)根據對象的存活時間長短將其分為新生代和老年代。生命週期短的物件會被頻繁且低成本地回收,而生命週期長的物件則很少被回收。這會導致記憶體洩漏:本應成為短生命週期物件卻意外保留的物件會進入老年代,並且回收頻率越來越低,從而使洩漏更難被發現和診斷。
這些機制都不會檢查程式是否應該繼續使用某個對象,而只會檢查程式是否可以存取該對象。
導致垃圾回收語言記憶體洩漏的五種機制
1. 靜態集合與類別級集合
Java 中的靜態欄位、Python 中的類別變數、C# 中的靜態屬性,它們的生命週期與應用程式的生命週期相同。除非明確清除,否則儲存在靜態欄位中的任何集合都會在程式的整個生命週期內累積物件。
Java的
// Java: static cache that grows without bound
public class SessionManager {
// Lives for the lifetime of the JVM process
private static final Map<String, UserSession> sessions = new HashMap<>();
public void createSession(String userId) {
sessions.put(userId, new UserSession(userId));
// Session is added but never removed when it expires
}
// Missing: cleanup when session expires or user logs out
}
这 sessions 可以透過類別載入器存取 map 對象,而類別載入器又可以透過根目錄存取。 UserSession 創建的進程會不斷累積。在長時間運行的服務中,這會在數小時或數天內耗盡堆記憶體。解決方法是主動刪除(呼叫)。 sessions.remove(userId) 註銷或過期時)或切換到具有自動驅逐功能的結構,例如有時限的快取。
2. 事件監聽器和回調
註冊觀察者、監聽器或回呼函數會在事件來源和監聽器之間建立參考。如果監聽器從未被註銷,事件來源會一直保持監聽器處於活動狀態,直到事件來源消失,而這可能比預期的時間要長得多。
蟒蛇
# Python: listener retained by long-lived event source
class DataSource:
def __init__(self):
self._listeners = []
def add_listener(self, listener):
self._listeners.append(listener)
# Missing: remove_listener method
class DataProcessor:
def __init__(self, source: DataSource):
self.source = source
source.add_listener(self._on_data) # strong reference held in source
def _on_data(self, data):
pass
# Each DataProcessor created is never collected because
# DataSource._listeners holds a reference to its bound method
source = DataSource()
for _ in range(10000):
processor = DataProcessor(source)
# processor goes out of scope here
# but source._listeners still holds 10,000 bound method references
3. 執行緒池中的執行緒局部變數
線程局部儲存的作用域限定於線程,而非任何特定任務。在使用執行緒池的應用程式中,執行緒會在多個請求中重複使用,因此在一個請求期間設定的執行緒局部變數對於同一執行緒處理的每個後續請求都保持不變。
Java的
// Java: ThreadLocal leak in a thread pool
public class RequestContextHolder {
private static final ThreadLocal<RequestContext> context = new ThreadLocal<>();
public static void setContext(RequestContext ctx) {
context.set(ctx);
}
public static RequestContext getContext() {
return context.get();
}
// Missing: clearContext() method called after each request
}
// In a servlet or request handler:
public void handleRequest(HttpRequest req) {
RequestContextHolder.setContext(new RequestContext(req));
// ... process request
// Missing: RequestContextHolder.clearContext();
// The RequestContext stays associated with this thread indefinitely
}
在一個包含 50 個執行緒、處理數百萬個請求的執行緒池中,每個執行緒最終都會持有一個 RequestContext 引用上次請求的資料、連線詳情、使用者憑證和請求參數。這些資訊永遠不會被收集,因為線程本身永遠不會被收集。
4. 捕捉大型物件圖的閉包
閉包會捕捉其封閉作用域內變數的引用。尤其是在 JavaScript 和 Python 中,一個小型回呼函數或處理程序可能會無意中捕獲大型物件、整個 DOM 樹、請求物件或大型資料集,導致這些物件在應該被回收很久之後仍然無法被回收。
JavaScript的
// JavaScript: closure capturing large object in event listener
function setupHandler() {
const largeDataset = fetchLargeDataset(); // 50MB of data
document.getElementById('btn').addEventListener('click', function() {
// Only uses one field, but captures entire largeDataset in closure
console.log(largeDataset.summary);
});
// largeDataset cannot be collected as long as the button exists
// because the event listener closure holds a reference to it
}
// Fix: capture only what you need
function setupHandlerFixed() {
const largeDataset = fetchLargeDataset();
const summary = largeDataset.summary; // extract only needed data
document.getElementById('btn').addEventListener('click', function() {
console.log(summary); // closure captures only the string
});
// largeDataset is now eligible for collection
}
5. 無限制緩存無需驅逐
快取是生產服務中最常見的記憶體洩漏源之一。如果沒有驅逐策略,快取就會不斷增長,最終導致大量物件無法被回收,因為快取本身始終可存取。
尖銳的
// C#: unbounded dictionary used as cache
public class PriceService {
// Growing forever -- no eviction, no size limit
private static Dictionary<string, decimal> _priceCache = new();
public decimal GetPrice(string productId) {
if (!_priceCache.TryGetValue(productId, out var price)) {
price = FetchPriceFromDatabase(productId);
_priceCache[productId] = price;
}
return price;
}
// After processing 1 million unique product IDs, the cache
// holds 1 million entries and grows with every new product
}
// Fix: use a cache with eviction policy
using Microsoft.Extensions.Caching.Memory;
public class PriceService {
private readonly IMemoryCache _cache;
public PriceService(IMemoryCache cache) {
_cache = cache;
}
public decimal GetPrice(string productId) {
return _cache.GetOrCreate(productId, entry => {
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
entry.SizeLimit = 1; // bounded by cache size configuration
return FetchPriceFromDatabase(productId);
});
}
}
按語言劃分的記憶體洩漏
Java的
除了靜態集合之外,Java 中最常見的記憶體洩漏模式涉及… finalize() 方法和內部類別。非靜態內部類別隱式地持有外部類別實例的參考。在 Activity 或 Fragment(Android)中定義的匿名類別持有對該 Activity 的引用,從而防止其在螢幕旋轉或導航後被回收。
爪哇的 WeakHashMap 解決了鍵是實際物件時的靜態集合問題:當鍵從其他地方無法存取時,該條目會自動刪除。對於不再被引用時應該釋放的值, WeakReference 以及 SoftReference 包裝物件時不建立阻止收集的強引用。
檢測工具:Java VisualVM、Eclipse Memory Analyzer (MAT)、JProfiler、YourKit 和 HeapHero,用於分析堆轉儲檔案。尋找 byte[], char[]以及 Object[] 堆直方圖的成長,通常支援不斷擴展的集合中的資料結構。
蟒蛇
Python 的垃圾回收機制分為兩層:引用計數層(主要層)和循環垃圾回收器(輔助層)。引用計數層會在計數歸零時立即釋放對象,速度快且可預測。循環垃圾回收器處理循環引用,但運行頻率較低,並且會拋出異常。
蟒蛇
# Python circular reference with __del__ -- historically problematic
class Node:
def __init__(self):
self.child = None
def __del__(self):
print("Node deleted")
a = Node()
b = Node()
a.child = b
b.child = a # circular reference
del a, b # neither freed in Python 2; freed by cycle collector in Python 3.4+
Python 3.4 及更高版本可以正確處理大多數循環引用。剩餘的記憶體洩漏模式包括:儲存在模組級全域變數中的對象,以及不斷增長的記憶體佔用。 defaultdict or dict 用作註冊表的結構,以及 C 擴充模組中未暴露給 Python GC 的記憶體。
檢測: tracemalloc (標準庫), memory_profiler, objgraph。 “ objgraph.show_most_common_types() 以及 objgraph.show_growth() 函數用於識別正在累積的類型。
C# 和 .NET
C# 洩漏最常發生在下列情況:事件處理程序未取消訂閱、靜態事件來源持有對訂閱者實例的參考以及 IDisposable 物件未釋放。
尖銳的
// C#: event handler leak
public class Publisher {
public static event EventHandler DataChanged; // static event
}
public class Subscriber {
public Subscriber() {
Publisher.DataChanged += OnDataChanged; // strong reference
}
private void OnDataChanged(object sender, EventArgs e) { }
// Missing: Dispose() calling Publisher.DataChanged -= OnDataChanged
}
这 Subscriber 只要 Publisher 存在(由於是靜態的,所以會一直存在),因為靜態事件持有一個引用訂閱者實例的委託。修復方法是實現 IDisposable 並取消訂閱 Dispose(),或使用 WeakEventManager.
檢測工具:.NET Memory Profiler、dotMemory、Visual Studio Diagnostic Tools 和具有 SOS 擴充功能的 WinDbg(用於生產環境堆轉儲)。
Go
Go 語言使用標記清除式垃圾回收機制,且暫停時間非常短。儘管如此,Go 程式仍然會因為 goroutine 洩漏(即 goroutine 啟動後永遠不會終止)而出現記憶體洩漏。
go
// Go: goroutine leak -- channel never closed, goroutine blocked forever
func processItems(items []Item) {
results := make(chan Result)
go func() {
for _, item := range items {
results <- process(item)
}
// Missing: close(results)
// This goroutine blocks on the send if no one reads after items run out
}()
for result := range results {
handleResult(result)
}
// If the goroutine blocks and results is never closed,
// the loop here also blocks -- both goroutines leak
}
每個洩漏的 goroutine 都持有自己的堆疊(初始大小為 2KB,根據需要增長)以及它引用的所有堆物件。如果一個服務每次請求洩露一個 goroutine,那麼隨著時間的推移,洩漏的 goroutine 將達到數千個。
檢測: runtime.NumGoroutine() 公制, pprof goroutine 分析端點(/debug/pprof/goroutine使用 pprof 和 goleak 進行測試。 pprof 分析會顯示每個 goroutine 的堆疊跟踪,從而可以輕鬆識別哪些函數無限期阻塞。
JavaScript 和 Node.js
在 Node.js 中,最常見的洩漏模式涉及全域變數、事件發射器監聽器累積而未移除以及緩衝區/流未正確關閉。
JavaScript的
// Node.js: EventEmitter listener leak
const EventEmitter = require('events');
const emitter = new EventEmitter();
function createHandler() {
emitter.on('data', (data) => {
// This listener is never removed
// Each call to createHandler adds another listener
console.log(data);
});
}
// Called once per request in a server:
// After 11 calls, Node prints MaxListenersExceededWarning
// After thousands of calls, thousands of listeners accumulate
// Fix: use emitter.once() for one-time listeners
// or explicitly remove with emitter.off()
偵測方法:Chrome DevTools 堆快照(用於瀏覽器 JS), node --inspect 使用 Chrome DevTools(適用於 Node.js), clinic.js 用於生產環境 Node.js 效能分析,以及 heapdump 用於擷取和比較一段時間內的堆快照的 npm 套件。
GC語言中的預防模式
這些結構化模式並非在生產環境中出現漏洞後再進行調試,而是從源頭預防了最常見的漏洞原因:
弱引用 允許對物件進行引用而不阻止其被回收。當垃圾回收器需要記憶體時,即使存在對弱引用物件的引用,它們也符合被回收的條件。 WeakReference<T> 在 Java 和 C# 中, weakref.ref() 在 Python 中, WeakRef 在 JavaScript 中,對於快取和觀察者註冊,引用物件不應延長被引用物件的生命週期。
具有驅逐功能的大小受限緩存 替換原始數據 HashMap or dict 使用專門建構的快取實作:Guava 的 CacheBuilder 在 Java 中, functools.lru_cache or cachetools 在Python中, IMemoryCache C# 中存在大小限制,且 node-lru-cache 在 Node.js 中。
顯性生命週期管理 確保具有已註冊監聽器、開啟資源或執行緒本地狀態的物件實作一個 close(), dispose()或使用等效的清理方法,並且呼叫者始終會透過該方法呼叫它。 try-with-resources 在 Java 中, with Python 語句 using C# 中的聲明,以及 defer 在圍棋中。
例程取消 在 Go 中使用 context.Context 透過取消機制確保每個 goroutine 都有終止路徑。選擇“ ctx.Done() 可以發出退出訊號,防止累積。
靜態分析如何在生產前發現潛在洩漏
記憶體分析是在記憶體洩漏發生後才發現它們。靜態分析則是在程式碼運行之前就找出導致記憶體洩漏的結構模式。
SMART TS XL“ 靜態程式碼分析 檢查 Java、Python、C#、JavaScript 和其他語言的程式碼庫的結構特徵,識別與意外保留相關的模式:沒有記錄驅逐的靜態集合、沒有相應註銷的事件監聽器註冊、沒有清理的 ThreadLocal 使用以及沒有相應處置的資源分配。
應用程式依賴關係映射功能揭示了 GC 語言記憶體洩漏通常涉及的長引用鏈,這些物件可以透過多個間接層從程式根目錄訪問,其中到根目錄的路徑會經過一個旨在臨時保存物件的長期容器。
對於管理大型遺留程式碼庫的組織而言,這些程式碼庫的記憶體管理模式是多年前甚至幾十年前建立的, SMART TS XL“ 影響分析 這項功能使補救措施具有範圍性和系統性:識別特定反模式(無界靜態快取、未移除的監聽器)發生的每個位置,枚舉受影響的組件,並按風險順序規劃補救措施,而不是一次只發現一個生產事件。