深入探究JVM之物件建立及分配策略

夜勿語發表於2020-07-22

@

前言

Java是物件導向的語言,所謂“萬事萬物皆物件”就是Java是基於物件來設計程式的,沒有物件程式就無法執行(8大基本型別除外),那麼物件是如何建立的?在記憶體中又是怎麼分配的呢?

正文

一、物件的建立方式

在Java中我們有幾種方式可以建立一個新的物件呢?總共有以下幾種方式:

  • new關鍵字
  • 反射
  • clone
  • 反序列化
  • Unsafe.allocateInstance

為了便於說明和理解,下文僅針對new出來的物件進行討論。

二、物件的建立過程

在這裡插入圖片描述
Java中物件的建立過程就包含上圖中的5個步驟,首先需要驗證待建立物件的類是否已經被JVM記載,如果沒有則會先進行類的載入,如果已經載入則會在堆中(不完全是堆,後文會講到)分配記憶體;分配完記憶體後則是對物件的成員變數設定初始值(0或null),這樣物件在堆中就建立好了。但是,這個物件是屬於哪個類的還不知道,因為類資訊存在於方法區,所以還需要設定物件的頭部(當然頭部中也不僅僅只有型別指標資訊,稍後也會詳細講到),這樣堆中才建立好了一個完整的物件,但是這個物件的成員變數還都是初始值,所以最後會呼叫init方法按照我們自己的意願初始化物件,一個真正的物件就建立好了。
物件的整個建立過程是非常簡單的,但是其中還有很多細節,比如物件會在哪裡建立?分配記憶體有哪些方式?怎麼保證執行緒安全?物件頭中有哪些資訊?下面一一講解。

物件在哪裡建立

基本上所有的物件都是在堆中,但並非絕對,在JDK1.6版本引入了逃逸分析技術。逃逸分析就是指標對物件的作用域進行判定,當一個物件在方法中被定義後,如果被其它方法其它執行緒訪問到,就稱為方法逃逸執行緒逃逸
該技術針對未逃逸的物件做了一個優化:棧上分配(除此之外還有同步消除標量替換,這裡暫時不講)。這個優化是指當一個物件能被確定不會在該方法之外被引用,那麼就可以直接在虛擬機器棧中建立該物件,那麼這個物件就可以隨著執行緒的消亡而銷燬,不再需要垃圾回收器進行回收。這個優化帶來的收益是明顯的,因為有相當一部分物件都只會在該方法內部被引用。逃逸分析預設是開啟的,可以通過-XX:-DoEscapeAnalysis引數關閉。下面看一個例項:

public class EscapeAnalysisTest {
    public static void main(String[] args) throws Exception {
        long start = System.currentTimeMillis();
        for (int i = 0; i < 50000000; i++) {//5000萬次---5000萬個物件
            allocate();
        }
        System.out.println((System.currentTimeMillis() - start) + " ms");
        Thread.sleep(600000);
    }

    static void allocate() {//逃逸分析(不會逃逸出方法)
        //這個myObject引用沒有出去,也沒有其他方法使用
        MyObject myObject = new MyObject(2020, 2020.6);
    }

    static class MyObject {
        int a;
        double b;

        MyObject(int a, double b) {
            this.a = a;
            this.b = b;
        }
    }
}

加上-XX:+PrintGC引數執行上面的方法,會看到控制檯只是列印了執行時間5ms,但是若再加上-XX:-DoEscapeAnalysis關閉逃逸分析就會出現下面的結果:

[GC (Allocation Failure)  66384K->848K(251392K), 0.0012528 secs]
[GC (Allocation Failure)  66384K->816K(251392K), 0.0010461 secs]
[GC (Allocation Failure)  66352K->848K(316928K), 0.0009666 secs]
[GC (Allocation Failure)  131920K->784K(316928K), 0.0018284 secs]
[GC (Allocation Failure)  131856K->816K(438272K), 0.0009315 secs]
[GC (Allocation Failure)  262960K->684K(438272K), 0.0022738 secs]
[GC (Allocation Failure)  262828K->684K(700928K), 0.0005052 secs]
308 ms

