深刻探究JVM之對象建立及分配策略

@java

前言

Java是面向對象的語言,所謂「萬事萬物皆對象」就是Java是基於對象來設計程序的,沒有對象程序就沒法運行(8大基本類型除外),那麼對象是如何建立的?在內存中又是怎麼分配的呢?算法

正文

1、對象的建立方式

在Java中咱們有幾種方式能夠建立一個新的對象呢?總共有如下幾種方式:數組

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

爲了便於說明和理解,下文僅針對new出來的對象進行討論。緩存

2、對象的建立過程

在這裏插入圖片描述
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字節的整數倍時,就須要對齊填充來補全。學習

3、對象的訪問定位

當對象建立完成後就存在於堆中,那麼棧中怎麼定位並引用到該對象呢?虛擬機規範中自己並無定義這一部分該如何實現,具體的實現取決於各個虛擬機廠商,而目前主流的定位方式有兩種:句柄直接指針

  • 句柄
    在這裏插入圖片描述
    經過句柄的方式引用就是虛擬機首先會在堆中劃分一塊區域做爲句柄池,句柄池中包含了指向對象實例類型數據的指針,而棧中則只須要引用句柄池便可。這種方式的好處顯而易見,引用很是穩定,不會隨着對象的移動而須要改變棧中的引用,但這樣勢必會下降引用的性能,同時堆中可用內存變少。
  • 直接指針
    在這裏插入圖片描述
    顧名思義,直接指針就是指棧中引用直接指向堆中的對象,這樣作的好處就是效率很是高,不須要經過句柄池中轉,但也所以失去了穩定性。

以上兩種方式在各個語言和框架都有使用,而本文所討論的HotSpot虛擬機使用的是直接指針方式,由於對象的訪問是很是頻繁的,這時效率就顯得格外重要。

4、判斷對象的存活

對象生死

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關聯上便可。
該方法是虛擬機提供給對象復活的惟一機會,可是該方法做用極小,由於使用不慎可能會致使系統崩潰,另外因爲它的運行優先級也很是低,經常須要主線程等待它的執行,致使系統性能大大下降,因此基本上能夠忘記該方法的存在了。

5、對象的分配策略

上文說到對象是在堆中分配內存的,可是堆中也是分爲新生代老年代的,新生代中又分了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的難點和基礎,但這是繞不開的一道坎,讀者只有多看,多思考,寫代碼復現文中提到的概念,才能真正的理解這些基礎知識。另外還有垃圾是怎麼回收的?有哪些垃圾回收器?怎麼選擇?這些問題將在下一篇進行解答。

相關文章
相關標籤/搜索