【譯】Java引用物件
在寫了15年C/C++之後,我於1999年開始寫Java。藉助指標切換(pointer handoffs)等編碼實踐或者Purify等工具,我認為自己對C風格的記憶體管理已經得心應手了,甚至已經不記得上次發生記憶體洩露是什麼時候了。所以起初我接觸到Java的自動記憶體管理時有些不屑,但很快就愛上它了。在我不需要再管理記憶體後我才意識到之前耗費了多少精力。
接著我就遇到了第一個 OutOfMemoryError
。當時我就坐在那面對著控制檯,沒有堆疊,因為堆疊也需要記憶體。除錯這個錯誤很困難,因為常用的工具都不能用了,甚至 malloc logger
都沒有,而且1999年的時候Java的偵錯程式還很原始。
我不記得當時是什麼原因導致的錯誤了,但我肯定當時沒有用引用物件解決它。引用物件是在一年後我寫服務端資料庫快取,嘗試用軟引用來限制快取大小時才進入我的“工具箱”的。結果證明它們在這種場景下用處不大,我下面會解釋原因。但當引用型別才進入我的“工具箱”後,我發現了很多其他用途,並且對JVM也有了更好的理解。
Java堆和物件生命週期
對於剛接觸Java的C++程式員來說,棧和隊之間的關係很難理解。在C++中,物件可以通過 new
操作在堆上建立,也可以通過“自動”分配在棧上建立。下面這種在C++中是合法的,會在棧上建立一個新的 Integer
物件,但對於Java編譯器來說這有語法錯誤。
Integer foo = Integer(1);
不同於C++,Java的所有物件都在堆儲存,要求用 new
操作來建立物件。區域性變數仍然儲存在棧中,但它們持有這個物件的指標而不是這個物件本身(更讓C++程式設計師困惑的是這些指標被叫做“引用”)。下面這個Java方法,有一個 Integer
變數引用一個從 String
解析而來的值:
public static void foo(String bar) { Integer baz = new Integer(bar); }
下圖顯示了這個方法相應的堆和棧之間的關係。棧被分割為棧幀,用於儲存呼叫樹中各個方法的引數和區域性變數。這些變數指向物件–這個例子中的引數 bar
和區域性變數 baz
–指23

現在仔細看看 foo()
的第一行,建立了一個 Integer
物件。這種情況下,JVM會先試圖去為這個物件找足夠的堆空間–在32位JVM上大約12 bytes,如果可以分配出空間,就呼叫 Integer
的建構函式, Integer
的建構函式會解析傳入的String然後初始化這個新建立的物件。最後,JVM在變數 baz
中儲存一個指向該物件的指標。
這是理想的道路,還有一些不那麼美好的道路,其中我們關心的是當 new
操作不能為這個物件找到12 bytes的情況。在這種情況下,JVM會在放棄並丟擲 OutOfMemoryError
之前呼叫垃圾回收器嘗試騰出空間。
垃圾回收
雖然Java給了你 new
操作來在堆上分配物件,但是沒有給你對應的 delete
操作來移除它們。當方法 foo()
返回,變數 baz
離開了作用域,但是它指向的物件依然存在於堆中。如果只是這樣的話,那所有的程式都會很快耗盡記憶體。Java提供了垃圾回收器來清理那些不再被引用的物件。
垃圾回收器會在程式嘗試建立一個新物件但堆沒有足夠的空間時工作。回收器在堆上尋找那些不再被程式使用的物件並回收它們的空間時,請求建立物件的執行緒會暫停。如果回收器無法騰出足夠的空間,並且JVM無法擴充套件堆, new
操作就會失敗並丟擲 OutOfMemoryError
,通常接下來你的應用會停止。
標記-清除
其中一個關於垃圾回收器的誤區是,很多人認為JVM為每個物件儲存了一個引用計數,回收器只會回收那些引用計數為0的物件。事實上,JVM使用被稱為“標記-清除”的技術。標記-清除演算法的思路很簡單:所有不能被程式訪問到的物件都是垃圾,都可以被收集。
標記-清除演算法有以下階段:
階段一:標記
垃圾回收器從“root”引用開始,標記所有可以到達的物件。

階段二:清除
在第一階段沒有被標記的都是不可到達的,也就是垃圾。如果垃圾物件定義了 finalizer
,它會被加到 finalization
佇列(後文詳細討論)。否則,它佔用的空間就可以被重新分配使用(具體的情況視GC的實現而定,有很多種實現)。

階段三:壓縮(可選)
一些回收器有第三步——壓縮。在這一步,GC會移動物件使回收的物件留下的空閒空間合併,這可以防止堆變得碎片化,避免大塊相鄰記憶體分配的失敗。
例如,Hotspot JVM,在新生代使用會壓縮的回收器,而在老年代使用非壓縮的回收器(至少在1.6和1.7的“server” JVM是這樣)。想了解更多資訊,可以看本文後面的參考文獻。

