在 Java 語言中,除了原始數據類型的變量,其他所有都是所謂的引用類型,指向各種不同的對象,理解引用對于掌握 Java 對象生命周期和 JVM 內部相關機制非常有幫助。
今天我要問你的問題是,強引用、軟引用、弱引用、幻象引用有什么區別?具體使用場景是什么?
## 典型回答
不同的引用類型,主要體現的是**對象不同的可達性(reachable)狀態和對垃圾收集的影響**。
所謂強引用(“Strong” Reference),就是我們最常見的普通對象引用,只要還有強引用指向一個對象,就能表明對象還“活著”,垃圾收集器不會碰這種對象。對于一個普通的對象,如果沒有其他的引用關系,只要超過了引用的作用域或者顯式地將相應(強)引用賦值為 null,就是可以被垃圾收集的了,當然具體回收時機還是要看垃圾收集策略。
軟引用(SoftReference),是一種相對強引用弱化一些的引用,可以讓對象豁免一些垃圾收集,只有當 JVM 認為內存不足時,才會去試圖回收軟引用指向的對象。JVM 會確保在拋出 OutOfMemoryError 之前,清理軟引用指向的對象。軟引用通常用來實現內存敏感的緩存,如果還有空閑內存,就可以暫時保留緩存,當內存不足時清理掉,這樣就保證了使用緩存的同時,不會耗盡內存。
弱引用(WeakReference)并不能使對象豁免垃圾收集,僅僅是提供一種訪問在弱引用狀態下對象的途徑。這就可以用來構建一種沒有特定約束的關系,比如,維護一種非強制性的映射關系,如果試圖獲取時對象還在,就使用它,否則重現實例化。它同樣是很多緩存實現的選擇。
對于幻象引用,有時候也翻譯成虛引用,你不能通過它訪問對象。幻象引用僅僅是提供了一種確保對象被 finalize 以后,做某些事情的機制,比如,通常用來做所謂的 Post-Mortem 清理機制,我在專欄上一講中介紹的 Java 平臺自身 Cleaner 機制等,也有人利用幻象引用監控對象的創建和銷毀。
## 考點分析
這道面試題,屬于既偏門又非常高頻的一道題目。說它偏門,是因為在大多數應用開發中,很少直接操作各種不同引用,雖然我們使用的類庫、框架可能利用了其機制。它被頻繁問到,是因為這是一個綜合性的題目,既考察了我們對基礎概念的理解,也考察了對底層對象生命周期、垃圾收集機制等的掌握。
充分理解這些引用,對于我們設計可靠的緩存等框架,或者診斷應用 OOM 等問題,會很有幫助。比如,診斷 MySQL connector-j 驅動在特定模式下(useCompression=true)的內存泄漏問題,就需要我們理解怎么排查幻象引用的堆積問題。
## 知識擴展
1\. 對象可達性狀態流轉分析
首先,請你看下面流程圖,我這里簡單總結了對象生命周期和不同可達性狀態,以及不同狀態可能的改變關系,可能未必 100% 嚴謹,來闡述下可達性的變化。

我來解釋一下上圖的具體狀態,這是 Java 定義的不同可達性級別(reachability level),具體如下:
* 強可達(Strongly Reachable),就是當一個對象可以有一個或多個線程可以不通過各種引用訪問到的情況。比如,我們新創建一個對象,那么創建它的線程對它就是強可達。
* 軟可達(Softly Reachable),就是當我們只能通過軟引用才能訪問到對象的狀態。
* 弱可達(Weakly Reachable),類似前面提到的,就是無法通過強引用或者軟引用訪問,只能通過弱引用訪問時的狀態。這是十分臨近 finalize 狀態的時機,當弱引用被清除的時候,就符合 finalize 的條件了。
* 幻象可達(Phantom Reachable),上面流程圖已經很直觀了,就是沒有強、軟、弱引用關聯,并且 finalize 過了,只有幻象引用指向這個對象的時候。
* 當然,還有一個最后的狀態,就是不可達(unreachable),意味著對象可以被清除了。
判斷對象可達性,是 JVM 垃圾收集器決定如何處理對象的一部分考慮。
所有引用類型,都是抽象類 java.lang.ref.Reference 的子類,你可能注意到它提供了 get() 方法:

