首頁 > 科技

萬字長文!阿里P8技術大牛整理的JVM物件及物件記憶體佈局,超全

2021-06-07 21:00:14

類與物件

在編譯時,通過Javac編譯器為虛擬機器規範的class檔案格式。class檔案格式是與作業系統和機器指令集無關的、平臺中立的格式。其他語言編寫的程式碼只需要實現指定語言的編譯器編譯位JVM規範標準的class檔案就可以實現該語言運行在JVM之上,這就是JVM的語言無關性。

通過java命令運行class檔案,首先會通過類載入器將class檔案載入到記憶體中,載入class檔案會為類生成一個klass例項。在klass包含了用於描述Java類的元資料,包括欄位個數、大小、是否為陣列、是否有父類、方法資訊等。

物件類二分模型

HotSpot虛擬機器是使用C++實現的, C++也是面嚮物件語言。可以採用java類一一對映到C++類,當創建Java物件就創建對應的C++類的物件。

但是由於如果C++的物件含有虛擬函式,則創建的物件會有虛方法表指針,指向虛方法表。如果採用這種直接一對一對映的方式,會導致含有虛方法的類創建的物件都包含虛方法指針。因此在HotSpot虛擬機器中,通過物件類二分模型,將類描述資訊和例項資料進行拆分。使用僅包含資料不包含方法的oop(Ordinary Object Pointer)物件描述Java的物件,使用klass描述java的類。oop的職責在於表示物件例項資料,沒必要維護虛擬函式指針。通過oop物件頭部的一個指針指向對應的klass物件進行關聯。

在HotSpot虛擬機器中,普通物件的類通過instanceKlass表示,物件例項則通過instanceOopDesc表示。

在JVM中引用類型可以分為物件,基本類型陣列和物件類型陣列。可以分別對映到Java中的對應的物件和類型。

除了常用的3類引用物件外,還有一些其他JVM自己要用的java.lang.ClassLoader用InstanceClassLoaderKlass描述,java.lang.Class用InstanceMirrorKlass描述等。

物件

HotSpot VM使用oop描述物件,oop字面意思是「普通物件指針」。它是指向一片記憶體的指針,只是將這片記憶體‘視作’(強制類型轉換)Java物件/陣列。物件的本質就是用物件頭和欄位資料填充這片記憶體。

物件記憶體佈局

JOL工具

在談論具體物件佈局時,推薦一個JOL工具,可以列印物件的記憶體佈局。通過maven引入。

通過ClassLayout.parseInstance(new Object()).toPrintable()即可列印物件的記憶體佈局。

物件頭

普通物件的物件頭包含2部分,第一部分被稱為Mark Word,第二部分為類型指針。如果物件為陣列,除了普通物件的兩部分外物件頭還包含陣列長度。下圖是64位虛擬機器物件頭。

32位虛擬機器頭部的Mark Word長度為4個位元組。

Mark Word

Mark Word儲存了物件運行時必要的資訊,包括雜湊碼(HashCode)、GC分代年齡、偏向狀態、鎖狀態標誌、偏向執行緒ID、偏向時間戳等資訊。通過類型指針,可以找到物件對應的類型資訊。32位虛擬機器和64位虛擬機器的Mark Word長度分別為4位元組和8位元組。

不論是32位還是64位虛擬機器的物件頭部都使用了4位元記錄分代年齡,每次GC時物件倖存年齡都會加1,因此物件在survivor區最多幸存15次,超過15次時,仍然有可達根的物件就會從survivor區被轉移到老年代。可以通過-XX:MaxTenuringThreshold=15參數修改最大幸存年齡。

CMS垃圾回收器預設為6次。

類型控制代碼

相比32位物件頭大小,64位物件頭更大一些,64位虛擬機器物件頭的Mark Word和類型指針地址都是8位元組。而通常情況,我們的程式不需要佔用那麼大的記憶體。因此虛擬機器通過壓縮指針功能,將物件頭的類型指針進行壓縮。而Mark Word由於運行時需要儲存的頭部資訊會大於4位元組,仍然使用8位元組。若配置開啟了-XX:+UseCompressedOops,虛擬機器會將類型指針地址壓縮為32位。若配置開啟了-XX:+UseCompressedClassPointers,則會壓縮klass物件的地址為32位。