執行時間大大提升,主要是用在了GC回收上。

分配記憶體

  • 分配方式
    JVM有兩種分配記憶體的方式:指標碰撞空閒列表。使用哪種方式取決於堆中記憶體是否規整,而是否規整又取決於使用的垃圾回收器,這個是下一篇的內容。如果記憶體規整,那麼就會使用指標碰撞分配記憶體,也就是將已用的記憶體和未用的記憶體分開分別放到一邊,中間使用指標作為分界線;當需要分配記憶體時,指標就向未分配的那一邊挪動一段與物件大小相等的距離。如果記憶體不是規整的,JVM會維護一個列表,列表中會記錄哪些記憶體是可用的,分配記憶體時首先就會去這個表裡面找到可用且大小合適的記憶體。
  • 執行緒安全
    理解了上面的兩種方式,敏銳的讀者應該很快就能發現其中的問題,我們的JVM肯定不會以單執行緒的方式去堆中建立物件,那樣效率是極低的,那麼怎麼保證同一時間不會有兩個執行緒同時佔用同一塊記憶體呢?JVM同樣有兩種方式保證執行緒安全:CAS和TLAB(本地執行緒緩衝)。
    • CAS是compare and swap,涉及到預期值記憶體值更新值。意思當前執行緒每當需要分配記憶體時首先從記憶體中取出值和期望值比較,如果相等則將記憶體中的值更新為更新值,否則則繼續迴圈比較,這樣當前執行緒在申請記憶體時,一旦該記憶體被其它執行緒提前佔據,那麼當前執行緒就會去申請其它未被佔據的記憶體,
    • TLAB是指執行緒首先會去堆中申請一塊記憶體,每個執行緒都在各自佔據的記憶體中建立物件,也就不存線上程安全問題了。可以通過-XX:+/-UseTLAB引數進行控制。

物件的記憶體佈局

在HotSpot虛擬機器中,物件在記憶體中分為三塊:物件頭、例項資料和對齊填充。如下圖:
在這裡插入圖片描述

物件的記憶體佈局上面這張圖寫的很清楚了,其中自身執行時資料瞭解一下有哪些資訊即可,型別指標則是指向物件所屬的類,如果物件是陣列,則物件頭中還會包含陣列的長度資訊;例項資料就是指物件的欄位資訊;最後對齊填充則不是必須的,因為為了方便處理和計算,HotSpot要求物件的大小必須是8位元組的整數倍,因此當不滿8位元組的整數倍時,就需要對齊填充來補全。

三、物件的訪問定位

當物件建立完成後就存在於堆中,那麼棧中怎麼定位並引用到該物件呢?虛擬機器規範中本身並沒有定義這一部分該如何實現,具體的實現取決於各個虛擬機器廠商,而目前主流的定位方式有兩種:控制程式碼直接指標

  • 控制程式碼
    在這裡插入圖片描述
    通過控制程式碼的方式引用就是虛擬機器首先會在堆中劃分一塊區域作為控制程式碼池,控制程式碼池中包含了指向物件例項型別資料的指標,而棧中則只需要引用控制程式碼池即可。這種方式的好處顯而易見,引用非常穩定,不會隨著物件的移動而需要改變棧中的引用,但這樣勢必會降低引用的效能,同時堆中可用記憶體變少。
  • 直接指標
    在這裡插入圖片描述
    顧名思義,直接指標就是指棧中引用直接指向堆中的物件,這樣做的好處就是效率非常高,不需要通過控制程式碼池中轉,但也因此失去了穩定性。

以上兩種方式在各個語言和框架都有使用,而本文所討論的HotSpot虛擬機器使用的是直接指標方式,因為物件的訪問是非常頻繁的,這時效率就顯得格外重要。

四、判斷物件的存活

物件生死

