垃圾回收_下

_吹雪_發表於2018-10-15

前言

在讀博士的時候,我曾經寫過一個統計 Java 物件生命週期的動態分析,並且用它來跑了一些基準測試。

其中一些程式的結果,恰好驗證了許多研究人員的假設,即大部分的 Java 物件只存活一小段時間,而存活下來的小部分 Java 物件則會存活很長一段時間。

之所以要提到這個假設,是因為它造就了Java虛擬機器的分代回收思想。簡單來說,就是將堆空間劃分為兩代,分別叫做新生代和老年代。新生代用來儲存新建的物件。當物件存活時間夠長時,則將其移動到老年代。

Java 虛擬機器可以給不同代使用不同的回收演算法。對於新生代,我們猜測大部分的 Java 物件只存活一小段時間,那麼便可以頻繁地採用耗時較短的垃圾回收演算法,讓大部分的垃圾都能夠在新生代被回收掉。

對於老年代,我們猜測大部分的垃圾已經在新生代中被回收了,而在老年代中的物件有大概率會繼續存活。當真正觸發針對老年代的回收時,則代表這個假設出錯了,或者堆的空間已經耗盡了。

這時候,Java 虛擬機器往往需要做一次全堆掃描,耗時也將不計成本。(當然,現代的垃圾回收器都在併發收集的道路上發展,來避免這種全堆掃描的情況。)

今天這一篇我們來關注一下針對新生代的 Minor GC。首先,我們來看看 Java 虛擬機器中的堆具體是怎麼劃分的。

Java 虛擬機器的堆劃分

前面提到,Java 虛擬機器將堆劃分為新生代和老年代。其中,新生代又被劃分為Eden區,以及兩個大小相同的Survivor區。

預設情況下,Java 虛擬機器採取的是一種動態分配的策略(對應 Java 虛擬機器引數 -XX:+UsePSAdaptiveSurvivorSizePolicy),根據生成物件的速率,以及 Survivor 區的使用情況動態調整 Eden 區和 Survivor 區的比例。

當然,你也可以通過引數 -XX:SurvivorRatio 來固定這個比例。但是需要注意的是,其中一個 Survivor 區會一直為空,因此比例越低浪費的堆空間將越高。
https://static001.geekbang.org/resource/image/2c/e5/2cc29b8de676d3747416416a3523e4e5.png

通常來說,當我們呼叫 new 指令時,它會在 Eden 區中劃出一塊作為儲存物件的記憶體。由於堆空間是執行緒共享的,因此直接在這裡邊劃空間是需要進行同步的。

否則,將有可能出現兩個物件共用一段記憶體的事故。如果你還記得前兩篇我用“停車位”打的比方的話,這裡就相當於兩個司機(執行緒)同時將車停入同一個停車位,因而發生剮蹭事故。

Java 虛擬機器的解決方法是為每個司機預先申請多個停車位,並且只允許該司機停在自己的停車位上。那麼當司機的停車位用完了該怎麼辦呢(假設這個司機代客泊車)?

答案是:再申請多個停車位便可以了。這項技術被稱之為 TLAB(Thread Local Allocation Buffer,對應虛擬機器引數 -XX:+UseTLAB,預設開啟)。

具體來說,每個執行緒可以向Java虛擬機器申請一段連續的記憶體,比如 2048 位元組,作為執行緒私有的TLAB。

這個操作需要加鎖,執行緒需要維護兩個指標(實際上可能更多,但重要也就兩個),一個指向 TLAB 中空餘記憶體的起始位置,一個則指向 TLAB 末尾。

接下來的 new 指令,便可以直接通過指標加法(bump the pointer)來實現,即把指向空餘記憶體位置的指標加上所請求的位元組數(我猜測會有留言問為什麼不把 bump the pointer 翻譯成指標碰撞。這裡先解釋一下,在英語中我們通常省略了 bump up the pointer 中的 up。在這個上下文中 bump 的含義應為“提高”。另外一個例子是當我們釋出軟體的新版本時,也會說 bump the version number。)。

如果加法後空餘記憶體指標的值仍小於或等於指向末尾的指標,則代表分配成功。否則,TLAB 已經沒有足夠的空間來滿足本次新建操作。這個時候,便需要當前執行緒重新申請新的 TLAB。

當Eden區的空間耗盡了怎麼辦? 這個時候Java虛擬機器便會觸發一次Minor GC,來收集新生代的垃圾。存活下來的物件,則會被送到 Survivor 區。

前面提到,新生代共有兩個 Survivor 區,我們分別用 from 和 to 來指代。其中 to 指向的 Survivior 區是空的。

當發生Minor GC時,Eden區和from指向的Survivor區中的存活物件會被複制到to指向的Survivor區中,然後交換from和to指標,以保證下一次Minor GC時,to指向的Survivor區還是空的。