需要注意的是,當地址經過壓縮後,定址範圍不可避免的會降低。對於64位CPU,由於目前記憶體一般到不了2^64,因此大多數64位CPU的地址匯流排實際會小於64位,比如48位。開啟-XX:+UseCompressedOops,預設也會開啟-XX:+UseCompressedClassPointers。關閉-XX:+UseCompressedOops,預設也會關閉-XX:+UseCompressedClassPointers。如果開啟-XX:+UseCompressedOops,但是關閉-XX:+UseCompressedClassPointers,啟動虛擬機器的時候會提示「Java HotSpot(TM) 64-Bit Server VM warning: UseCompressedClassPointers requires UseCompressedOops」。

普通物件記憶體佈局(64位虛擬機器指針壓縮時)

需要注意,由於記憶體按小端模式分佈,因此顯示的內容是反著的。上面實際物件頭內容為 00000000 00000001 f80001e5

陣列物件記憶體佈局(64位虛擬機器指針壓縮時)

物件頭與鎖膨脹

物件頭中儲存了鎖的必要資訊,不同的鎖的物件頭儲存內容稍有不同。32位物件頭儲存格式如下

JVM底層對加鎖進行了效能優化,預設虛擬機器啟動後大約4秒會開啟偏向鎖功能。當虛擬機器未啟用偏向鎖時,鎖的演化過程為無鎖->輕量鎖(自旋鎖)->重量鎖。當虛擬機器啟用了偏向鎖時,鎖的演化過程為無鎖->偏向鎖->輕量鎖(自旋鎖)->重量鎖。

本文不討論JVM對加鎖的具體優化邏輯,內容比較多,感興趣的可以看同學可以參考《淺談偏向鎖、輕量級鎖、重量級鎖》。

無鎖

當物件未加鎖時,鎖狀態為01,32位虛擬機器的物件頭部如圖所示

需要注意的是其中物件頭儲存的hashCode被稱為identityHashCode,當我們呼叫物件的hashCode方法,返回的就是該值。若我們重寫了hashCode的值,物件頭的hashCode值仍然是內部的identityHashCode,而不是我們重寫的hashCode值。可以通過System.identityHashCode列印identityHashCode,或者也可以通過toString直接列印物件輸出16進位制的identityHashCode。

偏向鎖

偏向鎖中的「偏」,就是偏心的「偏」、偏袒的「偏」。它的意思是這個鎖會偏向於第一個獲得它的執行緒,如果在接下來的執行過程中,該鎖一直沒有被其他的執行緒獲取,則持有偏向鎖的執行緒將永遠不需要再進行同步。

偏向鎖的鎖狀態和未鎖狀態一樣都是01,當物件處於偏向狀態時,偏向標記為1;當物件處於未偏向時,偏向標記為0。

32位虛擬機器的偏向鎖物件頭部如圖所示

偏向時間戳,它實際表示偏向的有效期。

無鎖狀態升級為偏向鎖的條件:

物件可偏向,物件未加鎖時,執行CAS更新物件頭部執行緒偏向執行緒ID為當前執行緒成功。物件可偏向,物件已加鎖,但偏向執行緒ID為空,執行CAS更新物件頭部執行緒偏向執行緒ID為當前執行緒成功。物件可偏向,物件已加鎖,且偏向執行緒ID等於當前執行緒ID。物件可偏向,物件已加鎖,且偏向執行緒ID不為空且不等於當前執行緒ID,執行CAS更新物件頭部執行緒偏向執行緒ID為當前執行緒成功。虛擬機器啟動時,會根據-XX:BiasedLockingStartupDelay配置延遲啟動偏向,在JDK1.8中,預設為4秒。有需要時可以通過-XX:BiasedLockingStartupDelay=0關閉延時偏向。

輕量級鎖

輕量級鎖的鎖狀態為00,32位虛擬機器的輕量級鎖頭部格式如下