JVM不需要我們手動釋放記憶體,這是Java廣受歡迎的原因之一,那麼它是如何做到自動管理記憶體,回收不需要的物件的呢?既然要回收物件,那麼就需要判斷哪些物件是可以被回收的,即物件的死活判定,哪些物件不會再被引用?有兩種實現方式:引用計數法可達性分析

  • 引用計數法:這個演算法很簡單,每個物件關聯一個計數器,物件每被引用一次,計數器就加1,引用失效時,計數器就減一,垃圾回收時只需要回收計數為0的物件即可。這樣做效率很高,但是這個演算法有個顯著的缺點,沒法解決迴圈依賴,即A依賴B,B依賴A,這樣它們的計數器都為1,但實際上除此之外沒有任何地方引用它們了,就會導致記憶體洩露(即記憶體無法被釋放)。
  • 可達性分析:相較於引用計數法,這個演算法效率會低一些,但卻是虛擬機器採用的方式,因為它就能解決迴圈依賴的問題。該演算法會將一部分物件作為GC Roots,然後以這些物件作為起點開始搜尋,當一個物件到GC Roots沒有任何途徑可以到達時,則表示該物件可以被回收。問題就在於那些物件可以作為GC Roots呢?
    • 虛擬機器棧(棧幀中的區域性變數表)中引用的物件
    • 方法區中類靜態屬性引用的物件
    • 常量池引用的物件
    • 本地方法棧中JNI(native方法)引用的物件

以上4種非常好理解,是重點,需要熟記於心,因為上面4種物件是在方法執行時或常量引用的物件,在對應的生命週期是肯定不能被GC回收的,作為GC Roots自然再合適不過。另外還有下面幾種可以作為了解:

  • JVM內部引用的物件(class物件、異常物件、類載入器等)
  • 被同步鎖(synchronized關鍵字)持有的物件
  • JVM內部的JMXBean、JVMTI中註冊的回撥、原生程式碼快取等
  • JVM中實現的“臨時性”物件,跨代引用的物件

回收方法區

除了堆中物件需要回收,方法區中的class物件也是可以被回收的,但是回收的條件非常苛刻:

  • 該類的所有例項都已經被回收,堆中不存在該類的物件
  • 載入該類的ClassLoader已被回收
  • 該類對應的java.lang.Class物件沒有在任何地方被引用,無法在任何地方通過反射訪問該類的方法

可以看到方法區的回收條件是多麼苛刻,所以方法區的回收率一般極低,因此可以通過-Xnoclassgc關閉方法區的回收,提升GC效率,但需要注意,關閉後將會導致方法區的記憶體永久被佔用,導致OOM出現。

引用

通過上文我們可以發現,物件的存活判定都是基於引用,而Java中引用又分為了4種:

  • 強引用:平時我們使用=賦值就屬於強引用,被強引用關聯的物件,永遠不會被GC回收。
  • 軟引用(SoftReference):常用來引用一些有用但並非必需的物件,如實現快取。因為軟引用只會在要發生OOM之前檢查並被回收掉,如果回收後空間仍然不足,才會丟擲OOM異常。
  • 弱引用(WeakReference):比軟引用更弱的引用,只要發生垃圾回收就會被回收掉的引用,也可以用來實現快取。在Java中,WeakHashMap和ThreadLocal的鍵都是利用弱引用實現的(注意這兩個類的區別,前者可以配合ReferenceQueue使用,當key被回收時會被加入到該佇列中,繼而在清除null key時直接掃描這個佇列即可;而後者在清除null key時需要遍歷所有的鍵。關於ThreaLocal後面會在併發系列中詳細分析)。
  • 虛引用(PhantomReference):最弱的引用,一個物件是否有虛引用,完全不會影響到其生命週期,無法通過該引用獲取到一個物件的例項,使用時需要和ReferenceQueue配合使用,而使用它的唯一目的就是在這個物件被垃圾回收時能夠接收到一個通知。

物件的自我拯救

