詳解JVM記憶體管理與垃圾回收機制3 - JVM中物件的記憶體佈局
在Java語言層面,可以通過Class類來描述普通的Java類,當JVM建立物件的同時,會生成對應的Class物件,用來描述此物件的大致模型,這也是反射的基礎。那麼在JVM的內部是如何描述一個普通的物件?我們先從一個簡單的示例著手,這有一個Child類:
public class Child extends Person implements Action { // 小孩上幾年級 public int grade; // Action介面就一個動作:walk @Override public void walk() { } }
通過 Child child = new Child()
來建立物件時,JVM在堆中開闢空間存放物件例項資料的同時,會在棧中建立該物件的引用,用於存放child物件在堆記憶體中的首地址,大致的示意圖如下所示。

新建立物件記憶體佔用示意圖
現在請大家思考:站在JVM的角度,要完整地描述執行時的child物件,需要記錄哪些資訊?
腦袋裡可能馬上就會跳出來這些資訊:
- 物件所屬類的相關資訊: 類(包含父類)的名稱、實現了哪些介面、是否有註解、方法列表、屬性列表、常量等
- 例項資料:物件儲存的有效資訊,比如物件各個屬性儲存的具體內容
除了這些呢?其實還有一些執行時的資料,比如:鎖資訊、執行緒ID、GC標記等。
JVM是如何記錄這些資訊的呢?HotSpot VM採用OOP-Klass的模型來描述Java物件例項。
Klass
Klass系物件 ( instanceKlass
、 arrayKlass
等) 用於描述物件的元資料,其中 instanceKlass
可以認為是 java.lang.Class
的VM級別的表示,但它們並不等價, instanceKlass
主要作用於整個程式執行過程中,而 Class
類只用於Java的反射API,接下來將以 instanceKlass
為例來介紹Klass,其它物件與之類似。
Klass類定義了所有Klass型別共有的資料結構和行為,比如型別名稱、與其它類之間的關係、訪問識別符號等等,具體可參看:
// 程式碼來自於hotspot/src/share/vm/oops/klass.hpp class Klass : public Metadata { // 反映物件整體佈局的描述符,在32位系統中佔用4個位元組 // 如果值為正數,表示物件大小,如果值為負數,表示陣列 jint_layout_helper; // 類名稱,比如:"java/lang/String"表示String物件 // 而[Ljava/lang/String描述String型別的一維陣列 Symbol*_name; // 對應的Java語言層面的Class物件例項 oop_java_mirror; // 父類,指標指向其父類的首地址 Klass*_super; // 第一個子類 Klass*_subklass; // subklass指向第一個子類,如果有多個子類 // 那麼可以通過_subklass->next_sibling()找到下一個子類 Klass*_next_sibling; // Java 中類名和類載入器唯一標識了一個類 // 由同一個類載入器載入的類通過 _next_link 連線起來 Klass*_next_link; ClassLoaderData*_class_loader_data; // 訪問識別符號,Java層面通過 Class.getModifiers()獲取 // 比如:1表示public jint_modifier_flags; // 類或者介面的訪問修飾符 AccessFlags_access_flags; // ......
HotSpot中為每一個已載入的Java類建立一個 instanceKlass
物件,用於在JVM層面表示Java類,它包含了虛擬機器內部執行一個類所需要的全部資訊,這些成員變數在類的解析階段 (主要是將常量池中的符號引用轉換為直接引用,即執行時實際記憶體地址) 完成賦值:
// 程式碼來自於hotspot/src/share/vm/oops/instanceKlass.hpp class InstanceKlass: public Klass { // 註解 Annotations*_annotations; // 常量 ConstantPool*_constants; // 方法列表 Array<Method*>* _methods; // 方法順序 Array<int>*_method_ordering; Array<Method*>* _default_methods; // 實現的介面 Array<Klass*>*_local_interfaces; // 繼承來的介面 Array<Klass*>*_transitive_interfaces; // 靜態變數的數量 u2_static_oop_field_count; // 成員變數的數量 u2_java_fields_count; // ......
接下來以文章開頭的 Child
物件為例,觀察程式執行過程中Child型別的Klass資訊,以加深大家的理解。
Child類繼承Person類並實現的Action的所有介面,通過HSDB來探測Klass物件資訊,如下圖所示,首先通過HSDB的 Class Browser
工具列出所有的類,找到我們定義的類,比如Person類例項的記憶體地址為:0x00000007c0060210,然後使用這個記憶體地址到 Inspector
中搜索,即可得到Person類在HotSpot內部instanceKlass型別的全貌,如下圖所示。

HSDB
從圖中可以得到,Person類的其中一個子類的Klass物件記憶體地址 _subklass:Klass @ 0x00000007c0060408
,通過這個地址可以在 Code Browser
中很方便的查詢到其對應的類是: Child
。除此之外,還可以找到一些非常熟悉的屬性:
- _super: Klass @ 0x00000007c0000f28 Person類的父類是Object類
- _mofifier_flags: 1 表示 public
- _name: Symbol @ 0x00007ff686715e00 類名稱,String物件的記憶體地址
- _layout_helper: 24 值為正數,表示物件的大小
- _methods: Array<Method > @ 0x00000001171558f0 * 方法列表
- ……
屬性太多,這裡無法一一列舉,鼓勵大家自己嘗試,隨便也學習一下怎麼使用HSDB來分析JVM內部的資料結構和狀態,但不鼓勵鑽牛角尖似的非要弄清楚每個屬性的含義和作用,至少在當前是不需要的。
再回到 instanceKlass.hpp
裡面,物件的註解、常量以及方法,在VM中分別使用 Annotations
、 ConstantPool
、 Method
來描述,它們同 Klass
一樣,均繼承自 Metadata
或者 MetaspaceObj
類。
在 HotSpot JVM 中,永久代中用於存放類和方法的元資料以及常量池,比如Class和Method。每當一個類初次被載入的時候,它的元資料都會放到永久代中。
需要注意的是,在JDK1.8中已經引入 Metaspace (元空間)
來替換原來的永久代 PermGen
,因此,JDK1.8裡的物件模型實現與1.7有很大的不同。通過上文的分析,希望能夠加深你對這句話的理解。
OOP
OOP用來描述物件的例項資訊,在Java程式執行過程中,每建立一個Java物件,在JVM內部也會相應的建立一個OOP物件來表示Java物件。oop的定義 oopDesc
如下 (oop相關類的定義均會在名稱後面新增字尾Desc,比如: instanceOopDesc
):
class oopDesc { private: // Mark Word volatile markOop_mark; // 元資料 // 使用了union來宣告metadata是為了在64位機器上對物件指標進行壓縮 union _metadata { Klass*_klass; narrowKlass _compressed_klass; } _metadata;
整個 oopDesc
定義瞭如下資訊:
- _mark (Mark Word):,雜湊碼,GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳
- _metadata (元資料指標):指向描述型別的Klass物件指標,Klass物件包含了例項物件所屬型別的元資料
在 _metadata
中包含一個壓縮指標,在32位系統中,物件的指標長度是32位,而在64位系統中,指標長度為64位。在64位系統剛剛興起的年代,對於那些從32位系統遷移到64位系統的引用來說,平白無故的多了差不多50%的記憶體佔用 (主要是指指標佔用的記憶體,非整個應用的記憶體佔用),基於節約記憶體的考量,可以在64位系統上對指標佔用的記憶體進行壓縮,更多的內容可以參考: -XX:+UseCompressedOops
引數。
Mark Word
儲存物件自身的執行時資料,其被設計成一個非固定的資料結構,可在極小的空間記憶體儲儘量多的資訊,它會根據自己的狀態複用自己的儲存空間。比如,在32位系統中,如果物件處於無鎖狀態,那麼 Mark Word
的32bit空間中的25個bit用於儲存物件的hash值,4bit用於儲存物件的分代年齡,2bit用於儲存鎖標誌位,1bit用於儲存鎖的型別;而當物件處於有鎖狀態下,根據鎖的型別不同,儲存的資料又不同,具體的示意圖如下:

Mark Word
關於表格中涉及到關於鎖的資訊僅做如下說明,更多相關內容可以關注後面的文章:
- 重量級鎖採用互斥量來控制對互斥資源的訪問,而輕量級鎖通過CAS機制來實現,因此,兩種鎖的重要區別是:拿到“鎖”時,是否存線上程排程和切換上下文的開銷。
- 在拿到“鎖”這樣的描述中,“鎖”所指的內容並不一致,重量級鎖只要拿到互斥訊號,即拿到鎖,而CAS操作通過compare是否成功來判斷是否拿到鎖,因而我們常說的鎖,其本質上是是否滿足某種條件。因此,注意表格中關於指向指標的描述。
- 幾種鎖競爭情況由弱到強分別是:無鎖 -> 偏向鎖 -> 輕量級鎖 -> 重量級鎖。
- Mark Word中如果記錄了執行緒ID,則認為該執行緒獲得了鎖,如果將執行緒ID清空,則認為自己釋放了鎖,當然還伴隨著鎖標誌位的改變。執行緒將自己的ID與Mark Word中的執行緒ID對比,就知道自己是否拿到當前訪問物件的鎖。
- 如果當前物件被鎖住,那麼該MarkWord中儲存著對應執行緒的ID,通過鎖標誌位、是否偏向鎖、執行緒ID等幾個值可以區分當前物件是否被鎖以及被誰鎖住。你可能會有個疑問,輕量級鎖和重量級鎖的MarkWord中並沒有執行緒ID,那麼怎麼區分是被哪個執行緒鎖住的呢?其實在輕量級鎖加鎖的過程中,會拷貝MarkWord到鎖記錄中去,因此只要知道指向鎖記錄的指標,也就知道鎖的執行緒ID。那重量級鎖呢?由於重量級鎖是通過獲取互斥訊號量的方式,那麼這個互斥訊號量是否屬於當前的執行緒,其實當前執行緒是能夠判斷的,這時候,執行緒ID就變得沒有太大的意義了。
總結
在HotSpot虛擬機器中,物件在記憶體中的佈局主要分為3個部分:物件頭、例項資料、對齊填充,其示意圖如下:

物件記憶體結構示意圖
其中,物件頭主要儲存物件的狀態資訊以及類的元資料指標,虛擬機器可以通過這個指標訪問到這個類對應的所有型別資訊;而例項資料則是物件真正儲存的有效性資訊,即在程式程式碼中鎖定義的各種型別的欄位內容;對其填充不是一定存在的,也沒有特殊的含義,僅僅起到佔位的作用:HotSpot要求物件起始地址必須是8位元組的整數倍,也就是說物件的大小必須是8的整數倍,因此,當例項資料部分大小不滿足8的整數倍時,就需要通過佔位符來填充。
最後需要關注的一點是,陣列例項相對於物件例項,多了一個數組長度。
引用 ( Reference
) 將記憶體中的一個又一個物件連線起來,那何為引用?請繼續關注下一個小節。
參考資料
- ofollow,noindex">Thread as a GC root - Stack Overflow
- JVM 中,InstanceKlass、java.lang.Class的關係?
- Java併發程式設計:Synchronized底層優化