Java虛擬機器會記錄Survivor區中的物件一共被來回複製了幾次。如果一個物件被複制的次數為 15(對應虛擬機器引數 -XX:+MaxTenuringThreshold),那麼該物件將被晉升(promote)至老年代。另外,如果單個Survivor區已經被佔用了50%(對應虛擬機器引數 -XX:TargetSurvivorRatio),那麼較高複製次數的物件也會被晉升至老年代。

總而言之,當發生 Minor GC 時,我們應用了標記 - 複製演算法, 將 Survivor 區中的老存活物件晉升到老年代,然後將剩下的存活物件和 Eden 區的存活物件複製到另一個 Survivor 區中。理想情況下,Eden 區中的物件基本都死亡了,那麼需要複製的資料將非常少,因此採用這種標記 - 複製演算法的效果極好。

Minor GC 的另外一個好處是不用對整個堆進行垃圾回收。但是,它卻有一個問題,那就是老年代的物件可能引用新生代的物件。也就是說,在標記存活物件的時候,我們需要掃描老年代中的物件。如果該物件擁有對新生代物件的引用,那麼這個引用也會被作為 GC Roots。

這樣一來,豈不是又做了一次全堆掃描呢?

卡表

HotSpot 給出的解決方案是一項叫做卡表(Card Table)的技術。該技術將整個堆劃分為 一個個大小為 512 位元組的卡,並且維護一個卡表,用來儲存每張卡的一個標識位。這個標識位代表對應的卡是否可能存有指向新生代物件的引用。如果可能存在,那麼我們就認為這張卡是髒的。

在進行 Minor GC 的時候,我們便可以不用掃描整個老年代,而是在卡表中尋找髒卡,並將髒卡中的物件加入到 Minor GC 的 GC Roots 裡。當完成所有髒卡的掃描之後,Java 虛擬機器便會將所有髒卡的標識位清零。

由於 Minor GC 伴隨著存活物件的複製,而複製需要更新指向該物件的引用。因此,在更新引用的同時,我們又會設定引用所在的卡的標識位。這個時候,我們可以確保髒卡中必定包含指向新生代物件的引用。

在 Minor GC 之前,我們並不能確保髒卡中包含指向新生代物件的引用。其原因和如何設定卡的標識位有關。

首先,如果想要保證每個可能有指向新生代物件引用的卡都被標記為髒卡,那麼 Java 虛擬機器需要截獲每個引用型例項變數的寫操作,並作出對應的寫標識位操作。

這個操作在解釋執行器中比較容易實現。但是在即時編譯器生成的機器碼中,則需要插入額外的邏輯。這也就是所謂的寫屏障(write barrier,注意不要和 volatile 欄位的寫屏障混淆)。

寫屏障需要儘可能地保持簡潔。這是因為我們並不希望在每條引用型例項變數的寫指令後跟著一大串注入的指令。

因此,寫屏障並不會判斷更新後的引用是否指向新生代中的物件,而是寧可錯殺,不可放過,一律當成可能指向新生代物件的引用。

這麼一來,寫屏障便可精簡為下面的虛擬碼 [1]。這裡右移 9 位相當於除以 512,Java 虛擬機器便是通過這種方式來從地址對映到卡表中的索引的。最終,這段程式碼會被編譯成一條移位指令和一條儲存指令。

雖然寫屏障不可避免地帶來一些開銷,但是它能夠加大 Minor GC 的吞吐率( 應用執行時間 /(應用執行時間 + 垃圾回收時間) )。總的來說還是值得的。不過,在高併發環境下,寫屏障又帶來了虛共享(false sharing)問題 [2]。

在介紹物件記憶體佈局中我曾提到虛共享問題,講的是幾個 volatile 欄位出現在同一快取行裡造成的虛共享。這裡的虛共享則是卡表中不同卡的標識位之間的虛共享問題。

在 HotSpot 中,卡表是通過 byte 陣列來實現的。對於一個 64 位元組的快取行來說,如果用它來載入部分卡表,那麼它將對應 64 張卡,也就是 32KB 的記憶體。

如果同時有兩個 Java 執行緒,在這 32KB 記憶體中進行引用更新操作,那麼也將造成儲存卡表的同一部分的快取行的寫回、無效化或者同步操作,因而間接影響程式效能。

為此,HotSpot 引入了一個新的引數 -XX:+UseCondCardMark,來儘量減少寫卡表的操作。

總結與實踐

今天我介紹了 Java 虛擬機器中垃圾回收具體實現的一些通用知識。

Java 虛擬機器將堆分為新生代和老年代,並且對不同代採用不同的垃圾回收演算法。其中,新生代分為 Eden 區和兩個大小一致的 Survivor 區,並且其中一個 Survivor 區是空的。

