CodeGym /課程 /JAVA 25 SELF /解析處理記憶體時的常見錯誤

解析處理記憶體時的常見錯誤

JAVA 25 SELF
等級 64 , 課堂 4
開放

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 GCZGCShenandoah

3. 與集合相關的錯誤

使用 HashMap 取代 WeakHashMap 來做快取。
如果你做的是一種在沒有「活」參照時應自動移除物件的快取,請使用 WeakHashMap

Map<Key, Value> cache = new WeakHashMap<>();

用一般的 HashMap 時,物件會一直存在,直到你手動清理快取,這會導致記憶體外洩。

遺漏對元素呼叫 remove()
如果你把物件加入集合(例如監聽器名單),卻在不需要時沒有移除,它們就會一直存在,特別是在長壽命集合(例如靜態集合)裡。

4. 最佳實務:如何避免問題

一定要移除監聽器。
如果物件訂閱了事件,當它不再需要時務必取消訂閱。可在 dispose() 方法或關閉視窗/畫面時處理。

button.removeActionListener(myListener);

快取請使用弱參照。
如果快取不需要保證物件一定存在,請使用 WeakReference 或其衍生集合(WeakHashMap)。當需要時,GC 就能回收記憶體。

在生產環境監控記憶體。
使用 jvisualvmjconsole 或 APM 系統。這能在使用者抱怨之前抓到外洩。

在懷疑外洩時分析 heap dump

如果應用程式開始比平常吃更多記憶體,請擷取 heap dump(例如透過 jmapjvisualvm),看看哪些物件佔的空間最多。通常幾分鐘內就能找出元兇。

調整 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 參照(WeakHashMapSoftReference)。否則就會外洩。

錯誤 #6:內部與匿名類別捕捉外部參照。 內部類別與 lambda 可能隱含保留對外部物件的參照。若把它們放進長壽命的集合——就會發生外洩。

錯誤 #7:忽視 GC 日誌。 若你不看 GC 日誌,就不會知道長暫停或頻繁的 Full GC——而使用者會從卡頓中得知。請開啟 -Xlog:gc*-XX:+PrintGCDetails

1
問卷/小測驗
記憶體與垃圾回收,等級 64,課堂 4
未開放
記憶體與垃圾回收
記憶體與垃圾回收
留言
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION