二 垃圾回收:第05講:大廠面試題:得心應手應對 OOM 的疑難雜症

在前面幾個課時中,我們不止一次提到了堆(heap),堆是一個巨大的對象池。在這個對象池中管理着數量巨大的對象實例。

而池中對象的引用層次,有的是很深的。一個被頻繁調用的接口,每秒生成對象的速度,也是非常可觀的。對象之間的關係,形成了一張巨大的網。雖然 Java 一直在營造一種無限內存的氛圍,但對象不能只增不減,所以需要垃圾回收。

那 JVM 是如何判斷哪些對象應該被回收?哪些應該被保持呢?

在古代,刑罰中有誅九族一說。指的是有些人犯大事時,皇上殺一人不足以平復內心的憤怒時,會對親朋好友產生連帶責任。誅九族時首先需要追溯到一個共同的祖先,再往下細數連坐。堆上的垃圾回收也有同樣的思路。我們接下來就具體分析 JVM 中是如何進行垃圾回收的。

JVM 的 GC 動作,是不受程序控制的,它會在滿足條件的時候,自動觸發。

在發生 GC 的時候,一個對象,JVM 總能夠找到引用它的祖先。找到最後,如果發現這個祖先已經名存實亡了,它們都會被清理掉。而能夠躲過垃圾回收的那些祖先,比較特殊,它們的名字就叫作 GC Roots。

從 GC Roots 向下追溯、搜索,會產生一個叫作 Reference Chain 的鏈條。當一個對象不能和任何一個 GC Root 產生關係時,就會被無情的誅殺掉。

如圖所示,Obj5、Obj6、Obj7,由於不能和 GC Root 產生關聯,發生 GC 時,就會被摧毀。

垃圾回收就是圍繞着 GC Roots 去做的。同時,它也是很多內存泄露的根源,因爲其他引用根本沒有這樣的權利。

那麼,什麼樣的對象,纔會是 GC Root 呢?這不在於它是什麼樣的對象,而在於它所處的位置。

GC Roots 有哪些

GC Roots 是一組必須活躍的引用。用通俗的話來說,就是程序接下來通過直接引用或者間接引用,能夠訪問到的潛在被使用的對象。

GC Roots 包括:

  • Java 線程中,當前所有正在被調用的方法的引用類型參數、局部變量、臨時值等。也就是與我們棧幀相關的各種引用。
  • 所有當前被加載的 Java 類。
  • Java 類的引用類型靜態變量。
  • 運行時常量池裏的引用類型常量(String 或 Class 類型)。
  • JVM 內部數據結構的一些引用,比如 sun.jvm.hotspot.memory.Universe 類。
  • 用於同步的監控對象,比如調用了對象的 wait() 方法。
  • JNI handles,包括 global handles 和 local handles。

這些 GC Roots 大體可以分爲三大類,下面這種說法更加好記一些:

  • 活動線程相關的各種引用。
  • 類的靜態變量的引用。
  • JNI 引用。

有兩個注意點:

  • 我們這裏說的是活躍的引用,而不是對象,對象是不能作爲 GC Roots 的。
  • GC 過程是找出所有活對象,並把其餘空間認定爲“無用”;而不是找出所有死掉的對象,並回收它們佔用的空間。所以,哪怕 JVM 的堆非常的大,基於 tracing 的 GC 方式,回收速度也會非常快。

引用級別

接下來的一道面試題就有意思多了:能夠找到 Reference Chain 的對象,就一定會存活麼?

我在面試的時候,經常會問這些問題,比如“弱引用有什麼用處”?令我感到奇怪的是,即使是一些工作多年的 Java 工程師,對待這個問題也是一知半解,錯失了很多機會。

對象對於另外一個對象的引用,要看關係牢靠不牢靠,可能在鏈條的其中一環,就斷掉了。

根據發生 GC 時,這條鏈條的表現,可以對這個引用關係進行更加細緻的劃分。

它們的關係,可以分爲強引用、軟引用、弱引用、虛引用等。

強引用 Strong references

當內存空間不足,系統撐不住了,JVM 就會拋出 OutOfMemoryError 錯誤。即使程序會異常終止,這種對象也不會被回收。這種引用屬於最普通最強硬的一種存在,只有在和 GC Roots 斷絕關係時,纔會被消滅掉。

這種引用,你每天的編碼都在用。例如:new 一個普通的對象。

Object obj = new Object()

這種方式可能是有問題的。假如你的系統被大量用戶(User)訪問,你需要記錄這個 User 訪問的時間。可惜的是,User 對象裏並沒有這個字段,所以我們決定將這些信息額外開闢一個空間進行存放。

static Map<User,Long> userVisitMap = new HashMap<>();
...
userVisitMap.put(user, time);

當你用完了 User 對象,其實你是期望它被回收掉的。但是,由於它被 userVisitMap 引用,我們沒有其他手段 remove 掉它。這個時候,就發生了內存泄漏(memory leak)。

這種情況還通常發生在一個沒有設定上限的 Cache 系統,由於設置了不正確的引用方式,加上不正確的容量,很容易造成 OOM。

軟引用 Soft references

軟引用用於維護一些可有可無的對象。在內存足夠的時候,軟引用對象不會被回收,只有在內存不足時,系統則會回收軟引用對象,如果回收了軟引用對象之後仍然沒有足夠的內存,纔會拋出內存溢出異常。

可以看到,這種特性非常適合用在緩存技術上。比如網頁緩存、圖片緩存等。

Guava 的 CacheBuilder,就提供了軟引用和弱引用的設置方式。在這種場景中,軟引用比強引用安全的多。

