本篇文章承接上文《內存管理與運行時數據區剖析》,如果想了解更多JVM運行時數據區相關或者內存管理相關,請查看上文。JVM虛擬機(一)-內存管理與運行時數據區剖析
虛擬機中對象的創建過程
在JVM的內存回收機制,也就是Java中對象的死(回收),我們必須先了解Java中對象的生(創建)和對象的內存分配
上圖是Java中的對象創建過程,在JVM中,對象的創建都是按照這個流程來實現的。
第一步:檢查加載
虛擬機遇到一條new指令時,首先檢查是否被類加載器加載,如果沒有,那必須先執行相應的類加載過程。
類加載就是ClassLoader 調用loadClass方法把class加載到JVM的運行時數據區的過程,,詳細的類加載過程這里不細說,后面我會專門寫一篇文章關于Android的類加載機制。
第二步:分配內存
接下來虛擬機將為新生對象分配內存。為對象分配空間的任務等同于把一塊確定大小的內存從Java堆中劃分出來。分配內存分兩種分配方式:
指針碰撞
如果Java堆中內存是絕對規整的,所有用過的內存都放在一邊,空閑的內存放在另一邊,中間放著一個指針作為分界點的指示器,那所分配內存就僅僅是把那個指針向空閑空間那邊挪動一段與對象大小相等的距離,這種分配方式稱為“指針碰撞”。
空閑列表
如果Java堆中的內存并不是規整的,已使用的內存和空閑的內存相互交錯,那就沒有辦法簡單地進行指針碰撞了,虛擬機就必須維護一個列表,記錄上哪些內存塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給對象實例,并更新列表上的記錄,這種分配方式稱為“空閑列表”。
選擇哪種分配方式由Java堆是否規整決定,而Java堆是否規整又由所采用的垃圾收集器是否帶有壓縮整理功能決定。
如果是Serial、ParNew等帶有壓縮的整理的垃圾回收器的話,系統采用的是指針碰撞,既簡單又高效。
如果是使用CMS這種不帶壓縮(整理)的垃圾回收器的話,理論上只能采用較復雜的空閑列表。
并發安全
除如何劃分可用空間之外,還有另外一個需要考慮的問題是對象創建在虛擬機中是非常頻繁的行為,即使是僅僅修改一個指針所指向的位置,在并發情況下也并不是線程安全的,可能出現正在給對象A分配內存,指針還沒來得及修改,對象B又同時使用了原來的指針來分配內存的情況。解決這個問題有兩種方案,CAS和分配緩沖(TLAB)
CAS(Compare And Swap)機制
解決這個問題有兩種方案,一種是對分配內存空間的動作進行同步處理——實際上虛擬機采用CAS配上失敗重試的方式保證更新操作的原子性。
分配緩沖(Thread Local Allocation Buffer,TLAB)
另一種是把內存分配的動作按照線程劃分在不同的空間之中進行,即每個線程在Java堆中預先分配一小塊私有內存,也就是本地線程分配緩沖(Thread Local Allocation Buffer,TLAB),JVM在線程初始化時,同時也會申請一塊指定大小的內存,只給當前線程使用,這樣每個線程都單獨擁有一個Buffer,如果需要分配內存,就在自己的Buffer上分配,這樣就不存在競爭的情況,可以大大提升分配效率,當Buffer容量不夠的時候,再重新從Eden區域申請一塊繼續使用。
TLAB的目的是在為新對象分配內存空間時,讓每個Java應用線程能在使用自己專屬的分配指針來分配空間,減少同步開銷。TLAB只是讓每個線程有私有的分配指針,但底下存對象的內存空間還是給所有線程訪問的,只是其它線程無法在這個區域分配而已。
第三步:內存空間初始化
注意內存空間初始化不是類構造方法,內存分配完成后,虛擬機需要將分配到的內存空間都初始化為零值(如int值為0,boolean值為false等等)。這一步操作保證了對象的實例字段在Java代碼中可以不賦初始值就直接使用,程序能訪問到這些字段的數據類型所對應的零值。
第四步:設置
接下來,虛擬機要對對象進行必要的設置,例如這個對象是哪個類的實例、如何才能找到類的元數據信息(Java classes在Java hotspot VM內部表示為類元數據)、對象的哈希碼、對象的GC分代年齡等信息。這些信息存放在對象的對象頭之中。
第五步:對象初始化
在上面工作都完成之后,從虛擬機的視角來看,一個新的對象已經產生了,但從Java程序的視角來看,對象創建才剛剛開始,所有的字段都還為零值。所以,一般來說,執行new指令之后會接著把對象按照程序員的意愿進行初始化(構造方法),這樣一個真正可用的對象才算完全產生出來。
對象的內存布局(結構)
在HotSpot虛擬機中,對象在內存中存儲的布局可以分為3塊區域:對象頭(Header)、實例數據(Instance Data)和對齊填充(Padding)。
對象頭(Header)
第一部分用于存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡、鎖狀態標志、線程持有的鎖、偏向線程ID、偏向時間戳等。
這是一個對象的頭信息文件:
第二部分用于存儲對象類型指針,即對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。
一般對象只有前兩個部分,如果對象是一個java數組,那么在對象頭中還有一塊用于記錄數組長度的數據。
實例數據(Instance Data)
用來存儲該對象的實例數據。如Int的值、對象的變量值
對齊填充(Padding)
對齊填充并不是必然存在的,也沒有特別的含義,它僅僅起著占位符的作用。由于HotSpot VM的自動內存管理系統要求對對象的大小必須是8字節的整數倍。當對象其他數據部分沒有對齊時,就需要通過對齊填充來補全。
對象的訪問定位
建立對象是為了使用對象,我們的Java程序需要通過棧上的reference數據來操作堆上的具體對象。目前主流的訪問方式有使用句柄和直接指針兩種。
句柄
如果使用句柄訪問的話,那么Java堆中將會劃分出一塊內存來作為句柄池,reference中存儲的就是對象的句柄地址,而句柄中包含了對象實例數據與類型數據各自的具體地址信息。
直接指針
如果使用直接指針訪問, reference中存儲的直接就是對象地址。
這兩種對象訪問方式各有優勢,使用句柄來訪問的最大好處就是reference中存儲的是穩定的句柄地址,在對象被移動(垃圾收集時移動對象是非常普遍的行為)時只會改變句柄中的實例數據指針,而reference本身不需要修改。
使用直接指針訪問方式的最大好處就是速度更快,它節省了一次指針定位的時間開銷,由于對象的訪問在Java中非常頻繁,因此這類開銷積少成多后也是一項非常可觀的執行成本。
對Sun HotSpot而言,它是使用直接指針訪問方式進行對象訪問的。
判斷對象的存活
在堆里面存放著幾乎所有的對象實例,垃圾回收器在對對進行回收前,要做的事情就是確定這些對象中哪些還是“存活”著,哪些已經“死去”(死去代表著不可能再被任何途徑使用得對象了)
引用計數法
對象中添加一個引用計數器,每當有一個地方引用它,計數器就加1,當引用失效時,計數器減1.
Python在用,但主流虛擬機沒有使用,因為存在對象相互引用的情況,這個時候需要引入額外的機制來處理,這樣做影響效率。
可達性分析
來判定對象是否存活的。這個算法的基本思路就是通過一系列的稱為“GC Roots”的對象作為起始點,從這些節點開始向下搜索,搜索所走過的路徑稱為引用鏈(Reference Chain),當一個對象到GC Roots沒有任何引用鏈相連時,則證明此對象是不可用的。作為GC Roots的對象包括下面幾種:
1. 虛擬機棧(棧幀中的本地變量表)中引用的對象。
2.方法區中類靜態屬性引用的對象。
3.方法區中常量引用的對象。
4.本地方法棧中JNI(即一般說的Native方法)引用的對象。
5.JVM的內部引用(class對象、異常對象NullPointException、OutofMemoryError,系統類加載器)。
6.所有被同步鎖(synchronized關鍵)持有的對象。
7.JVM內部的JMXBean、JVMTI中注冊的回調、本地代碼緩存等
8.JVM實現中的“臨時性”對象,跨代引用的對象(在使用分代模型回收只回收部分代時)
Finalize方法
即使通過可達性分析判斷不可達的對象,也不是“非死不可”,它還會處于“緩刑”階段,真正要宣告一個對象死亡,需要經過兩次標記過程,一次是沒有找到與GCRoots的引用鏈,它將被第一次標記。隨后進行一次篩選(如果對象覆蓋了finalize),我們可以在finalize中去拯救。
對象的引用分類
強引用
一般的Object obj = new Object() ,就屬于強引用。在任何情況下,只有有強引用關聯(與根可達)還在,垃圾回收器就永遠不會回收掉被引用的對象。
軟引用 SoftReference
一些有用但是并非必需,用軟引用關聯的對象,系統將要發生內存溢出(OuyOfMemory)之前,這些對象就會被回收(如果這次回收后還是沒有足夠的空間,才會拋出內存溢出)。例如,一個程序用來處理用戶提供的圖片。如果將所有圖片讀入內存,這樣雖然可以很快的打開圖片,但內存空間使用巨大,一些使用較少的圖片浪費內存空間,需要手動從內存中移除。如果每次打開圖片都從磁盤文件中讀取到內存再顯示出來,雖然內存占用較少,但一些經常使用的圖片每次打開都要訪問磁盤,代價巨大。這個時候就可以用軟引用構建緩存。
運行結果:
弱引用 WeakReference
一些有用(程度比軟引用更低)但是并非必需,用弱引用關聯的對象,只能生存到下一次垃圾回收之前,GC發生時,不管內存夠不夠,都會被回收。需要注意的是軟引用 (SoftReference)和弱引用 (WeakReference),可以用在內存資源緊張的情況下以及創建不是很重要的數據緩存。當系統內存不足的時候,緩存中的內容是可以被釋放的。
軟引用實際運用(WeakHashMap、ThreadLocal)
參看代碼:
運行結果:
虛引用 PhantomReference
幽靈引用,最弱(隨時會被回收掉)垃圾回收的時候收到一個通知,就是為了監控垃圾回收器是否正常工作。實際中運用不到,太容易被回收。
對象的分配策略
Java 的對象可以分為基本數據類型和普通對象。對于普通對象來說,JVM 會首先在堆上創建對象,然后在其他地方使用的其實是它的引用。比如,把這個引用保存在虛擬機棧的局部變量表中。對于基本數據類型來說(byte、short、int、long、float、double、char),有兩種情況。當你在方法體內聲明了基本數據類型的對象,它就會在棧上直接分配。其他情況,都是在堆上分配。我們在書上或者網上會經常看到一句話“幾乎所有的對象(普通對象)都是在堆上分配”。其實幾乎所有并不是全部對象,有些對象是在棧上分配的。首先我們先來看下對象分配的流程:
棧上分配
當新new一個對象時,我們首先判斷這個對象能不能在棧上分配,這里的棧指的是運行時數據的虛擬機棧。
為什么對象先要判斷是否在棧上分配呢
因為在JVM虛擬機棧中的棧幀對象是在方法開始的時候創建的,運行完后便會清除棧幀中的操作數棧和本地變量表的數據。在棧上分配對象,不需要垃圾回收,提高性能。那又怎么去判斷對象是否滿足在棧中分配的條件呢,這就要引入逃逸分析。
逃逸分析
所謂逃逸分析是看方法中的對象沒有發生逃逸,這個逃逸分兩種,分析對象動態作用域,當一個對象在方法中定義后,它可能被外部方法所引用,比如:調用參數傳遞到其他方法中,這種稱之為方法逃逸;如果被外部線程訪問到,例如:賦值給其他線程中訪問的變量,這個稱之為線程逃逸。
從不逃逸到方法逃逸到線程逃逸,稱之為對象由低到高的不同逃逸程度。如果確定一個對象不會發生方法逃逸和線程逃逸,那么讓對象在棧上分配內存可以提高JVM的效率。
public class EscapeAnalysisTest {
public static void main(String[] args) throws Exception {
long start = System.currentTimeMillis();
for (int i = 0; i < 50000000; i++) {
allocate();
}
System.out.println((System.currentTimeMillis() - start) + " ms");
Thread.sleep(600000);
}
static void allocate() {
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;
}
}
}
這段代碼在調用的過程中 myboject這個對象滿足逃逸分析算法,JVM可以做棧上分配
然后通過開啟和關閉DoEscapeAnalysis開關觀察不同。
開啟逃逸分析(JVM默認開啟)
查看執行速度
關閉逃逸分析
查看執行速度
測試結果可見,開啟逃逸分析對代碼的執行性能有很大的影響!如果是逃逸分析出來的對象可以在棧上分配的話,那么該對象的生命周期就跟隨線程了,就不需要垃圾回收,如果是頻繁的調用此方法則可以得到很大的性能提高。采用了逃逸分析后,滿足逃逸的對象在棧上分配。
堆上分配
對象的分配原則
1.對象優先在Eden分配
大多數情況下,對象在新生代Eden區中分配。當Eden區沒有足夠空間分配時,虛擬機將發起一次Minor GC
2.空間分配擔保
在發生Minor GC之前,虛擬機會先檢查老年代最大可用的連續空間是否大于新生代所有對象總空間,如果這個條件成立,那么Minor GC可以確保是安全的。如果不成立,則虛擬機會查看HandlePromotionFailure設置值是否允許擔保失敗。如果允許,那么會繼續檢查老年代最大可用的連續空間是否大于歷次晉升到老年代對象的平均大小,如果大于,將嘗試著進行一次Minor GC,盡管這次Minor GC是有風險的,如果擔保失敗則會進行一次Full GC;如果小于,或者HandlePromotionFailure設置不允許冒險,那這時也要改為進行一次Full GC。
3.大對象直接進入老年代
最典型的大對象是那種很長的字符串以及數組。這樣做的目的:1.避免大量內存復制,2.避免提前進行垃圾回收,明明內存有空間進行分配。
4.長期存活的對象進入老年代
如果對象在Eden出生并經過第一次Minor GC后仍然存活,并且能被Survivor容納的話,將被移動到Survivor空間中,并將對象年齡設為1,對象在Survivor區中每熬過一次 Minor GC,年齡就增加1,當它的年齡增加到一定程度(并發的垃圾回收器默認為15),CMS是6時,就會被晉升到老年代中。
5.動態對象年齡判定
為了能更好地適應不同程序的內存狀況,虛擬機并不是永遠地要求對象的年齡必須達到了MaxTenuringThreshold才能晉升老年代,如果在Survivor空間中相同年齡所有對象大小的總和大于Survivor空間的一半,年齡大于或等于該年齡的對象就可以直接進入老年代,無須等到MaxTenuringThreshold中要求的年齡