@ 前言 Java是面向對象的語言,所謂“萬事萬物皆對象”就是Java是基於對象來設計程式的,沒有對象程式就無法運行(8大基本類型除外),那麼對象是如何創建的?在記憶體中又是怎麼分配的呢? 正文 一、對象的創建方式 在Java中我們有幾種方式可以創建一個新的對象呢?總共有以下幾種方式: new關鍵字 ...
@目錄
前言
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關聯上即可。
該方法是虛擬機提供給對象複活的唯一機會,但是該方法作用極小,因為使用不慎可能會導致系統崩潰,另外由於它的運行優先順序也非常低,常常需要主線程等待它的執行,導致系統性能大大降低,所以基本上可以忘記該方法的存在了。
五、對象的分配策略
上文說到對象是在堆中分配記憶體的,但是堆中也是分為新生代和老年代的,新生代中又分了Eden、from、survivor區,那麼對象具體會分配到哪個區呢?這涉及到對象的分配規則,下麵一一說明。
優先在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的難點和基礎,但這是繞不開的一道坎,讀者只有多看,多思考,寫代碼復現文中提到的概念,才能真正的理解這些基礎知識。另外還有垃圾是怎麼回收的?有哪些垃圾回收器?怎麼選擇?這些問題將在下一篇進行解答。