在只針對新生代的 Minor GC 中,Eden 區和非空 Survivor 區的存活物件會被複制到空的 Survivor 區中,當 Survivor 區中的存活物件複製次數超過一定數值時,它將被晉升至老年代。

因為 Minor GC 只針對新生代進行垃圾回收,所以在列舉 GC Roots 的時候,它需要考慮從老年代到新生代的引用。為了避免掃描整個老年代,Java 虛擬機器引入了名為卡表的技術,大致地標出可能存在老年代到新生代引用的記憶體區域。

由於篇幅的原因,我沒有講解 Java 虛擬機器中具體的垃圾回收器。我在文章的末尾附了一段簡單的介紹,如果你有興趣的話可以參閱一下。

今天的實踐環節,我們來看看 Java 物件的生命週期對垃圾回收的影響。

前面提到,Java 虛擬機器的分代垃圾回收是基於大部分物件只存活一小段時間,小部分物件卻存活一大段時間的假設的。

然而,現實情況中並非每個程式都符合前面提到的假設。如果一個程式擁有中等生命週期的物件,並且剛移動到老年代便不再使用,那麼將給預設的垃圾回收策略造成極大的麻煩。

下面這段程式將生成 64G 的 Java 物件。並且,我通過 ALIVE_OBJECT_SIZE 這一變數來定義同時存活的 Java 物件的大小。這也是一種對於垃圾回收器來說比較直觀的生命週期。

當我們使用 Java 8 的預設 GC,並且將新生代的空間限制在 100M 時,試著估算當 ALIVE_OBJECT_SIZE 為多少時,這段程式不會觸發 Full GC(提示一下,如果 Survivor 區沒法儲存所有存活物件,將發生什麼。)。實際執行情況又是怎麼樣的?

// Run with java -XX:+PrintGC -Xmn100M -XX:PretenureSizeThreshold=10000 LifetimeTest
// You may also try with -XX:+PrintHeapAtGC,-XX:-UsePSAdaptiveSurvivorSizePolicy or -XX:SurvivorRatio=N
public class LifetimeTest {
  private static final int K = 1024;
  private static final int M = K * K;
  private static final int G = K * M;

  private static final int ALIVE_OBJECT_SIZE = 32 * M;

  public static void main(String[] args) {
    int length = ALIVE_OBJECT_SIZE / 64;
    ObjectOf64Bytes[] array = new ObjectOf64Bytes[length];
    for (long i = 0; i < G; i++) {
      array[(int) (i % length)] = new ObjectOf64Bytes();
    }
  }
}

class ObjectOf64Bytes {
  long placeholder0;
  long placeholder1;
  long placeholder2;
  long placeholder3;
  long placeholder4;
  long placeholder5;
}

附錄:Java 虛擬機器中的垃圾回收器

針對新生代的垃圾回收器共有三個:Serial,Parallel Scavenge 和 Parallel New。這三個採用的都是標記-複製演算法。其中,Serial 是一個單執行緒的,Parallel New 可以看成 Serial 的多執行緒版本。Parallel Scavenge 和 Parallel New 類似,但更加註重吞吐率。此外,Parallel Scavenge 不能與 CMS 一起使用。

針對老年代的垃圾回收器也有三個:剛剛提到的 Serial Old 和 Parallel Old,以及 CMS。Serial Old 和 Parallel Old都是標記-壓縮演算法。同樣,前者是單執行緒的,而後者可以看成前者的多執行緒版本。

CMS採用的是標記-清除演算法,並且是併發的。除了少數幾個操作需要 Stop-the-world 之外,它可以在應用程式執行過程中進行垃圾回收。在併發收集失敗的情況下,Java 虛擬機器會使用其他兩個壓縮型垃圾回收器進行一次垃圾回收。由於G1的出現,CMS在Java9中已被廢棄[3]。

G1(Garbage First)是一個橫跨新生代和老年代的垃圾回收器。實際上,它已經打亂了前面所說的堆結構,直接將堆分成極其多個區域。每個區域都可以充當 Eden 區、Survivor 區或者老年代中的一個。它採用的是標記-壓縮演算法,而且和CMS一樣都能夠在應用程式執行過程中併發地進行垃圾回收。

G1 能夠針對每個細分的區域來進行垃圾回收。在選擇進行垃圾回收的區域時,它會優先回收死亡物件較多的區域。這也是G1名字的由來。

即將到來的 Java 11 引入了 ZGC,宣稱暫停時間不超過10ms。如果你感興趣的話,可參考 R 大的這篇文章(https://www.zhihu.com/question/287945354/answer/458761494)。

從多演算法中只有CMS採用標記-清除演算法,所以它被拋棄了。兩個關鍵詞Serial(Old)/Parallel(NewOld)

相關文章