除了幻象引用(因為 get 永遠返回 null),如果對象還沒有被銷毀,都可以通過 get 方法獲取原有對象。這意味著,利用軟引用和弱引用,我們可以將訪問到的對象,重新指向強引用,也就是人為的改變了對象的可達性狀態!這也是為什么我在上面圖里有些地方畫了雙向箭頭。
所以,對于軟引用、弱引用之類,垃圾收集器可能會存在二次確認的問題,以保證處于弱引用狀態的對象,沒有改變為強引用。
但是,你覺得這里有沒有可能出現什么問題呢?
不錯,如果我們錯誤的保持了強引用(比如,賦值給了 static 變量),那么對象可能就沒有機會變回類似弱引用的可達性狀態了,就會產生內存泄漏。所以,檢查弱引用指向對象是否被垃圾收集,也是診斷是否有特定內存泄漏的一個思路,如果我們的框架使用到弱引用又懷疑有內存泄漏,就可以從這個角度檢查。
2\. 引用隊列(ReferenceQueue)使用
談到各種引用的編程,就必然要提到引用隊列。我們在創建各種引用并關聯到響應對象時,可以選擇是否需要關聯引用隊列,JVM 會在特定時機將引用 enqueue 到隊列里,我們可以從隊列里獲取引用(remove 方法在這里實際是有獲取的意思)進行相關后續邏輯。尤其是幻象引用,get 方法只返回 null,如果再不指定引用隊列,基本就沒有意義了。看看下面的示例代碼。利用引用隊列,我們可以在對象處于相應狀態時(對于幻象引用,就是前面說的被 finalize 了,處于幻象可達狀態),執行后期處理邏輯。
~~~
Object counter = new Object();
ReferenceQueue refQueue = new ReferenceQueue<>();
PhantomReference<Object> p = new PhantomReference<>(counter, refQueue);
counter = null;
System.gc();
try {
// Remove 是一個阻塞方法,可以指定 timeout,或者選擇一直阻塞
Reference<Object> ref = refQueue.remove(1000L);
if (ref != null) {
// do something
}
} catch (InterruptedException e) {
// Handle it
}
~~~
3\. 顯式地影響軟引用垃圾收集
前面泛泛提到了引用對垃圾收集的影響,尤其是軟引用,到底 JVM 內部是怎么處理它的,其實并不是非常明確。那么我們能不能使用什么方法來影響軟引用的垃圾收集呢?
答案是有的。軟引用通常會在最后一次引用后,還能保持一段時間,默認值是根據堆剩余空間計算的(以 M bytes 為單位)。從 Java 1.3.1 開始,提供了 -XX:SoftRefLRUPolicyMSPerMB 參數,我們可以以毫秒(milliseconds)為單位設置。比如,下面這個示例就是設置為 3 秒(3000 毫秒)。
~~~
-XX:SoftRefLRUPolicyMSPerMB=3000
~~~
這個剩余空間,其實會受不同 JVM 模式影響,對于 Client 模式,比如通常的 Windows 32 bit JDK,剩余空間是計算當前堆里空閑的大小,所以更加傾向于回收;而對于 server 模式 JVM,則是根據 -Xmx 指定的最大值來計算。
本質上,這個行為還是個黑盒,取決于 JVM 實現,即使是上面提到的參數,在新版的 JDK 上也未必有效,另外 Client 模式的 JDK 已經逐步退出歷史舞臺。所以在我們應用時,可以參考類似設置,但不要過于依賴它。
4\. 診斷 JVM 引用情況
如果你懷疑應用存在引用(或 finalize)導致的回收問題,可以有很多工具或者選項可供選擇,比如 HotSpot JVM 自身便提供了明確的選項(PrintReferenceGC)去獲取相關信息,我指定了下面選項去使用 JDK 8 運行一個樣例應用:
~~~
-XX:+PrintGCDetails -XX:+PrintGCTimeStamps -XX:+PrintReferenceGC
~~~
這是 JDK 8 使用 ParrallelGC 收集的垃圾收集日志,各種引用數量非常清晰。
~~~
0.403: [GC (Allocation Failure) 0.871: [SoftReference, 0 refs, 0.0000393 secs]0.871: [WeakReference, 8 refs, 0.0000138 secs]0.871: [FinalReference, 4 refs, 0.0000094 secs]0.871: [PhantomReference, 0 refs, 0 refs, 0.0000085 secs]0.871: [JNI Weak Reference, 0.0000071 secs][PSYoungGen: 76272K->10720K(141824K)] 128286K->128422K(316928K), 0.4683919 secs] [Times: user=1.17 sys=0.03, real=0.47 secs]
~~~
**注意:JDK 9 對 JVM 和垃圾收集日志進行了廣泛的重構**,類似 PrintGCTimeStamps 和 PrintReferenceGC 已經不再存在,我在專欄后面的垃圾收集主題里會更加系統的闡述。
5.Reachability Fence
除了我前面介紹的幾種基本引用類型,我們也可以通過底層 API 來達到強引用的效果,這就是所謂的設置**reachability fence**。
為什么需要這種機制呢?考慮一下這樣的場景,按照 Java 語言規范,如果一個對象沒有指向強引用,就符合垃圾收集的標準,有些時候,對象本身并沒有強引用,但是也許它的部分屬性還在被使用,這樣就導致詭異的問題,所以我們需要一個方法,在沒有強引用情況下,通知 JVM 對象是在被使用的。說起來有點繞,我們來看看 Java 9 中提供的案例。
~~~
class Resource {
private static ExternalResource[] externalResourceArray = ...
int myIndex; Resource(...) {
myIndex = ...
externalResourceArray[myIndex] = ...;
...
}
protected void finalize() {
externalResourceArray[myIndex] = null;
...
}
public void action() {
try {
// 需要被保護的代碼
int i = myIndex;
Resource.update(externalResourceArray[i]);
} finally {
// 調用 reachbilityFence,明確保障對象 strongly reachable
Reference.reachabilityFence(this);
}
}
private static void update(ExternalResource ext) {
ext.status = ...;
}
}
~~~
方法 action 的執行,依賴于對象的部分屬性,所以被特定保護了起來。否則,如果我們在代碼中像下面這樣調用,那么就可能會出現困擾,因為沒有強引用指向我們創建出來的 Resource 對象,JVM 對它進行 finalize 操作是完全合法的。
~~~
new Resource().action()
~~~
類似的書寫結構,在異步編程中似乎是很普遍的,因為異步編程中往往不會用傳統的“執行 -> 返回 -> 使用”的結構。
在 Java 9 之前,實現類似功能相對比較繁瑣,有的時候需要采取一些比較隱晦的小技巧。幸好,java.lang.ref.Reference 給我們提供了新方法,它是 JEP 193: Variable Handles 的一部分,將 Java 平臺底層的一些能力暴露出來:
~~~
static void reachabilityFence(Object ref)
~~~
在 JDK 源碼中,reachabilityFence 大多使用在 Executors 或者類似新的 HTTP/2 客戶端代碼中,大部分都是異步調用的情況。編程中,可以按照上面這個例子,將需要 reachability 保障的代碼段利用 try-finally 包圍起來,在 finally 里明確聲明對象強可達。
今天,我總結了 Java 語言提供的幾種引用類型、相應可達狀態以及對于 JVM 工作的意義,并分析了引用隊列使用的一些實際情況,最后介紹了在新的編程模式下,如何利用 API 去保障對象不被意外回收,希望對你有所幫助。
## 一課一練
關于今天我們討論的題目你做到心中有數了嗎?給你留一道練習題,你能從自己的產品或者第三方類庫中找到使用各種引用的案例嗎?它們都試圖解決什么問題?
- 前言
- 開篇詞
- 開篇詞 -以面試題為切入點,有效提升你的Java內功
- 模塊一 Java基礎
- 第1講 談談你對Java平臺的理解?
- 第2講 Exception和Error有什么區別?
- 第3講 談談final、finally、 finalize有什么不同?
- 第4講 強引用、軟引用、弱引用、幻象引用有什么區別?
- 第5講 String、StringBuffer、StringBuilder有什么區別?
- 第6講 動態代理是基于什么原理?
- 第7講 int和Integer有什么區別?
- 第8講 對比Vector、ArrayList、LinkedList有何區別?
- 第9講 對比Hashtable、HashMap、TreeMap有什么不同?
- 第10講 如何保證集合是線程安全的? ConcurrentHashMap如何實現高效地線程安全?
- 第11講 Java提供了哪些IO方式? NIO如何實現多路復用?
- 第12講 Java有幾種文件拷貝方式?哪一種最高效?
- 第13講 談談接口和抽象類有什么區別?
- 第14講 談談你知道的設計模式?
- 模塊二 Java進階
- 第15講 synchronized和ReentrantLock有什么區別呢?
- 第16講 synchronized底層如何實現?什么是鎖的升級、降級?
- 第17講 一個線程兩次調用start()方法會出現什么情況?
- 第18講 什么情況下Java程序會產生死鎖?如何定位、修復?
- 第19講 Java并發包提供了哪些并發工具類?
- 第20講 并發包中的ConcurrentLinkedQueue和LinkedBlockingQueue有什么區別?
- 第21講 Java并發類庫提供的線程池有哪幾種? 分別有什么特點?
- 第22講 AtomicInteger底層實現原理是什么?如何在自己的產品代碼中應用CAS操作?
- 第23講 請介紹類加載過程,什么是雙親委派模型?
- 第24講 有哪些方法可以在運行時動態生成一個Java類?
- 第25講 談談JVM內存區域的劃分,哪些區域可能發生OutOfMemoryError?
- 第26講 如何監控和診斷JVM堆內和堆外內存使用?
- 第27講 Java常見的垃圾收集器有哪些?
- 第28講 談談你的GC調優思路?
- 第29講 Java內存模型中的happen-before是什么?
- 第30講 Java程序運行在Docker等容器環境有哪些新問題?
- 模塊三 Java安全基礎
- 第31講 你了解Java應用開發中的注入攻擊嗎?
- 第32講 如何寫出安全的Java代碼?
- 模塊四 Java性能基礎
- 第33講 后臺服務出現明顯“變慢”,談談你的診斷思路?
- 第34講 有人說“Lambda能讓Java程序慢30倍”,你怎么看?
- 第35講 JVM優化Java代碼時都做了什么?
- 模塊五 Java應用開發擴展
- 第36講 談談MySQL支持的事務隔離級別,以及悲觀鎖和樂觀鎖的原理和應用場景?
- 第37講 談談Spring Bean的生命周期和作用域?
- 第38講 對比Java標準NIO類庫,你知道Netty是如何實現更高性能的嗎?
- 第39講 談談常用的分布式ID的設計方案?Snowflake是否受冬令時切換影響?
- 周末福利
- 周末福利 談談我對Java學習和面試的看法
- 周末福利 一份Java工程師必讀書單
- 結束語
- 結束語 技術沒有終點
- 結課測試 Java核心技術的這些知識,你真的掌握了嗎?