Java 理論與實踐: 用弱引用堵住記憶體流失

來源:互聯網
上載者:User
Java 理論與實踐: 用弱引用堵住記憶體流失

弱引用使得表達對象生命週期關係變得容易了

文檔選項

將此頁作為電子郵件發送

對此頁的評價

協助我們改進這些內容

層級: 中級

Brian Goetz , 首席顧問, Quiotix

2005 年 12 月 19 日

雖然用 Java 語言編寫的程式在理論上是不會出現“記憶體流失”的,但是有時對象在不再作為程式的邏輯狀態的一部分之後仍然不被垃圾收集。本月,負責保障應用程式健康的工程師 Brian Goetz 探討了無意識的對象保留的常見原因,並展示了如何用弱引用堵住泄漏。

要讓垃圾收集(GC)回收程式不再使用的對象,對象的邏輯 生命週期(應用程式使用它的時間)和對該對象擁有的引用的實際 生命週期必須是相同的。在大多數時候,好的軟體工程技術保證這是自動實現的,不用我們對對象生命週期問題花費過多心思。但是偶爾我們會建立一個引用,它在記憶體中包含對象的時間比我們預期的要長得多,這種情況稱為無意識的對象保留(unintentional object retention)

全域 Map 造成的記憶體流失

無意識對象保留最常見的原因是使用 Map 將中繼資料與臨時對象(transient object)相關聯。假定一個對象具有中等生命週期,比分配它的那個方法調用的生命週期長,但是比應用程式的生命週期短,如客戶機的通訊端串連。需要將一些中繼資料與這個通訊端關聯,如產生串連的使用者的標識。在建立 Socket 時是不知道這些資訊的,並且不能將資料添加到 Socket 對象上,因為不能控制 Socket 類或者它的子類。這時,典型的方法就是在一個全域 Map 中儲存這些資訊,如清單 1 中的 SocketManager 類所示:

清單 1. 使用一個全域 Map 將中繼資料關聯到一個對象

public class SocketManager {    private Map<Socket,User> m = new HashMap<Socket,User>();        public void setUser(Socket s, User u) {        m.put(s, u);    }    public User getUser(Socket s) {        return m.get(s);    }    public void removeUser(Socket s) {        m.remove(s);    }}SocketManager socketManager;...socketManager.setUser(socket, user);

這種方法的問題是中繼資料的生命週期需要與通訊端的生命週期掛鈎,但是除非準確地知道什麼時候程式不再需要這個通訊端,並記住從 Map 中刪除相應的映射,否則,SocketUser 對象將會永遠留在 Map 中,遠遠超過響應了請求和關閉通訊端的時間。這會阻止 SocketUser 對象被垃圾收集,即使應用程式不會再使用它們。這些對象留下來不受控制,很容易造成程式在長時間運行後記憶體爆滿。除了最簡單的情況,在幾乎所有情況下找出什麼時候 Socket 不再被程式使用是一件很煩人和容易出錯的任務,需要人工對記憶體進行管理。

回頁首

找出記憶體流失

程式有記憶體流失的第一個跡象通常是它拋出一個 OutOfMemoryError,或者因為頻繁的垃圾收集而表現出糟糕的效能。幸運的是,垃圾收集可以提供能夠用來診斷記憶體流失的大量資訊。如果以 -verbose:gc 或者 -Xloggc 選項調用 JVM,那麼每次 GC 運行時在控制台上或者記錄檔中會列印出一個診斷資訊,包括它所花費的時間、當前堆使用方式以及恢複了多少記憶體。記錄 GC 使用方式並不具有幹擾性,因此如果需要分析記憶體問題或者調優垃圾收集器,在生產環境中預設啟用 GC 日誌是值得的。

有工具可以利用 GC 日誌輸出並以圖形方式將它顯示出來,JTune 就是這樣的一種工具(請參閱 參考資料)。觀察 GC 之後堆大小的圖,可以看到程式記憶體使用量的趨勢。對於大多數程式來說,可以將記憶體使用量分為兩部分:baseline 使用和 current load 使用。對於伺服器應用程式,baseline 使用就是應用程式在沒有任何負荷、但是已經準備好接受請求時的記憶體使用量,current load 使用是在處理請求過程中使用的、但是在請求處理完成後會釋放的記憶體。只要負荷大體上是恒定的,應用程式通常會很快達到一個穩定的記憶體使用量水平。如果在應用程式已經完成了其初始化並且負荷沒有增加的情況下,記憶體使用量持續增加,那麼程式就可能在處理前面的請求時保留了產生的對象。