虛擬機器提供了一次自我拯救的機會給物件,即finalize方法。如果物件覆蓋了該方法,當經過可達性分析後,就會進行一次判斷,判斷該物件是否有必要執行finalize方法,如果物件沒有覆蓋該方法或者已經執行過一次該方法都會判定為該物件沒有必要執行finalize方法,在GC時被回收。否則就會將該物件放入到一個叫F-Queue的佇列中,之後GC會對該佇列的物件進行二次標記,即呼叫該方法,如果我們要讓該物件復活,那麼就只需要在finalize方法中將該物件重新與GC Roots關聯上即可。
該方法是虛擬機器提供給物件復活的唯一機會,但是該方法作用極小,因為使用不慎可能會導致系統崩潰,另外由於它的執行優先順序也非常低,常常需要主執行緒等待它的執行,導致系統效能大大降低,所以基本上可以忘記該方法的存在了。

五、物件的分配策略

上文說到物件是在堆中分配記憶體的,但是堆中也是分為新生代老年代的,新生代中又分了Edenfromsurvivor區,那麼物件具體會分配到哪個區呢?這涉及到物件的分配規則,下面一一說明。

優先在Eden區分配

大多數情況,物件直接在Eden區中分配記憶體,當Eden區記憶體不足時,就會進行一次MinorGC(新生代垃圾回收,可以通過-XX:+PrintGCDetails這個引數列印GC日誌資訊)。

大物件直接進入老年代

什麼是大物件?虛擬機器提供了一個引數:-XX:PretenureSizeThreshold,當物件大小大於該值時,該物件就會直接被分配到老年代中(該引數只對Serial和ParNew垃圾收集器有效)。為什麼不分配到新生代中呢?因為在新生代中每一次MinroGC都會導致物件在Eden、from和sruvivor中複製,如果存在很多這樣的大物件,那麼新生代的GC和複製效率就會極低(關於垃GC的內容後面的文章會詳細講解)。

長期存活的物件進入老年代

既然物件優先在新生代中分配,那麼什麼時候會進入到老年代呢?這就和上文講解的物件頭中的分代年齡有關了,預設情況下超過15歲就會進入老年代,可以通過-XX:MaxTenuringThreshold引數進行設定。那歲數又是怎麼增長的呢?每當物件熬過一次MiniorGC後年齡都會增加1歲。

動態物件年齡判定

但是虛擬機器並不是要求物件年齡必須達到MaxTenuringThreshold才能晉升老年代,當Survivor空間中相同年齡的所有物件的大小總和大於Survivor空間一半時,年齡大於或等於該年齡的物件就會直接晉升到老年代

空間分配擔保

在發生MiniorGC之前,虛擬機器首先會檢查老年代中最大可用的連續空間是否大於新生代所有物件的總和,如果大於則進行一次MiniorGC;否則,則會檢查HandlePromotionFailure設定值是否允許擔保失敗。如果允許則會檢查老年代最大連續空間是否大於歷次晉升到老年代物件的平均大小,如果大於則進行一次MiniorGC,否則則進行一次FullGC。
為什麼要這麼設計呢?因為頻繁的FullGC會導致效能大大降低,而取歷次晉升老年代物件的平均大小肯定也不是百分百有效,因為存在物件突然大大增加的情況,這個時候就會出現擔保失敗的情況,也會導致FullGC。需要注意的是HandlePromotionFailure這個引數在JDK6Update24後就不會再影響到虛擬機器的空間分配擔保策略了,即預設老年代的連續空間大於新生代物件的總大小或歷次晉升的平均大小就會進行MinorGC,否則進行FullGC。

總結

本文概念性的東西非常多,這是學習JVM的難點和基礎,但這是繞不開的一道坎,讀者只有多看,多思考,寫程式碼復現文中提到的概念,才能真正的理解這些基礎知識。另外還有垃圾是怎麼回收的?有哪些垃圾回收器?怎麼選擇?這些問題將在下一篇進行解答。

相關文章