那麼什麼是“roots”呢?在一個簡單的Java應用中,它們是方法引數和區域性變數(儲存在棧中)、當前執行的表示式操作的物件(也儲存在棧中)、靜態類成員變數。
對於使用自己classloader的程式,例如應用伺服器,情況複雜一些:只有被 system classloader
(JVM啟動時使用這個loader)載入的類包含root引用。那些被應用建立的classloader一旦沒有其他引用也會被回收。這是應用伺服器可以熱部署的原因:它們為每個部署的應用建立獨立的classloader,當應用下線或重新部署時釋放classloader引用。
理解root引用很重要,因為這定義了“強引用”,即如果可以從root沿著引用鏈到達某個物件,那麼這個物件就被“強引用”了,則不會被回收。
回到 foo()
方法,引數 bar
和區域性變數 baz
使用當方法執行時才是強引用,一旦方法結束,它們都超出了作用域,被他們引用的物件就可以回收。另一種可能是, foo()
返回一個它建立的Integer引用,這意味著這個物件會被呼叫 foo()
的那個方法保持強引用。
看下面這個例子:
LinkedList foo = new LinkedList(); foo.add(new Integer(123));
變數 foo
是一個指向LinkedList物件的root引用,列表中有0個或多個元素,都指向其物件。當我們呼叫 add()
時,向列表中添加了一個指向值為123的Integer例項的元素,這是一個強引用,意味著這個Integer例項不會被回收。一旦 foo
超出了作用域,這個LinkedList和它裡面的一切都可以被回收,當前前提是沒有其他強引用指向它了。
你也許想知道迴圈引用會發生什麼,即物件A包含一個物件B的引用,同時物件B也包含物件A的引用。答案是標記-清除回收器並不傻,如果A和B都無法由強引用鏈到達,那麼它們都可以被回收。
Finalizers
C++允許物件定義析構方法,當物件離開作用域或者被明確刪除時,它的解構函式會被呼叫來清理它使用的資源,對大多數物件來說即釋放通過 new
或 malloc
分配的記憶體。在Java中,垃圾回收器會為你處理記憶體清理,所以不需要明確的解構函式來做這些。
然而,記憶體並不是唯一可能需要被清理的資源。例如 FileOutputStream
,當建立這個物件的例項時,會從作業系統分配一個檔案操作符(檔案控制代碼),如果你在關閉流之前讓它的所有引用都離開作用域了,這個檔案操作符會發生什麼呢?答案是流有 finalizer
,這個方法會在垃圾回收器回收物件前被JVM呼叫。這個例子中的 FileOutputStream
在 finalizer
方法中會關閉流,這樣就會將檔案操作符返回給作業系統,同時也會重新整理快取,確保所有資料被正確地寫到磁碟。
任何物件都可以有 finalizer
,你只需要定義 finalize()
方法即可:
protected void finalize() throws Throwable { // 在這裡釋放你的物件 }
finalizers
看上去是一個由你自己清理的簡單方式,但實際上有嚴重的限制。首先,你永遠也不要依賴它做重要的事,因為物件的 finalizers
可能不會被呼叫,應用可能在物件被回收之前就結束了。 finalizers
還有一些更微妙的問題,我會在虛引用時討論。
物件的生命週期(無引用物件)
總結起來,物件的一生可以用下面的圖總結:被建立、被使用、可回收、最終被回收。陰影部分表示物件是“強可達”的時期,這是與引用物件規定的可達性比較而言很重要的時期。

進入引用物件的世界
JDK 1.2引入了 java.lang.ref
包,物件的生命週期增加了3種階段:軟可達、弱可達、虛可達。這些階段只用來可否被回收,換言之,那些不是強引用的物件,必須是其中一種引用物件的被引用者:
-
軟可達
物件是
SoftReference
的被引用者,並且沒有強引用指向它。垃圾回收器會盡可能地保留它,但會在丟擲OutOfMemoryError
之前回收它。 -
弱可達
物件是
WeakReference
的被引用者,並且沒有強引用指向它。垃圾回收器可以在任何時間回收它,不會試圖去保留它。通常這個物件會在Major GC
被回收,可能在Minor GC
中存活。 -
虛可達
物件是
PhantomReference
的被引用者,它已經被選擇要回收並且finalizer
(如果有)已經運行了。這裡的“可達”有點用詞不當,在這個時候你已經沒有辦法訪問到原始的物件了。
如你所想,把這三種新的可選狀態加到物件生命週期圖中會變得很複雜。儘管文件指出了一個邏輯上從強可達到軟可達、弱可達、虛可達的回收過程,但實際過程取決於你的程式建立了哪種引用物件。如果你建立了一個 WeakReference
而不是一個 SoftReference
,那麼物件回收的過程是直接從強可達到弱可達最後被回收的。

還有一點需要清楚的是,不是所有的物件都需要與引用物件關聯,事實上,只有極少部分物件需要。引用物件是一個間接層:你通過引用物件去訪問它的被引用者,你肯定不希望你的程式碼中充斥著這些間接層。事實上大部分程式只會使用引用物件去訪問很少一部分它建立的物件。
引用和被引用者
引用物件是在你程式程式碼和一些稱為被引用者的物件之間的中間層。每個引用物件都是圍繞它的被引用者建立,並且被引用者是不能修改的。

引用物件提供了 get()
方法來獲取被引用者的強引用。垃圾回收器可能在某些情況下回收被引用者,一旦回收了, get()
會返回 null
。正確使用引用,你需要類似下面這樣的程式碼:
SoftReference<List<Foo>> ref = new SoftReference<List<Foo>>(new LinkedList<Foo>()); // 程式碼其他地方建立了`Foo`,你想把它新增到列表中 List<Foo> list = ref.get(); if (list != null) { list.add(foo); } else { // 列表已經被回收了,做一些恰當的事 }
換言之:
- 你必須總是檢檢視被引用者是否是null。
垃圾回收器可能在任何時間回收被引用者,如果你無所顧忌地使用,很快就會收穫NullPointerException
。 - 當你想使用被引用者時,你必須持有一個它的強引用。
再次強調, 垃圾回收器可能在任何時間回收被引用者,甚至是在單個表示式之間。上面的例子如果我不定義list
變數,我而是簡單地呼叫ref.get().add(foo)
,被引用者可能在檢查是否為null和實際使用之間被回收。牢記垃圾回收器是在它自己的執行緒執行的,它不關心你的程式碼在幹什麼。 - 你必須持有引用型別的強引用。
如果你建立了一個引用物件,但超出了它的作用域,那麼這個引用物件自己也會被回收。這是顯然的,但很容易被忘記,特別是在用引用佇列(qv)追蹤引用物件的時候。
同樣要記住的是軟引用、弱引用、虛引用只有在沒有其他強引用指向被引用者時才有意義,它們讓你可以在物件通常會成為垃圾回收器的食物時候獲得該物件。這可能看起來很奇怪,如果不再持有強引用了,為什麼我還關心這個物件呢?原因視特殊的引用型別而定。
軟引用
我們先從軟引用開始來回答這個問題。如果一個物件是 SoftReference
的被引用者,並且它沒有強引用,那麼垃圾回收器可以回收但儘量不去回收它。因此,只要JVM有足夠的記憶體,軟引用物件就會在垃圾回收中存活,甚至經歷好幾輪垃圾回收依然存活。
JDK文件說軟引用適用於記憶體敏感的快取:每個快取物件都通過 SoftReference
訪問,如果JVM覺得需要記憶體,它就會清除一些或者所有引用並回收對應的被引用者。如果JVM不需要記憶體,被引用者就會留在堆中,並且可以被程式程式碼訪問到。在這種方案下,被引用者在使用時是強引用的,其他情況是軟引用的,如果軟引用被清除了,你需要重新整理快取。
想作為這種角色使用,被快取的物件需要比較大,如每個幾kB。比如說,你想實現一個檔案服務相同的檔案會被定期檢索,或者有一些大的圖片物件需要快取時會有用。但如果你的物件很小,你只有在需要定義大量物件時情況才會不同,引用物件還會增加整個程式的負擔。
記憶體限制型快取被認為是有害的
我的觀點是,可用記憶體絕對是最差的管理快取的方式。如果你的堆很小,你不時需要重新載入物件,無論它們是不是被活躍地使用,你也無法知道這個,因為快取會靜默地處理它們。大的堆更糟:你會持有物件遠大於它的正常壽命,當每次垃圾回收時會使你的應用變慢,因為需要檢查這些物件。如果這些物件沒有被訪問,這一部分堆有可能被交換出去,回收過程中可能有大量頁錯誤。
底線:如果你要用快取,詳細它會如何被使用,選一個適合的快取策略(LRU、timed LRU),在選擇基於記憶體的策略前仔細考慮。
軟引用用於斷路器
用軟引用為記憶體分配提供斷路器是更好的選擇:在你的程式碼和它分配的記憶體之間使用軟引用,你就可以避免可怕的 OutOfMemoryError
。這個技巧可以正常運作是因為在應用裡記憶體的分配是趨於區域性的:從資料庫中讀取行、從一個檔案中處理資料。
例如,如果你寫過很多JDBC的程式碼,你可能會有類似下面這樣的方法以某種方式處理查詢的結果並且確保 ResultSet
被正確地關閉。這隻有一個小缺陷:如果查詢返回了一百萬行,你沒有可用的記憶體去儲存它們時會發生什麼?
public static List<List<Object>> processResults(ResultSet rslt) throws SQLException { try { List<List<Object>> results = new LinkedList<List<Object>>(); ResultSetMetaData meta = rslt.getMetaData(); int colCount = meta.getColumnCount(); while (rslt.next()) { List<Object> row = new ArrayList<Object>(colCount); for (int ii = 1 ; ii <= colCount ; ii++) row.add(rslt.getObject(ii)); results.add(row); } return results; } finally { closeQuietly(rslt); } }
答案當然是會得到 OutOfMemoryError
。這是使用斷路器的絕佳地方:如果在處理查詢時JVM要耗盡記憶體了,那就釋放所有已經使用的那些記憶體,丟擲一個應用特殊的異常。
你可能很奇怪,這種情況下這次查詢將被忽略,為什麼不直接讓記憶體耗盡的錯誤來做這件事呢?原因是並不僅僅只有你的應用被記憶體耗盡影響。如果你在一個應用伺服器上執行,你的記憶體使用可能幹掉其他應用。即使是在一個獨有的環境,斷路器也能提升你的應用的健壯性,因為它能限制問題,讓你有機會恢復並繼續執行。
要建立一個斷路器,首先你需要做的是把結果的列表包裝在 SoftReference
中(你在前面已經見過這個程式碼了):
SoftReference<List<List<Object>>> ref = new SoftReference<List<List<Object>>>(new LinkedList<List<Object>>());
然後,你遍歷結果,在你需要更新這個列表時為它建立強引用:
while (rslt.next()) { rowCount++; // store the row data List<List<Object>> results = ref.get(); if (results == null) throw new TooManyResultsException(rowCount); else results.add(row); results = null; }
這可以滿足要求是因為這個方法幾乎所有的記憶體分配都發生在2個地方:呼叫 next()
時和程式碼把行裡的資料存放到它自己的列表中時。第一種情況當你呼叫 next()
時會發生很多事情: ResultSet
一般會在包含多行的一大塊二進位制資料中檢索,然後當你呼叫 getObject()
,它會取出一部分資料把它轉成Java物件。
當這些昂貴的操作發生時,這個list只有來自 SoftReference
的引用,如果記憶體耗盡,引用會被清除,list會變成垃圾。這意味著這個方法可能丟擲異常,但丟擲異常的影響是有限的,也許呼叫方能以一點數量限制重新進行查詢。
一旦昂貴的操作完成,你可以沒有影響地拿到list的強引用。注意到我用 LinkedList
儲存結果而不是 ArrayList
, LinkedList
增長時只會增加少量位元組,不太可能引起 OutOfMemoryError
,而如果 ArrayList
需要增加容量,它需要建立一個新陣列,對於大列表來說,這可能意味著數MB的記憶體分配。
還注意到我在新增新元素後把 results
變數設定為 null
,這是少數幾種這樣做是合理的情形之一。儘管在迴圈的最後變數超出了作用域,但垃圾回收器可能並不知道(因為JVM沒有理由去清除變數在呼叫棧中的位置)。因此如果我不清除這個變數的話,它會在隨後的迴圈中成為隱藏的強引用。
軟引用不是萬能的
軟引用可以預防很多記憶體耗盡的情況,但不能預防所有。問題在於:為了真正地使用軟引用,你需要建立一個被引用者的強引用,即為了向 results
中新增一行,我們需要持有實際列表的引用。我們持有強引用的時候就會面臨發生記憶體耗盡錯誤的風險。
使用斷路器的目標是把一些無用的東西的時間視窗減到最小:你持有物件強引用的時間,更重要的是在這段時間中分配記憶體的總量。在我們的例子中,我們限制強引用去新增一行到 results
中,我們使用 LinkedList
而不是 ArrayList
因為前者擴容時增長更小。
我想重申的是,如果我一個變數持有強引用,但這個變數很快超出了作用域,語言細則沒有說JVM需要清除超出作用域的變數,如果是像寫的這樣,Oracle/OpenJDK JVM都沒有這樣做,如果我不明確地清除 results
變數,在遍歷期間會保持強引用,阻止軟引用做它的工作。
最後,仔細考慮那些隱藏的強引用。例如,你可能會想在使用DOM構造XML文件時加入斷路器。在DOM中,每個節點都持有它父節點的引用,從而導致持有了樹中每個其他節點的引用。如果你用遞迴去建立文件,你的棧中可能塞滿了個別節點的引用。
弱引用
弱引用,正如它名字顯示,是一個當垃圾回收器來敲門時不會反抗的引用物件。如果被引用者沒有強引用或軟引用而只有弱引用,那它就可以被回收。所以弱引用有什麼用呢?有2個主要用途:關聯沒有內在聯絡的物件,或者通過 canonicalizing map
減少重複。
ObjectOutputStream
的問題
第一個例子,我準備聚焦不使用弱引用的物件序列化。 ObjectOutputStream
以及它的夥伴 ObjectInputStream
提供了任意Java物件與位元組流之間相互轉換的方式。根據物件模型的觀點,流和用這些流寫的物件之間是沒有聯絡的。流不是由這些被寫的物件組成的,也不是它們的聚集。
但是當你看這些流的說明時,你會看到事實上是有聯絡的:為了維持物件的唯一性,輸出流會和每個被寫的物件關聯一個唯一的識別符號,隨後的寫物件的請求被替換為寫這個識別符號。這個特徵對於流序列號物件的能力來說絕對是很重要的,如果沒有這個特徵,自我引用的物件會變成一個無限的位元組流。
要實現這個特徵,流需要持有每個寫到流中的物件的強引用。對於決定在socket通訊時用物件流作為訊息協議的程式設計師來說,有這麼一個問題:訊息被設計為短暫的,但流會在記憶體中持有它們,不久之後,程式會耗盡記憶體(除非程式設計師知道在每次通訊後呼叫 reset()
)。
這種非與生俱來的聯絡驚人的普遍。它們會在程式設計師為了使用物件而需要去維持必不可少的上下文時出現。有時這些聯絡被執行環境默默管理,例如servlet Session
物件;有時這些聯絡需要被程式設計師明確地管理,例如物件流;還有些時候,這種聯絡只有當生產環境的服務丟擲記憶體耗盡的錯誤時才會被發現,比如埋藏在程式程式碼深處的靜態 Map
。
弱引用提供了一種維持這種聯絡的同時還能讓垃圾回收器做它的工作的方式,弱引用只有在同時還有強引用時才保持有效。回到物件流的例子,如果你用流來通訊,一旦訊息被寫完就可以被回收了。另一方面,當流用來RMI訪問一個生命週期很長的資料結構時,它能保持它一致。
不幸的是,儘管物件流通訊協議在JDK 1.2時被更新了。虛引用也是這樣被加入的,但JDK的開發者並沒有選擇把二者結合到一起,所以記得呼叫 reset()
。
用 Canonicalizing Maps
消除重複資料
儘管存在物件流這種情況,但我不認為有很多你應該關聯兩個沒有內在關係的物件的情行。我所看到的一些例子,例如Swing監聽器,它們會自我清理,看起來更像是黑客,而不是有效的設計選擇。
當我最初寫這篇文章的時候,大約是在2007年,我提出了 canonicalizing map
作為 String.intern()
的替代物,是在假設被存入常量池的字串永遠不會被清理的前提下。後來我得知這種擔心是毫無根據的。更重要的是,從JDK 8開始,OpenJDK已經完全去掉了永久代。因此,沒有必要害怕 intern()
,但是 canonicalizing map
對於字串以外的物件仍然有用。
在我看來,弱引用的最佳用途是實現 canonicalizing map
,這是一種確保同時只存在一個值物件例項的辦法。 String.intern()
是這種map的典型例子:當你把一個字串存入常量池時,JVM會將它新增到一個特殊的map中,這個map也用於儲存字串文字。這樣做的原因不是像一些人認為的那樣為了更快地進行比較。這是為了最大限度地減少重複的非文字字串(如從檔案或訊息佇列中讀取的字串)佔用的記憶體量。
簡單的 canonicalizing map
通過使用相同的物件作為key和value來工作:你用任意例項傳給map,如果map中已經有一個值,你就返回它。如果map中沒有值,則儲存傳入的例項(並返回它)。當然,這僅適用於可用作map的key的物件。如果我們不擔心記憶體洩漏,下面可能是我們實現 String.intern()
的方式:
private Map<String,String> _map = new HashMap<String,String>(); public synchronized String intern(String str) { if (_map.containsKey(str)) return _map.get(str); _map.put(str, str); return str; }
如果你只有少量字串要放入常量池,例如也許在處理一個檔案的簡單方法中,這個實現沒什麼問題。然而,假設你正在編寫一個長期執行的應用程式,該應用程式必須處理來自多個來源的輸入,其中包含範圍廣泛的字串,但仍有高度的重複。例如,一臺處理上傳的郵政地址資料檔案的服務: New York
將會有很多條目, Temperanceville VA
的條目就不多了。你會想要消除前者的重複,但是不想保留後者超過必要的時間。
這就是弱引用的 canonicalizing map
有所幫助的地方:只有程式中的一些程式碼正在使用它,它才允許你建立一個規範的例項。最後一個強引用消失後,這個規範的字串將被回收。如果稍後再次出現該字串,它將成為新的規範的例項。
為了改進我們的“規範化工具”,我們可以用 WeakHashMap
替換 HashMap
:
private Map<String,WeakReference<String>> _map = new WeakHashMap<String,WeakReference<String>>(); public synchronized String intern(String str) { WeakReference<String> ref = _map.get(str); String s2 = (ref != null) ? ref.get() : null; if (s2 != null) return s2; _map.put(str, new WeakReference(str)); return str; }
首先要注意的是,雖然map的key是字串,但它的值是 WeakReference<String>
。這是因為 WeakHashMap
對其key使用弱引用,但對其value持有強引用。因為我們的key和value是相同的,所以entry永遠不會被回收。通過包裝條目,我們讓GC回收它。
其次,注意返回字串的過程:首先我們檢索弱引用,如果它存在,那麼我們檢索引用物件。但是我們也必須檢查那個物件。存在引用仍在map中但已經被清除了的可能。只有當引用物件不為空時,我們才返回它;否則,我們認為傳入的字串是新的規範的版本。
第三,請注意我對 intern()
方法用了 synchronized
。 canonicalizing map
最有可能的用途是在多執行緒環境中,例如應用服務, WeakHashMap
沒有內部同步。這個例子中的同步實際上相當幼稚, intern()
方法可能成為爭論的焦點。在現實世界的實現中,我可能會使用 ConcurrentHashMap
,但是對於教程來說,這種幼稚的方法更有效。
最後, WeakHashMap
的文件關於條目何時從map中移除有些模糊。它指出,“ WeakHashMap
的行為可能就像一個未知執行緒正在無聲地刪除條目。”實際上沒有其他執行緒。相反,每當map被訪問時,它就會被清理。為了跟蹤哪些條目不再有效,它使用了引用佇列。
引用佇列
雖然判斷一個引用是不是null可以讓你知道它的引用物件是不是已經被回收,但是這樣做並不是很高效;如果你有很多引用,你的程式會花大部分時間尋找那些已經被清除的引用。
更好的解決方案是引用佇列:你在構建時將引用與佇列相關聯,並且該引用將在被清除後放入佇列中。要發現哪些引用已被清除,你需要從佇列拉取。這可以通過後臺執行緒來完成,但是在建立新引用時從佇列拉取通常更簡單( WeakHashMap
就是這樣做的)。
引用佇列最常與虛引用一起使用,在後面會描述,但是可以與任何引用型別一起使用。下面的程式碼是一個弱引用的例子:它建立了一組緩衝區,通過 WeakReference
訪問,並且在每次建立後檢視哪些引用已經被清除。如果執行此程式碼,你會看到 create
訊息的長時間出現,當垃圾回收器執行時偶爾會出現一些 clear
訊息。
public static void main(String[] argv) throws Exception { Set<WeakReference<byte[]>> refs = new HashSet<WeakReference<byte[]>>(); ReferenceQueue<byte[]> queue = new ReferenceQueue<byte[]>(); for (int ii = 0 ; ii < 1000 ; ii++) { WeakReference<byte[]> ref = new WeakReference<byte[]>(new byte[1000000], queue); System.err.println(ii + ": created " + ref); refs.add(ref); Reference<? extends byte[]> r2; while ((r2 = queue.poll()) != null) { System.err.println("cleared " + r2); refs.remove(r2); } } }
一如既往,關於這個程式碼有一些值得注意的事情。首先,雖然我們建立的是 WeakReference
例項,但是佇列會給我們返回 Reference
。這提醒你,一旦它們入隊,你使用的是什麼型別的引用就不再重要了,被引用者已經被清除。
第二,我們必須對引用物件本身進行強引用。引用物件知道佇列,但是佇列在引用進入佇列前不知道引用。如果我們沒有維護對引用物件的強引用,它本身就會被回收,並且永遠不會被新增到佇列中。在這個例子中,我使用了一個 Set
,一旦引用被清除,就刪除它們(將它們留在 Set
中是記憶體洩漏)。
虛引用
虛引用不同於軟引用和弱引用,它們不用於訪問它們的被引用者。相反,他們的唯一目的是當它們的被引用者已經被回收時通知你。雖然這看起來毫無意義,但它實際上允許你比 finalizers
更靈活地執行資源清理。
Finalizers
的問題
ofollow,noindex">這篇文章 中我更詳細地討論了 finalizers
。簡而言之,你應該依靠 try/catch/finally
清理資源,而不是 finalizers
或虛引用。
在物件生命週期的描述中,我提到 finalizers
有一些微妙的問題,使得它們不適合清理非記憶體資源。還有一些非微妙的問題,為了完整起見,我將在這裡討論。
-
finalizer
可能永遠不會被呼叫如果你的程式從未用完可用記憶體,那麼垃圾回收器不會執行,你的
finalizer
也不會執行。對於長時間執行的應用程式(例如服務)來說,通常不會出現這個問題,但是短時間執行的程式可能會在沒有執行垃圾收集的情況下完成。雖然有一種方法可以告訴JVM在程式退出之前執行finalizers
,但這是不可靠的,可能會與其他shutdown hooks
衝突。 -
Finalizers
可能建立一個物件的其他強引用例如,通過將物件新增到集合中。這基本上覆活了這個物件,但是,就像
Stephen King's Pet Sematary
一樣,返回的物件“不太正確”。尤其是,當物件再次符合回收條件時,它的finalizer
不會執行。也許你會使用這種復活技巧是有原因的,但是我無法想象,而且在程式碼上看起來會非常模糊。
現在這些都已經過時了,我相信 finalizers
的真正問題是它們在垃圾回收器首次識別要回收的物件的時間和實際回收其記憶體的時間之間引入了間隙,因為 finalization
發生在它自己的執行緒上,獨立於垃圾回收器的執行緒。JVM保證在返回 OutMemoryError
之前執行一次 full collection
,但是如果所有符合回收條件的物件都有 finalizers
,則回收將不起作用:這些物件保留在記憶體中等待 finalization
。假設一個標準JVM只有一個執行緒來處理所有物件的 finalization
,一些長時間執行的 finalization
,你就可以看到問題可能會出現。
以下程式演示了這種行為:每個物件都有一個 finalizer
休眠半秒鐘。不會有很長時間,除非你有成千上萬的物件要清理。每個物件在建立後都會立即超出作用域,但是在某個時候你會耗盡記憶體(如果你想執行這個例子,我建議使用 -Xmx64m
來使錯誤快速發生;在我的開發機器上,有3Gb堆,實際上需要幾分鐘才能失敗)。
public class SlowFinalizer { public static void main(String[] argv) throws Exception { while (true) { Object foo = new SlowFinalizer(); } } // some member variables to take up space -- approx 200 bytes double a,b,c,d,e,f,g,h,i,j,k,l,m,n,o,p,q,r,s,t,u,v,w,x,y,z; // and the finalizer, which does nothing by take time protected void finalize() throws Throwable { try { Thread.sleep(500L); } catch (InterruptedException ignored) {} super.finalize(); } }
虛引用知曉之事
當物件不再被使用時虛引用允許應用程式知曉,這樣應用程式就可以清理物件的非記憶體資源。然而,與 finalizers
不同的是,當應用程式知道到這一點時,物件本身已經被收集了。
此外,與 finalizers
不同,清理由應用程式而不是垃圾回收器來排程。您可以將一個或多個執行緒專用於清理,如果物件數量需要,可以增加執行緒數量。另一種方法——通常更簡單——是使用物件工廠,並在建立新例項之前清理所有回收的例項。
理解虛引用的關鍵點是,你不能使用引用來訪問物件: get()
總是返回null,即使物件仍然是強可達的。這意味著引用物件持有的不能是要清理的資源的唯一引用。相反,你必須維持對這些資源的至少一個其他強引用,並使用引用佇列來通知被引用者已被回收。與其他引用型別一樣,您的程式也必須擁有對引用物件本身的強引用,否則它將被回收,資源將記憶體洩露。

用虛引用實現連線池
資料庫連線是任何應用程式中最寶貴的資源之一:它們需要時間來建立,並且資料庫伺服器嚴格限制它們將接受的同時開啟的連線的數量。儘管如此,程式設計師對它們非常粗心,有時會為每個查詢開啟一個新的連線,或者忘記關閉它,或者不在 finally
塊中關閉它。
大多數應用服務部署使用連線池,而不是允許應用直接連線資料庫:連線池維護一組開啟的連線(通常是固定的),並根據需要將它們交給程式。用於生產環境的連線池提供了幾種防止連線洩漏的方法,包括超時(識別執行時間過長的查詢)和恢復被垃圾回收器回收的連線。
下面這個連線池旨在演示虛引用,不能用於生產環境。Java有幾個可用於生產環境的連線池,如 Apache Commons DBCP
和 C3P0
。
後一個特性是虛引用的一個很好的例子。為了使它工作,連線池提供的 Connection
物件只是實際資料庫連線的包裝,可以在不丟失資料庫連線的情況下回收它們,因為連線池保持對實際連線的強引用。連線池將虛引用與“包裝成的”連線相關聯,如果引用最終出現在引用佇列中,則會將實際連線返回給連線池。
連線池中最不有趣的部分是 PooledConnection
,如下所示。正如我說過的,它是一個包裝,委派對實際連線的呼叫。不同的是我使用了反射代理來實現。JDBC介面隨著Java的每一個版本而發展,其方式既不向前也不向後相容;如果我使用了具體的實現,除非你使用了與我相同的JDK版本,否則你將無法編譯。反射代理解決了這個問題,也使程式碼變得更短。
public class PooledConnection implements InvocationHandler { private ConnectionPool _pool; private Connection _cxt; public PooledConnection(ConnectionPool pool, Connection cxt) { _pool = pool; _cxt = cxt; } private Connection getConnection() { try { if ((_cxt == null) || _cxt.isClosed()) throw new RuntimeException("Connection is closed"); } catch (SQLException ex) { throw new RuntimeException("unable to determine if underlying connection is open", ex); } return _cxt; } public static Connection newInstance(ConnectionPool pool, Connection cxt) { return (Connection)Proxy.newProxyInstance( PooledConnection.class.getClassLoader(), new Class[] { Connection.class }, new PooledConnection(pool, cxt)); } @Override public Object invoke(Object proxy, Method method, Object[] args) throws Throwable { // if calling close() or isClosed(), invoke our implementation // otherwise, invoke the passed method on the delegate } private void close() throws SQLException { if (_cxt != null) { _pool.releaseConnection(_cxt); _cxt = null; } } private boolean isClosed() throws SQLException { return (_cxt == null) || (_cxt.isClosed()); } }
需要注意的最重要的一點是, PooledConnection
同時引用了底層資料庫連線和連線池。後者用於那些確實記得關閉連線的應用程式:我們希望立即告知連線池,以便底層連線可以立即被重用。
getConnection()
方法也值得一提:它的存在是為了捕捉那些在顯式關閉連線後試圖使用該連線的應用程式。如果連線已經交給另一個消費者,這可能是一件非常糟糕的事情。因此 close()
顯式清除引用, getConnection()
會檢查該引用,並在連線不再有效時丟擲異常。 invocation handler
用於所有委託呼叫。
現在讓我們將注意力轉向連線池本身,從它用來管理連線的物件開始。
private Queue<Connection> _pool = new LinkedList<Connection>(); private ReferenceQueue<Object> _refQueue = new ReferenceQueue<Object>(); private IdentityHashMap<Object,Connection> _ref2Cxt = new IdentityHashMap<Object,Connection>(); private IdentityHashMap<Connection,Object> _cxt2Ref = new IdentityHashMap<Connection,Object>();
當連線池被構造並存儲在 _pool
中時,可用連線被初始化。我們使用引用佇列 _refQueue
來標識已回收的連線。最後,我們有連線和引用之間的雙向對映,在將連線返回到連線池時使用。
正如我之前說過的,實際的資料庫連線將在提交給應用程式程式碼之前被包裝在 PooledConnection
中。這發生在 wrapConnection()
函式中,也是我們建立虛引用和連線-引用對映的地方。
private synchronized Connection wrapConnection(Connection cxt) { Connection wrapped = PooledConnection.newInstance(this, cxt); PhantomReference<Connection> ref = new PhantomReference<Connection>(wrapped, _refQueue); _cxt2Ref.put(cxt, ref); _ref2Cxt.put(ref, cxt); System.err.println("Acquired connection " + cxt ); return wrapped; }
與 wrapConnection
對應的是 releaseConnection()
,該函式有兩種變體。當應用程式程式碼顯式關閉連線時, PooledConnection
呼叫第一個,這是“快樂的道路”,它將連線放回連線池中供以後使用。它還會清除連線和引用之間的對映,因為它們不再需要。請注意,此方法具有預設(包)同步:它由 PooledConnection
呼叫,因此不能是私有的,但通常不可訪問。
synchronized void releaseConnection(Connection cxt) { Object ref = _cxt2Ref.remove(cxt); _ref2Cxt.remove(ref); _pool.offer(cxt); System.err.println("Released connection " + cxt); }
另一個變體使用虛引用來呼叫,這是“可悲的道路”,當應用程式不記得關閉連線時才會呼叫。在這種情況下,我們得到的只是虛引用,我們需要使用對映來檢索實際連線(然後使用第一個變體將其返回到連線池中)。
private synchronized void releaseConnection(Reference<?> ref) { Connection cxt = _ref2Cxt.remove(ref); if (cxt != null) releaseConnection(cxt); }
有一種邊緣情況:如果引用在應用程式呼叫 close()
之後進入佇列,會發生什麼?這種情況不太可能發生:當我們清除對映時,虛引用應該已經有資格被回收,這樣它就不會進入佇列。然而,我們必須考慮這種情況,這導致上面的空檢查:如果對映已經被移除,那麼連線已經被顯式返回,我們不需要做任何事情。
好了,您已經看到了底層程式碼,現在是時候讓應用程式呼叫唯一的方法了:
public Connection getConnection() throws SQLException { while (true) { synchronized (this) { if (_pool.size() > 0) return wrapConnection(_pool.remove()); } tryWaitingForGarbageCollector(); } }
getConnection()
的最佳路徑是在 _pool
中有可用的連線。在這種情況下,一個連線被移除、包裝並返回給呼叫者。不信的情況是沒有任何連線,在這種情況下,呼叫者希望我們阻塞直到有一個連線可用。這可以通過兩種方式發生:要麼應用程式關閉連線並返回到 _pool
中,要麼垃圾回收器找到一個已被放棄的連線,並將其關聯的虛引用加入佇列。
為什麼我使用 synchronized(this)
而不是顯式鎖?簡而言之,這個實現是作為教學輔助工具,我想用最少的樣板來強調同步點。在生產環境使用的連線池中,我實際上會避免顯式同步,而是依賴並行資料結構,如 ArrayBlockingQueue
和 ConcurrentHashMap
。
在走這條路之前,我想談談同步。顯然,對內部資料結構的所有訪問都必須同步,因為多個執行緒可能會嘗試同時獲取或返回連線。只要 _pool
中有連線,同步程式碼就能快速執行,競爭的可能性就很低。然而,如果我們必須迴圈直到連線變得可用,我們希望最大限度地減少同步的時間:我們不希望在請求連線的呼叫者和返回連線的另一個呼叫者之間造成死鎖。因此,在檢查連線時,使用顯式同步塊。
那麼,如果我們呼叫 getConnection()
,並且池是空的,會發生什麼呢?這是我們檢查引用佇列以找到被廢棄的連線的時機。
private void tryWaitingForGarbageCollector() { try { Reference<?> ref = _refQueue.remove(100); if (ref != null) releaseConnection(ref); } catch (InterruptedException ignored) { // we have to catch this exception, but it provides no information here // a production-quality pool might use it as part of an orderly shutdown } }
這個函式強調了另一組相互衝突的目標:如果引用佇列中沒有任何引用,我們不想浪費時間,但是我們也不想在一個迴圈中重複檢查 _pool
和 _refQueue
。所以我在輪詢佇列時使用了一個短暫的超時時間:如果沒有準備好,它會給另一個執行緒返回連線的機會。當然,這也帶來了一個公平性問題:當一個執行緒正在等待引用佇列時,另一個執行緒可能會返回一個被第三個執行緒立即佔用的連線。理論上,等待執行緒可能會永遠等待。在現實世界中,由於不太需要資料庫連線,這種情況不太可能發生。
虛引用帶來的問題
前面我提到到 finalizers
不能保證被呼叫。虛引用也是這樣,原因相同:如果回收器不執行,不可達的物件不會被回收,對這些物件的引用也不會進入佇列。考慮一個程式只在迴圈中呼叫 getConnection()
,讓返回的連線超出作用域,如果它沒有做任何其他事情來讓垃圾回收器執行,那麼它會很快耗盡連線池然後阻塞,等待永遠無法恢復的連線。
當然,有辦法解決這個問題。最簡單的方法之一是在 tryWaitingForGarbageCollector()
中呼叫 System.gc()
。儘管圍繞這種方法有一些爭議,但這是促使JVM回到理想狀態的有效方式。這是一種既適用於 finalizers
也適用於虛引用的技術。
這並不意味著你應該忽略虛引用,只使用 finalizer
。例如,在連線池的情況下,你可能希望顯式關閉該連線池並關閉所有底層連線。你可以用 finalizer
來完成,但是需要和虛引用一樣多的工作。在這種情況下,通過引用獲得的可控因素(相對於任意終結執行緒)使它們成為更好的選擇。
最後一些思考:有時候你只需要更多記憶體
雖然引用物件是管理記憶體消耗的非常有用的工具,但有時它們是不夠的,有時又是過度的。例如,假設你正在構建一些大型物件,其中包含從資料庫中讀取的資料。雖然你可以使用軟引用作為讀取的斷路器,並使用弱引用將資料規範化,但最終您的程式需要一定量的記憶體來執行。如果你不能給它足夠的記憶體來實際完成任何工作,那麼不管你的錯誤恢復能力有多強都無濟於事。
應對 OutOfMemoryError
時你首先應該搞清楚它為什麼會發生。可能你有記憶體洩露,可能僅僅是你記憶體的設定太低了。
開發過程中,你應該指定大的堆記憶體大小——1G或更多——關注程式到底用了多少記憶體(這種情況jconsole是一個有用的工具)。大多數應用會在模擬的負載下達到一個穩定的狀態,這將指引你的生產環境堆配置。如果你的記憶體使用隨時間增長,那很可能你在物件不再使用後仍持有強引用,引用型別可能會有用,但更可能的是有bug需要修復。
底線是你需要理解你的應用。如果沒有重複, canonicalizing map
對你沒有幫助。如果你希望定期執行數百萬行查詢,軟引用是沒有用的。但是在可以使用引用物件的情況下,它們會是你的救命恩人。