1. 處理記憶體時的常見錯誤
是時候看看自動記憶體管理這門「魔法」的另一面了。即使你不寫 C(必須親自盯著每一個位元組),在 Java 中也完全可能把事情搞砸,讓應用程式瘋狂吃記憶體,就像貪吃的貓啃香腸一樣。讓我們拆解最常見的錯誤以及如何避免它們。
遺漏移除的監聽器(listeners)
在 Java 中經常使用「監聽器」模式——物件訂閱另一個物件的事件。例如,你建立了一個按鈕並為它加入點擊處理器:
button.addActionListener(new ActionListener() {
@Override
public void actionPerformed(ActionEvent e) {
// 處理點擊
}
});
問題在於:如果當按鈕或視窗不再需要時,你忘了移除這個監聽器(removeActionListener),監聽器就會一直留在記憶體中。即使你關閉了視窗並清掉了所有對它的參照,監聽器物件仍可能保留對視窗的參照(或反過來),使得垃圾回收器無法釋放記憶體。
類比:想像你已經搬家,卻忘了取消披薩店的電子報——廣告仍會寄到舊地址。
不會被清理的靜態集合
靜態欄位的生命期與類別一樣長(有時甚至到應用程式結束)。如果你有一個靜態集合:
public class Cache {
public static final List<String> globalList = new ArrayList<>();
}
而你把物件加入其中卻不移除,它們將會一直佔用記憶體。即使在其他地方已經沒有對這些物件的參照,靜態集合中的參照也會阻止 GC 回收它們。
真實案例:桌面應用中的照片快取從不清理。幾個小時後就會出現 OutOfMemoryError。
未釋放資源(檔案、串流、連線)
雖然 Java 會回收記憶體,但它不會自動關閉檔案描述元、網路連線與其他外部資源。如果忘了關閉檔案或串流,資源會一直被佔用,某個時刻系統會說:「不行了,檔案開太多!」(IOException: Too many open files)。
建議:一律使用 try-with-resources:
try (FileInputStream in = new FileInputStream("data.txt")) {
// 讀取檔案
} // in.close() 會自動呼叫!
長時間懸掛在記憶體中的大型物件
有時你建立了大型陣列或集合,使用之後卻「忘了釋放」。例如:
List<byte[]> bigList = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
bigList.add(new byte[1024 * 1024]); // 每個 1 MB
}
// ... 忘了清空 bigList
如果這個集合存在於靜態欄位,或位於長壽命的物件中,這些記憶體就會一直被佔用。
內部與匿名類別:捕捉外部參照
Java 中的匿名(與內部)類別會隱含保留對外部物件的參照:
public class Outer {
void doSomething() {
Runnable r = new Runnable() {
@Override
public void run() {
System.out.println("Hello from inner!");
}
};
// r 被保存在某處
}
}
如果物件 r 被放進靜態集合或快取,它就會「抓住」Outer 的實例參照,即便那個實例已經不需要。結果就是記憶體外洩。使用 lambda 表達式情況稍好,但若 lambda 使用了外部類別的欄位,參照仍會被保留。
2. 與垃圾回收器相關的錯誤
強制呼叫 System.gc()
許多新手認為:「記憶體快用完了——呼叫 System.gc() 就好了!」事實上這只是對 JVM 的一個請求,並不保證立即進行回收。頻繁呼叫會大幅降低效能,造成長暫停與卡頓。在實際應用中最好信任 JVM——它會自行決定何時回收。順帶一提,有些 JVM 甚至會忽略顯式的 GC 呼叫(例如使用選項 -XX:+DisableExplicitGC)。
忽視 GC 日誌
從 GC 日誌可以看出何時發生回收、耗時多久,以及釋放了多少記憶體。如果不查看這些日誌,就可能錯過問題訊號:長暫停、頻繁的 Full GC、記憶體外洩等。
如何啟用 GC 日誌:
java -Xlog:gc* -jar MyApp.jar
或對於舊版 JVM:
java -XX:+PrintGCDetails -XX:+PrintGCDateStamps -jar MyApp.jar
不當選擇 GC
回收器的選擇會影響延遲與穩定性。對低延遲需求(交易所、線上遊戲),並行的停下全世界(stop-the-world)Parallel GC不是好主意:它可能在回收期間「凍結」所有執行緒。請考慮 G1 GC、ZGC 或 Shenandoah。
3. 與集合相關的錯誤
使用 HashMap 取代 WeakHashMap 來做快取。
如果你做的是一種在沒有「活」參照時應自動移除物件的快取,請使用 WeakHashMap:
Map<Key, Value> cache = new WeakHashMap<>();
用一般的 HashMap 時,物件會一直存在,直到你手動清理快取,這會導致記憶體外洩。
遺漏對元素呼叫 remove()。
如果你把物件加入集合(例如監聽器名單),卻在不需要時沒有移除,它們就會一直存在,特別是在長壽命集合(例如靜態集合)裡。
4. 最佳實務:如何避免問題
一定要移除監聽器。
如果物件訂閱了事件,當它不再需要時務必取消訂閱。可在 dispose() 方法或關閉視窗/畫面時處理。
button.removeActionListener(myListener);
快取請使用弱參照。
如果快取不需要保證物件一定存在,請使用 WeakReference 或其衍生集合(WeakHashMap)。當需要時,GC 就能回收記憶體。
在生產環境監控記憶體。
使用 jvisualvm、jconsole 或 APM 系統。這能在使用者抱怨之前抓到外洩。
在懷疑外洩時分析 heap dump
如果應用程式開始比平常吃更多記憶體,請擷取 heap dump(例如透過 jmap 或 jvisualvm),看看哪些物件佔的空間最多。通常幾分鐘內就能找出元兇。
調整 JVM 參數。
- -Xmx — 堆(heap)的最大大小
- -Xms — 堆(heap)的初始大小
合理的限制有助於避免 OutOfMemoryError 並加速診斷。
5. 實作:含記憶體外洩的程式碼與修正
範例 1:透過靜態集合導致外洩
public class MemoryLeakDemo {
// 靜態集合——存活到程式結束
private static final List<byte[]> leakyList = new ArrayList<>();
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
leakyList.add(new byte[1024 * 1024]); // 每次 1 MB
System.out.println("已新增 " + (i + 1) + " MB");
}
// OutOfMemoryError!
}
}
修正:使用區域變數,或在不再需要時清理集合。
public class MemoryLeakFixed {
public static void main(String[] args) {
List<byte[]> tempList = new ArrayList<>();
for (int i = 0; i < 1000; i++) {
tempList.add(new byte[1024 * 1024]);
System.out.println("已新增 " + (i + 1) + " MB");
}
// tempList = null; // 可以顯式設為 null
// 離開方法後,這些物件可由 GC 回收
}
}
範例 2:透過監聽器導致外洩
public class Window {
private final List<EventListener> listeners = new ArrayList<>();
public void addListener(EventListener l) {
listeners.add(l);
}
// 沒有 removeListener 方法!
}
修正:新增移除監聽器的方法,並在關閉視窗時呼叫它。
public void removeListener(EventListener l) {
listeners.remove(l);
}
範例 3:用 HashMap 做快取而非 WeakHashMap
Map<Object, Object> cache = new HashMap<>();
// ... 加入物件
修正:改用 WeakHashMap:
Map<Object, Object> cache = new WeakHashMap<>();
JVM 設定建議:用於監控記憶體
- 開啟 GC 日誌:-Xlog:gc* 或 -XX:+PrintGCDetails
- 限制堆的最大大小:-Xmx512m
- 若使用快取——請監控其大小,並在可行時使用弱參照
- 嘗試不同 GC:-XX:+UseG1GC、-XX:+UseZGC、-XX:+UseShenandoahGC
7. 處理記憶體時的常見錯誤
錯誤 #1:遺漏的監聽器與訂閱。 若你把監聽器加到物件上卻忘了移除,監聽器物件(以及它所參照的一切)會留在記憶體中。這是 GUI 與事件系統中的經典問題。請使用 removeListener/removeActionListener。
錯誤 #2:未清理的靜態集合。 靜態欄位的生命期最長。若把物件放入其中卻不清理,這些物件會永久佔用記憶體。對無上限的快取尤其陰險。
錯誤 #3:未釋放外部資源。 保留未關閉的串流、檔案或連線?你會耗掉記憶體並撞上作業系統限制。使用 try-with-resources 並關閉資源。
錯誤 #4:強制呼叫 System.gc()。 這不是萬靈丹,只是對 JVM 的請求。經常導致長暫停與效能退化。
錯誤 #5:用一般集合做快取。 若快取中的物件應該能自動移除,請使用弱/soft 參照(WeakHashMap、SoftReference)。否則就會外洩。
錯誤 #6:內部與匿名類別捕捉外部參照。 內部類別與 lambda 可能隱含保留對外部物件的參照。若把它們放進長壽命的集合——就會發生外洩。
錯誤 #7:忽視 GC 日誌。 若你不看 GC 日誌,就不會知道長暫停或頻繁的 Full GC——而使用者會從卡頓中得知。請開啟 -Xlog:gc* 或 -XX:+PrintGCDetails。
GO TO FULL VERSION