升級為輕量級鎖條件:

物件不可偏向,跳過偏向鎖直接使用輕量級鎖。物件可偏向,但偏向加鎖失敗(存線上程競爭)。物件獲取呼叫hashCode後加鎖。物件已升級為重量級鎖後,鎖降級只能降級為輕量級鎖,無法降級為偏向鎖。輕量級鎖會線上程的棧幀中開闢一個鎖記錄區域,將當前物件的頭部儲存在鎖記錄區域中,將鎖記錄區域的地址儲存到當前物件頭部。

物件不可偏向直接升級到輕量鎖

偏向鎖競爭升級為輕量鎖

偏向後呼叫hashCode方法升級為輕量級鎖

重量級鎖

輕量級鎖的鎖狀態為10,32位重量級鎖頭部如圖所示

輕量級鎖自迴圈一定次數後一致獲取不到鎖,則升級為重量級鎖條件。自旋次數預設為10次,可以通過-XX:PreBlockSpin配置修改次數。

重量級鎖降級

當重量級鎖解鎖後就會進行鎖降級,鎖降級只能降級為輕量鎖,無法再使用偏向鎖。

例項資料

物件例項資料預設按照long、double、int、short、char、byte、boolean、reference順序佈局,相同欄位寬度總是分配在一起。若有父物件,則父物件的例項欄位在子物件前面。另外如果HotSpot虛擬機器的 +XX:CompactFields參數值為true(預設就為true),那子類之中較窄的變數也允許插入父類變數的空隙之中,以節省出一點點空間。

填充

JVM中,物件大小預設為8的整數倍,若物件大小不能被8整除,則會填充空位元組來填充物件保證。

物件生命週期

在瞭解完物件頭部後,我們看下物件的創建的時候發生了什麼事情。當我們呼叫new Object()創建一個物件時,生成的位元組碼如下

0 new #2 <java/lang/Object>3 dup4 invokespecial #1 <java/lang/Object.<init>>

首先通過new指令分配物件,並將物件地址入棧,通過dup指令複製一份棧頂元素。通過invokespecial指令呼叫物件的init進行初始化會消耗棧頂2個槽。由於init方法需要傳入一個參數,該參數即為引用物件本身。在init初始化時會將this指針進行賦值。這樣我們在程式碼中就可以通過this指向當前物件。

物件創建流程如下圖所示。

棧上分配通常物件都是在堆上創建的,若物件僅在當前作用域下使用,那麼使用完很快就會被GC回收。JVM通過逃逸分析對物件作用域進行分析,如果物件僅在當前作用域下使用,則將物件的例項資料分配在棧上,從而提升物件創建速度的同時減少GC回收的物件數量。執行緒局部緩衝區(TLAB)如果無法在棧上分配,則物件會在堆上分配。對於JDK1.8來說,Java堆通常使用分代模型,(關於GC,垃圾回收演算法等這裡不做具體討論)。經過統計,90%的物件在使用完成後都會被回收,因此預設新生代會分配10%的空間給倖存者區。物件先在eden區進行分配,但是我們知道,堆是所有執行緒共享的區域,會存在多執行緒併發問題。因此在堆上分配就需要進行執行緒同步。為了提高分配效率,JVM會為每個執行緒從eden區初始化一塊堆記憶體,該記憶體是執行緒私有的。這樣每次分配物件時就無需進行同步操作,從而提高物件分配效率。執行緒的這塊局部記憶體區域被稱為執行緒局部緩衝區(TLAB)。通常這塊記憶體會小於eden區的1%。當這塊記憶體用完時,就會重新通過CAS的方式為執行緒重新分配一塊TLAB。通常物件分配有兩種方式,一種是線性分配,當記憶體是規整時(大部分垃圾回收器新生代都是用標記清理演算法,可以保證記憶體規整),通過一個指針向後移動物件大小,直接分配一塊記憶體給物件,指針左邊是已使用的記憶體,指針右邊是未使用的記憶體,這種方式被稱為指針碰撞。TLAB配合指針碰撞技術能夠線上程安全的情況下移動一次指針直接就可以完成物件的記憶體分配。當記憶體不規整時(比如CMS垃圾回收器通常情況並不會每次GC後都壓縮記憶體,會存在記憶體碎片),則需要一塊額外的記憶體記錄哪些記憶體是空閒的,這個快取被稱為空閒列表eden區分配如果TLAB無法分配物件,那麼物件只能在Eden區直接分配,前面說過,在堆上分配,必須採用同步策略避免有產生執行緒安全問題。如果分配記憶體時,物件的klass沒有解析過,則需要先進行類載入過程,然後才能分配物件。這個過程被稱為慢速分配,而如果klass已解析過則直接可以分配物件,這個過程被稱為快速分配老年代分配當eden區放不下物件時(當然還有其他的判斷策略,這裡暫時不去關心),物件直接分配到老年代。物件例項初始化當物件完成記憶體分配時,就會初始化物件,將記憶體清零。需要注意,物件的靜態變數在類初始化的初始化階段已經完成設定。初始化物件頭部當物件例項初始化完,就會設定物件頭部,預設的物件頭部存放在klass,如果啟用了偏向,則設定的就是可偏向的物件頭。物件訪問方式