清單 2 展示了一個有記憶體流失的程式。MapLeaker 線上程池中處理任務,並在一個 Map 中記錄每一項任務的狀態。不幸的是,在任務完成後它不會刪除那一項,因此狀態項和任務對象(以及它們的內部狀態)會不斷地積累。

清單 2. 具有基於 Map 的記憶體流失的程式

public class MapLeaker {    public ExecutorService exec = Executors.newFixedThreadPool(5);    public Map<Task, TaskStatus> taskStatus         = Collections.synchronizedMap(new HashMap<Task, TaskStatus>());    private Random random = new Random();    private enum TaskStatus { NOT_STARTED, STARTED, FINISHED };    private class Task implements Runnable {        private int[] numbers = new int[random.nextInt(200)];        public void run() {            int[] temp = new int[random.nextInt(10000)];            taskStatus.put(this, TaskStatus.STARTED);            doSomeWork();            taskStatus.put(this, TaskStatus.FINISHED);        }    }    public Task newTask() {        Task t = new Task();        taskStatus.put(t, TaskStatus.NOT_STARTED);        exec.execute(t);        return t;    }}

圖 1 顯示 MapLeaker GC 之後應用程式堆大小隨著時間的變化圖。上升趨勢是存在記憶體流失的警示訊號。(在真實的應用程式中,坡度不會這麼大,但是在收集了足夠長時間的 GC 資料後,上升趨勢通常會表現得很明顯。)

圖 1. 持續上升的記憶體使用量趨勢

確信有了記憶體流失後,下一步就是找出哪種對象造成了這個問題。所有記憶體分析器都可以產生按照對象類進行分解的堆快照。有一些很好的商業堆分析工具,但是找出記憶體流失不一定要花錢買這些工具 —— 內建的 hprof 工具也可完成這項工作。要使用 hprof 並讓它跟蹤記憶體使用量,需要以 -Xrunhprof:heap=sites 選項調用 JVM。

清單 3 顯示分解了應用程式記憶體使用量的 hprof 輸出的相關部分。(hprof 工具在應用程式退出時,或者用 kill -3 或在 Windows 中按 Ctrl+Break 時產生使用分解。)注意兩次快照相比,Map.EntryTaskint[] 對象有了顯著增加。

請參閱 清單 3。

清單 4 展示了 hprof 輸出的另一部分,給出了 Map.Entry 對象的分配點的呼叫堆疊資訊。這個輸出告訴我們哪些調用鏈產生了 Map.Entry 對象,並帶有一些程式分析,找出記憶體流失來源一般來說是相當容易的。

清單 4. HPROF 輸出,顯示 Map.Entry 對象的分配點

TRACE 300446:java.util.HashMap$Entry.<init>(<Unknown Source>:Unknown line)java.util.HashMap.addEntry(<Unknown Source>:Unknown line)java.util.HashMap.put(<Unknown Source>:Unknown line)java.util.Collections$SynchronizedMap.put(<Unknown Source>:Unknown line)com.quiotix.dummy.MapLeaker.newTask(MapLeaker.java:48)com.quiotix.dummy.MapLeaker.main(MapLeaker.java:64)

回頁首

弱引用來救援了

SocketManager 的問題是 Socket-User 映射的生命週期應當與 Socket 的生命週期相匹配,但是語言沒有提供任何容易的方法實施這項規則。這使得程式不得不使用人工記憶體管理的老技術。幸運的是,從 JDK 1.2 開始,垃圾收集器提供了一種聲明這種對象生命週期依賴性的方法,這樣垃圾收集器就可以協助我們防止這種記憶體流失 —— 利用弱引用

弱引用是對一個對象(稱為 referent)的引用的持有人。使用弱引用後,可以維持對 referent 的引用,而不會阻止它被垃圾收集。當垃圾收集器跟蹤堆的時候,如果對一個對象的引用只有弱引用,那麼這個 referent 就會成為垃圾收集的候選對象,就像沒有任何剩餘的引用一樣,而且所有剩餘的弱引用都被清除。(只有弱引用的對象稱為弱可及(weakly reachable)。)

