HotSpot物件揭秘

物件的建立

HotSpot物件揭秘

文章配圖

類載入檢查:

虛擬機器遇到一條new指令時,首先將去檢查這個指令的引數是否能在常量池中定位到這個類的符號引用,並且檢查這個符號引用代表的類是否已經被載入過、解析和初始化過。如果沒有,那必須先執行相應的類載入過程。

分配記憶體:

類載入檢查

通過後,虛擬機器將會非新生物件

分配記憶體。

物件所需的記憶體大小在類載入之後就可以確定了,為物件分配空間的過程等同於把一塊確定大小的記憶體從Java堆中劃分出來。

分配方式

有“

指標碰撞

”,“

空閒列表

”兩種,

選擇哪種分配方式由Java堆是否規整決定,而Java堆是否規整又根據垃圾收集器是否帶有壓縮整理功能決定。

指標碰撞:假設Java堆記憶體是規整的,所有已分配的記憶體都在一邊,未分配的都在另一邊, 中間有指標指示器用來分隔二者,那麼分配記憶體就是把指標指示器移動和物件大小相等的舉例,這種方式成為“指標碰撞”(

Serial、ParNew垃圾收集器,使用了標記-整理演算法,系統採用的分配演算法就為指標碰撞,簡單又高效

文章配圖

空閒列表:如果Java堆空間不是連續規整的,已分配的記憶體和空閒的記憶體混合在一起,那麼就需要維護一個空閒列表,來記錄哪些記憶體是可用的,在分配記憶體的時候從列表中找到一塊足夠大的空間給物件例項,並更新列表上的記錄,這種方式稱為“空閒列表”(

CMS垃圾收集器,“理論上”就只能使用較為複雜的空閒列表,為什麼是理論上呢?因為在CMS裡為了更快的分配記憶體,設計了一個叫Linear Allocation Buffer的分配緩衝區,透過空閒列表拿到一大塊分配緩衝區後,在它裡面仍然可以使用指標碰撞方式來分配

文章配圖

記憶體的分配方式:選擇以上兩種方式的哪一種,取決於Java記憶體是否規整,而Java記憶體是否規整取決於GC收集器的演算法是“

標記-清除

”還是“

標記-整理

”,

複製演算法記憶體也是規整的

記憶體分配併發問題:

在建立物件的時候有一個很重要的問題就是執行緒安全,因為在實際開發過程中,建立物件是很頻繁的事情,作為虛擬機器來說必須要保證執行緒安全才行,通常來講,虛擬機器採用兩種方式來保證執行緒安全:

CAS+失敗重試:

CAS是樂觀鎖的一種實現方式。所謂樂觀鎖就是,每次不加鎖而是假設沒有衝突而去完成某項操作, 如果因為衝突失敗就重試,直到成功為止。虛擬機器採用CAS配上失敗重試的方式保證更新操作的原子性。

TLAB:

把記憶體分配的動作按照執行緒劃分在不同的空間中進行,即每個執行緒在Java堆中預先分配一小塊記憶體成為本地執行緒快取(Thread Local Allocation Buffer),JVM在給執行緒中的物件分配記憶體的時候,首先在TLAB分配,當物件大於TLAB中剩餘記憶體或者TLAB中記憶體用盡時,再採用上述的CAS分配(

可以使用-XX:+/-UseTLAB引數來開啟或關閉TLAB

)。

3。初始化零值:記憶體分配完成後,虛擬機器需要將分配到的記憶體都初始化為零值(不包括物件頭),這一步操作保證了物件的例項欄位在Java程式碼中可以不賦初始值就可以使用,程式能訪問到這些欄位的資料型別對應的零值。

4。設定物件頭:初始化零值完成後,

虛擬機器要對物件進行必要的設定,

例如這個物件是哪個類的例項、如何才能找到類的元資料資訊、物件的雜湊碼、物件的GC分代年齡等資訊。

這些資訊存放在物件頭中。

另外,根據虛擬機器當前執行狀態 的不同,如是否啟用偏向鎖等,物件頭會有不同的設定方式。

5。執行init方法:在上面的工作完成之後,從虛擬機器的視角來看,一個新的物件已經產生了,但是從Java程式的視角來看,物件建立才剛開始,

init

方法還沒有執行,所有的欄位都還為零值。所以一般來說,執行new指令之後會緊接著執行

init

方法,把物件按照程式設計師的意願進行初始化,這樣一個真正可用的物件才算完全產生。

物件的記憶體佈局

文章配圖

在HotSpot虛擬機器中,物件在記憶體中的佈局可以分為3塊區域:物件頭、例項資料和對齊填充。

記憶體分析小工具:

<!—— JOL依賴 ——> org。openjdk。jol jol-core 0。9

物件頭

包括兩部分資訊

第一部分用於儲存物件自身的執行時資料(官方稱為Mark Word):雜湊碼、GC分代年齡、鎖狀態標誌等

32位的HotSpot虛擬機器中,如物件未被同步鎖鎖定的狀態下,Mark Word的有25bit用於儲存物件的hashCode,4個位元用於儲存物件分代年齡,2個位元用於儲存鎖標誌位,1個位元固定為0,在其他狀態下鎖標誌位的狀態如下:

文章配圖

文章配圖

64位的HotSpot虛擬機器中,Mark Word的儲存情況如下:

文章配圖

另一部分是型別指標,即物件指向它的類元資料的指標,虛擬機器透過這個指標確定這個物件是哪個類的例項,如果物件是一個Java陣列,那在物件頭中還必須有一塊用於記錄陣列長度的資料,因為虛擬機器可以透過Java物件的元資料確定Java物件的大小,如果陣列的長度不確定,將無法透過元資料資訊推斷出陣列的大小。

例項資料部分是物件真正儲存的有效資訊,

也是程式中所定義的各種型別的欄位內容,無論是從父類繼承下來的,還是在子類中定義的欄位都必須記錄下來。這部分的儲存順序會受到虛擬機器分配策略引數(-XX:FieldsAllocationStype引數)和欄位在Java原始碼中定義的順序影響

HotSpot預設的分配順序為longs/doules、ints、shorts/chars,bytes/booleans、oops(Ordinary Objects Poninters,OOPs),相同大小的欄位總是被分配到一起儲存,在滿足這個條件的情況下, 父類中定義的變數會出現在子類之前。如果使用

-XX:CompactFields=true(預設為true)

,那子類中較小的變數也允許插入父類變數的空隙中,以省出一點點空間。

對齊填充部分不是天然存在的,也沒有什麼特別的含義,僅僅起佔位作用。

因為Hotspot的自動記憶體管理系統要求物件起始地址必須是8位元組的整數倍,換句話說就是物件的大小必須是8位元組的整數倍。而物件頭部分正好是8位元組的倍數(1倍或者2倍),因此,當物件的例項資料部分沒有對齊的時候,就需要透過對齊填充來補全,。

物件的訪問定位

建立物件就是為了使用物件,我們的Java程式透過棧上的reference資料來操作堆上的具體物件。物件的訪問方式由虛擬機器實現而定,主流的訪問方式有兩種:

控制代碼:如果使用的控制代碼的方式,那麼虛擬機器的堆記憶體中還會分出一塊用作控制代碼池,Java虛擬機器棧的本地變量表中reference中儲存的就是物件的控制代碼地址,而控制代碼中包含了物件例項資料與型別資料各自的具體地址資訊;

文章配圖

直接指標(HotSpot使用的):如果使用直接指標訪問,那麼Java堆物件的佈局中就必須考慮如果放置訪問型別資料的相關資訊,而reference中儲存的直接就是物件堆中的地址

文章配圖

這兩種訪問物件的方式各有優勢。使用控制代碼來訪問的最大的好處是reference中儲存的是穩定的控制代碼地址,在物件移動的時候只改變控制代碼中的例項資料指標,而reference本身不需要修改。使用直接指標訪問方式最大的好處就是訪問速度快,它節省了一次指標定位的時間開銷。

重點:String類和常量池

String建立物件的兩種方式

String str1 = “abcd”;String str2 = new String(“abcd”);System。out。println(str1==str2);//false

這兩種建立方式是不同的,第一種是直接從常量池中把“abcd”的地址拿出來了;第二種透過new建立,會在堆中建立物件例項“abcd”。因此str1和str2中持有的分別是常量池的地址和堆的地址,false。

文章配圖

String型別的常量池比較特殊。使用方式:

直接使用雙引號宣告出來的String物件會直接儲存在常量池中。

如果不使用雙引號宣告的String物件,可以使用String提供的intern()方法,String。intern()是一個Native方法,作用是:如果常量池中已經包含一個等於此String物件內容的字串,則返回常量池中該字串的引用;如果沒有,則在常量池中創建於此String內容相同的字串,並返回常量池中建立的字串的引用。

String s1 = new String(“計算機”);String s2 = s1。intern();//s2持有的是常量池中字串的地址String s3 = “計算機”;System。out。println(s2);//計算機System。out。println(s1 == s2);//false,因為一個是堆記憶體中的String物件一個是常量池中的String物件,System。out。println(s3 == s2);//true,因為兩個都是常量池中的String物件

字串拼接

String str1 = “str”;String str2 = “ing”;String str3 = “str” + “ing”;//常量池中的物件String str4 = str1 + str2; //在堆上建立的新的物件 String str5 = “string”;//常量池中的物件System。out。println(str3 == str4);//falseSystem。out。println(str3 == str5);//trueSystem。out。println(str4 == str5);//false

儘量避免使用拼接字串,因為這樣會建立多個物件,如果需要改變字串的話,可以使用Stringbuilder或者Stringbuffer。

String s = new String(“a”)建立了幾個物件

建立了兩個物件。

驗證:

String s1 = new String(“abc”);// 堆記憶體的地址值String s2 = “abc”;System。out。println(s1 == s2);// 輸出false,因為一個是堆記憶體,一個是常量池的記憶體,故兩者是不同的。System。out。println(s1。equals(s2));// 輸出true

解釋:先有字串“abc”放入常量池,然後new了一個字串“abc”放入Java堆(字串常量“abc”在編譯期就已經確定放入常量池,而Java堆上的“abc”是在執行期初始化階段才確定),然後Java虛擬機器棧的s1指向Java堆上的“abc”。

String#intern

JDK6中,intern方法會把首次遇到的字串例項複製到永久代的字串常量池中儲存,返回的也是永久代裡面這個字串引用。

JDK7中,intern方法實現就不需要再複製字串的例項到永久代了,既然字串常量池已經移到Java堆中,那隻需要在常量池中記錄一下首次出現例項的引用即可。

下面這段程式碼在JDK6、7中的執行結果不同

public class RuntimeConstantPoolOOM { public static void main(String[] args) { String str1 = new StringBuilder(“計算機”)。append(“軟體”)。toString(); System。out。println(str1。intern() == str1); String str2 = new StringBuilder(“ja”)。append(“va”)。toString(); System。out。println(str2。intern() == str2); }}JDK6:false false str1是虛擬機器棧區域性變數引用,str1。intern()持有的是“計算機軟體”在永久代的引用。 str2持有的是虛擬機器棧區域性變數引用,JDK7:true false str

8種基本資料型別的包裝類和常量池

Java基本資料型別的包裝類的大部分都實現了常量池技術,即Byte、Short、Long、Character、Boolean;這5種包裝類預設建立了數值[-128,127]的相應型別的快取資料,但是超出此範圍仍然會去建立新的物件。

兩種浮點數型別的包裝類Float、Double並沒有實現常量池技術。

Integer i1 = 33;Integer i2 = 33;System。out。println(i1 == i2);// 輸出trueInteger i11 = 333;Integer i22 = 333;System。out。println(i11 == i22);// 輸出falseDouble i3 = 1。2;Double i4 = 1。2;System。out。println(i3 == i4);// 輸出false

Integer快取原始碼:

/***此方法將始終快取-128到127(包括端點)範圍內的值,並可以快取此範圍之外的其他值。*/public static Integer valueOf(int i) { if (i >= IntegerCache。low && i <= IntegerCache。high) return IntegerCache。cache[i + (-IntegerCache。low)]; return new Integer(i);}

應用場景:

1、Integer i1 = 40;Java在編譯的時候會直接將程式碼封裝成Integer i1 = Integer.valueOf(40),從而使用常量池中的物件

2、Integer i1 =new Integer(40);這種情況下會建立新的物件;

Integer i1 = 40;Integer i2 = new Integer(40);System。out。println(i1==i2);//輸出false

Integer比較更豐富的一個例子:

Integer i1 = 40;Integer i2 = 40;Integer i3 = 0;Integer i4 = new Integer(40);Integer i5 = new Integer(40);Integer i6 = new Integer(0);System。out。println(“i1=i2 ” + (i1 == i2));System。out。println(“i1=i2+i3 ” + (i1 == i2 + i3));System。out。println(“i1=i4 ” + (i1 == i4));System。out。println(“i4=i5 ” + (i4 == i5));System。out。println(“i4=i5+i6 ” + (i4 == i5 + i6)); System。out。println(“40=i5+i6 ” + (40 == i5 + i6));

結果:

i1=i2 truei1=i2+i3 truei1=i4 falsei4=i5 falsei4=i5+i6 true40=i5+i6 true

解釋:

語句i4==i5 + i6,因為+這個運算子不適用於Integer物件,首先i5和i6進行自動拆箱操作,進行數值相加,即i4 == 40。然後Integer物件無法與數值進行直接比較,所以i4自動拆箱轉為int值40,最終這條語句轉為40 == 40進行數值比較。

本文由部落格一文多發平臺 OpenWrite 釋出!