現在我們瞭解了物件的記憶體佈局和物件的創建邏輯,那麼物件在運行時,如何通過棧的局部變數找到實際的物件呢?常用的物件訪問方式有2種,直接指針訪問控制代碼訪問

直接指針訪問

物件創建時,局部變量表只儲存物件的地址,地址指向的是堆中的實際物件的markword地址,JVM中採用的就是這種方式訪問物件。

控制代碼訪問

通過控制代碼訪問時局部變數儲存的時控制代碼池的物件控制代碼,控制代碼池中,則會儲存物件例項指針和物件類型指針。再通過這兩個指針分別指向物件例項池中的物件和元資料的klass。

相比直接指針訪問,這種訪問方式由於需要2次訪問,而直接指針只需要一次訪問,因此控制代碼訪問物件的速度相對較慢。但是對於垃圾回收器來說是比較友好的,因為物件移動無需更新棧中的局部變量表的內容,只需要更新控制代碼池中的物件例項指針的值。

HSDB

前面我們通過JOL工具可以很方便的輸出物件的佈局。JDK也提供了一些工具可以檢視更詳細的運行時資料。HSDB(Hotspot Debugger) 是 JDK1.8 自帶的工具,使用該工具可以連線到運行時的java程序,檢視到JVM運行時的狀態。

以該偏向鎖程式碼為例

為了能看到運行時狀態,我們可以使用idea工具單筆偵錯,也可以使用jdb工具進行偵錯。jdb是Java的偵錯程式,位於%JAVA_HOME%/bin下面。 通過jdb -classpath XXX class名 執行main方法。執行後,我們可以將打斷點,然後進行偵錯。

通過stop in <class id>.<method>[(argument_type,...)]在方法中打斷點,或者可以通過stop at <class id>:<line>在指定行打斷點。通過stop in com.company.BiasedLock.main將斷點打在main方法。通過run運行通過next進行偵錯。(可以使用step進行單步偵錯)

此時我們可以通過HSDB連線到程序。通過JPS 命令檢視程序的pid

通過java -cp 「.;%JAVA_HOME%/lib/sa-jdi.jar」 sun.jvm.hotspot.HSDB 3828 啟動HSDB(這種方式會阻塞我們的程式,不要直接在生產環境這樣操作)第一次啟動可能會報錯誤無法找到sawindbg.dll,這時需要將%JAVA_HOME%/lib目錄下面的sawindbg.dll檔案拷貝到jre的/lib目錄下即可。

啟動後,在介面選中main執行緒,點選工具欄第二個圖片開啟執行緒棧。

HSDB工具線上程棧中已經標出我們的物件。在選單找到記憶體檢視器

輸入棧局部變量表中的物件的地址,就可以顯示出物件的記憶體,和JOL工具列印的物件頭部是一樣的。


IT145.com E-mail:sddin#qq.com