WeakReference 的 referent 是在構造時設定的,在沒有被清除之前,可以用 get() 擷取它的值。如果弱引用被清除了(不管是 referent 已經被垃圾收集了,還是有人調用了 WeakReference.clear()),get() 會返回 null。相應地,在使用其結果之前,應當總是檢查 get() 是否返回一個非 null 值,因為 referent 最終總是會被垃圾收集的。

用一個普通的(強)引用拷貝一個對象引用時,限制 referent 的生命週期至少與被拷貝的引用的生命週期一樣長。如果不小心,那麼它可能就與程式的生命週期一樣 —— 如果將一個對象放入一個全域集合中的話。另一方面,在建立對一個對象的弱引用時,完全沒有擴充 referent 的生命週期,只是在對象仍然存活的時候,保持另一種到達它的方法。

弱引用對於構造弱集合最有用,如那些在應用程式的其餘部分使用對象期間儲存關於這些對象的中繼資料的集合 —— 這就是 SocketManager 類所要做的工作。因為這是弱引用最常見的用法,WeakHashMap 也被添加到 JDK 1.2 的類庫中,它對鍵(而不是對值)使用弱引用。如果在一個普通 HashMap 中用一個對象作為鍵,那麼這個對象在映射從 Map 中刪除之前不能被回收,WeakHashMap 使您可以用一個對象作為 Map 鍵,同時不會阻止這個對象被垃圾收集。清單 5 給出了 WeakHashMapget() 方法的一種可能實現,它展示了弱引用的使用:

清單 5. WeakReference.get() 的一種可能實現

public class WeakHashMap<K,V> implements Map<K,V> {    private static class Entry<K,V> extends WeakReference<K>       implements Map.Entry<K,V> {        private V value;        private final int hash;        private Entry<K,V> next;        ...    }    public V get(Object key) {        int hash = getHash(key);        Entry<K,V> e = getChain(hash);        while (e != null) {            K eKey= e.get();            if (e.hash == hash && (key == eKey || key.equals(eKey)))                return e.value;            e = e.next;        }        return null;    }

調用 WeakReference.get() 時,它返回一個對 referent 的強引用(如果它仍然存活的話),因此不需要擔心映射在 while 迴圈體中消失,因為強引用會防止它被垃圾收集。WeakHashMap 的實現展示了弱引用的一種常見用法 —— 一些內部對象擴充 WeakReference。其原因在下面一節討論引用隊列時會得到解釋。

在向 WeakHashMap 中添加映射時,請記住映射可能會在以後“脫離”,因為鍵被垃圾收集了。在這種情況下,get() 返回 null,這使得測試 get() 的傳回值是否為 null 變得比平時更重要了。

用 WeakHashMap 堵住泄漏

SocketManager 中防止泄漏很容易,只要用 WeakHashMap 代替 HashMap 就行了,如清單 6 所示。(如果 SocketManager 需要安全執行緒,那麼可以用 Collections.synchronizedMap() 封裝 WeakHashMap)。當映射的生命週期必須與鍵的生命週期聯絡在一起時,可以使用這種方法。不過,應當小心不濫用這種技術,大多數時候還是應當使用普通的 HashMap 作為 Map 的實現。

清單 6. 用 WeakHashMap 修複 SocketManager

public class SocketManager {    private Map<Socket,User> m = new WeakHashMap<Socket,User>();        public void setUser(Socket s, User u) {        m.put(s, u);    }    public User getUser(Socket s) {        return m.get(s);    }}

引用隊列

WeakHashMap 用弱引用承載映射鍵,這使得應用程式不再使用鍵對象時它們可以被垃圾收集,get() 實現可以根據 WeakReference.get() 是否返回 null 來區分死的映射和活的映射。但是這隻是防止 Map 的記憶體消耗在應用程式的生命週期中不斷增加所需要做的工作的一半,還需要做一些工作以便在鍵對象被收集後從 Map 中刪除死項。否則,Map 會充滿對應於死鍵的項。雖然這對於應用程式是不可見的,但是它仍然會造成應用程式耗盡記憶體,因為即使鍵被收集了,Map.Entry 和值對象也不會被收集。

可以通過周期性地掃描 Map,對每一個弱引用調用 get(),並在 get() 返回 null 時刪除那個映射而消除死映射。但是如果 Map 有許多活的項,那麼這種方法的效率很低。如果有一種方法可以在弱引用的 referent 被垃圾收集時發出通知就好了,這就是引用隊列 的作用。

引用隊列是垃圾收集器嚮應用程式返回關於對象生命週期的資訊的主要方法。弱引用有兩個建構函式:一個只取 referent 作為參數,另一個還取引用隊列作為參數。如果用關聯的引用隊列建立弱引用,在 referent 成為 GC 候選對象時,這個引用對象(不是 referent)就在引用清除後加入 到引用隊列中。之後,應用程式從引用隊列提取引用並瞭解到它的 referent 已被收集,因此可以進行相應的清理活動,如去掉已不在弱集合中的對象的項。(引用隊列提供了與 BlockingQueue 同樣的出列模式 —— polled、timed blocking 和 untimed blocking。)

WeakHashMap 有一個名為 expungeStaleEntries() 的私人方法,大多數 Map 操作中會調用它,它去掉引用隊列中所有失效的引用,並刪除關聯的映射。清單 7 展示了 expungeStaleEntries() 的一種可能實現。用於儲存鍵-值對應的 Entry 類型擴充了 WeakReference,因此當 expungeStaleEntries() 要求下一個失效的弱引用時,它得到一個 Entry。用引用隊列代替定期掃描內容的方法來清理 Map 更有效,因為清理過程不會觸及活的項,只有在有實際排入佇列的引用時它才工作。

清單 7. WeakHashMap.expungeStaleEntries() 的可能實現