軟引用可以和一個引用隊列(ReferenceQueue)聯合使用,如果軟引用所引用的對象被垃圾回收,Java 虛擬機就會把這個軟引用加入到與之關聯的引用隊列中。

我們可以看一下它的代碼。軟引用需要顯式的聲明,使用泛型來實現。

// 僞代碼
Object object = new Object();
SoftReference<Object> softRef = new SoftReference(object);

這裏有一個相關的 JVM 參數。它的意思是:每 MB 堆空閒空間中 SoftReference 的存活時間。這個值的默認時間是1秒(1000)。

-XX:SoftRefLRUPolicyMSPerMB=<N>

這裏要特別說明的是,網絡上一些流傳的優化方法,即把這個值設置成 0,其實是錯誤的,這樣容易引發故障,感興趣的話你可以自行搜索一下。

這種比較偏門的優化手段,除非在你對其原理相當瞭解的情況下,才能設置一些比較特殊的值。比如 0 值,無限大等,這種值在 JVM 的設置中,最好不要發生。

弱引用 Weak references

弱引用對象相比較軟引用,要更加無用一些,它擁有更短的生命週期。

當 JVM 進行垃圾回收時,無論內存是否充足,都會回收被弱引用關聯的對象。弱引用擁有更短的生命週期,在 Java 中,用 java.lang.ref.WeakReference 類來表示。

它的應用場景和軟引用類似,可以在一些對內存更加敏感的系統裏採用。它的使用方式類似於這段的代碼:

// 僞代碼
Object object = new Object();
WeakReference<Object> softRef = new WeakReference(object);

虛引用 Phantom References

這是一種形同虛設的引用,在現實場景中用的不是很多。虛引用必須和引用隊列(ReferenceQueue)聯合使用。如果一個對象僅持有虛引用,那麼它就和沒有任何引用一樣,在任何時候都可能被垃圾回收。

實際上,虛引用的 get,總是返回 null。

Object  object = new Object();
ReferenceQueue queue = new ReferenceQueue();
// 虛引用,必須與一個引用隊列關聯
PhantomReference pr = new PhantomReference(object, queue);

虛引用主要用來跟蹤對象被垃圾回收的活動。

當垃圾回收器準備回收一個對象時,如果發現它還有虛引用,就會在回收對象之前,把這個虛引用加入到與之關聯的引用隊列中。

程序如果發現某個虛引用已經被加入到引用隊列,那麼就可以在所引用的對象的內存被回收之前採取必要的行動。

下面的方法,就是一個用於監控 GC 發生的例子。

private static void startMonitoring(ReferenceQueue<MyObject> referenceQueue, 
                           Reference<MyObject> ref) {
    
     ExecutorService ex = Executors.newSingleThreadExecutor();
     ex.execute(() -> {
         while (referenceQueue.poll()!=ref) {
             //don't hang forever
             if(finishFlag){
                 break;
            }
        }
        System.out.println("-- ref gc'ed --");

    });
    ex.shutdown();
}

基於虛引用,有一個更加優雅的實現方式,那就是 Java 9 以後新加入的 Cleaner,用來替代 Object 類的 finalizer 方法。

典型 OOM 場景

OOM 的全稱是 Out Of Memory,那我們的內存區域有哪些會發生 OOM 呢?我們可以從內存區域劃分圖上,看一下彩色部分。

可以看到除了程序計數器,其他區域都有OOM溢出的可能。但是最常見的還是發生在堆上。

所以 OOM 到底是什麼引起的呢?有幾個原因:

  • 內存的容量太小了,需要擴容,或者需要調整堆的空間。
  • 錯誤的引用方式,發生了內存泄漏。沒有及時的切斷與 GC Roots 的關係。比如線程池裏的線程,在複用的情況下忘記清理 ThreadLocal 的內容。
  • 接口沒有進行範圍校驗,外部傳參超出範圍。比如數據庫查詢時的每頁條數等。
  • 對堆外內存無限制的使用。這種情況一旦發生更加嚴重,會造成操作系統內存耗盡。

典型的內存泄漏場景,原因在於對象沒有及時的釋放自己的引用。比如一個局部變量,被外部的靜態集合引用。

你在平常寫代碼時,一定要注意這種情況,千萬不要爲了方便把對象到處引用。即使引用了,也要在合適時機進行手動清理。關於這部分的問題根源排查,我們將在實踐課程中詳細介紹。

小結

你可以注意到 GC Roots 的專業叫法,就是可達性分析法。另外,還有一種叫作引用計數法的方式,在判斷對象的存活問題上,經常被提及。

因爲有循環依賴的硬傷,現在主流的 JVM,沒有一個是採用引用計數法來實現 GC 的,所以我們大體瞭解一下就可以。引用計數法是在對象頭裏維護一個 counter 計數器,被引用一次數量 +1,引用失效記數 -1。計數器爲 0 時,就被認爲無效。你現在可以忘掉引用計數的方式了。

本課時,我們詳細介紹了 GC Roots 都包含哪些內容。HostSpot 採用 tracing 的方式進行 GC,內存回收的速度與處於 living 狀態的對象數量有關。

這部分涉及的內容較多,如果面試被問到,你可以採用白話版的方式進行介紹,然後舉例深入。

接下來,我們瞭解到四種不同強度的引用類型,尤其是軟引用和虛引用,在平常工作中使用還是比較多的。這裏面最不常用的就是虛引用,但是它引申出來的 Cleaner 類,是用來替代 finalizer 方法的,這是一個比較重要的知識點。

本課時最後討論了幾種典型的 OOM 場景,你可能現在對其概念比較模糊。接下來的課時,我們將詳細介紹幾個常見的垃圾回收算法,然後對這些 OOM 的場景逐個擊破。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章