    private void expungeStaleEntries() {Entry<K,V> e;        while ( (e = (Entry<K,V>) queue.poll()) != null) {            int hash = e.hash;            Entry<K,V> prev = getChain(hash);            Entry<K,V> cur = prev;            while (cur != null) {                Entry<K,V> next = cur.next;                if (cur == e) {                    if (prev == e)                        setChain(hash, next);                    else                        prev.next = next;                    break;                }                prev = cur;                cur = next;            }        }    }

回頁首

結束語

弱引用和弱集合是對堆進行管理的強大工具,使得應用程式可以使用更複雜的可及性方案,而不只是由普通(強)引用所提供的“要麼全部要麼沒有”可及性。下個月,我們將分析與弱引用有關的軟引用,將分析在使用弱引用和軟引用時,垃圾收集器的行為。

回頁首

參考資料

學習

  • 您可以參閱本文在 developerWorks 全球網站上的 英文原文

  • “關注效能: 調優垃圾收集”:Kirk Pepperdine 和 Jack Shirazi 展示了緩慢的記憶體流失最終對於垃圾收集器造成了無法承受的壓力。
  • “HPROF”:Sun 的這一篇文章描述了如何使用內建的 HPROF 分析工具。
  • Reference objects and garbage collection:Sun 的這篇文章是在 Reference 對象剛加入類庫不久寫的,描述了垃圾收集器如何處理 Reference 對象。
  • Java 理論與實踐:Brian Goetz 所寫的全部系列文章。
  • Java 技術專區:數百篇關於 Java 編程各個方面的文章。

獲得產品和技術

  • JTune:免費 JTune 工具,可以使用 GC 日誌並以圖形方式顯示堆大小、GC 期間和其他有用的記憶體管理資料。

討論

  • 加入本文的論壇 。(您也可以通過點擊文章頂部或者底部的論壇連結參加討論。)

  • developerWorks blogs:參與 developerWorks 社區。

回頁首

關於作者

Brian Goetz 成為專業軟體開發人員已經超過 18 年了。他是 Quiotix 的首席顧問,該公司是位於加利福尼亞 Los Altos 的軟體開發和諮詢公司。他參加了幾個 JCP 專家組。Brian 的 Java Concurrency In Practice 一書將於 2005 年末由 Addison-Wesley 出版。請在業界流行的出版物上查閱 Brian 已發表的和即將發表的文章。

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.