java物件在記憶體中的結構
我們都知道在Java語言規範已經規定了int的大小是4個位元組,那麼Integer物件的大小是多少呢?要知道一個物件的大小,那麼必須需要知道物件在虛擬機器中的結構是怎樣的,來看看Hotspot中物件在記憶體中的結構:
從上面的這張圖裡面可以看出,物件在記憶體中的結構主要包含以下幾個部分:
- Mark Word:物件的Mark Word部分佔4個位元組,其內容是一系列的標記位,比如輕量級鎖的標記位,偏向鎖標記位等等。
- Class物件指標:Class物件指標的大小也是4個位元組,其指向的位置是物件對應的Class物件(其對應的元資料物件)的記憶體地址
- 物件實際資料:這裡麵包括了物件的所有成員變數,其大小由各個成員變數的大小決定,比如:byte和boolean是1個位元組,short和char是2個位元組,int和float是4個位元組,long和double是8個位元組,reference是4個位元組
- 對齊:最後一部分是對齊填充的位元組,按8個位元組填充。
根據上面的圖,那麼我們可以得出Integer的物件的結構如下:
Integer只有一個int型別的成員變數value,所以其物件實際資料部分的大小是4個位元組,然後再在後面填充4個位元組達到8位元組的對齊,所以可以得出Integer物件的大小是16個位元組。
因此,我們可以得出Integer物件的大小是原生的int型別的4倍。
關於物件的記憶體結構,需要注意陣列的記憶體結構和普通物件的記憶體結構稍微不同,因為資料有一個長度length欄位,所以在物件頭後面還多了一個int型別的length欄位,佔4個位元組,接下來才是陣列中的資料,如下圖:
【轉】HotSpot虛擬機器物件探祕
0人收藏此文章, 我要收藏 發表於8個月前(2012-02-19 23:29) , 已有44次閱讀 共0個評論
請讀者首先注意本篇的題目中的限定語“HotSpot虛擬機器”,在虛擬機器規範中明確寫道, 所有在虛擬機器規範之中沒有明確描述的實現細節,都不應成為虛擬機器設計者發揮創造性的牽絆,設計者可以完全自主決定所有規範中不曾描述的虛擬機器內部細節。 例 如:執行時資料區的記憶體如何佈局、選用哪種垃圾收集的演算法等”。因此,本篇(整個記憶體篇中所有的文章)的內容會涉及到虛擬機器“自主決定”的實現,我們的討 論將在HotSpot VM的範圍內展開。同時,我也假定讀者已經理解了虛擬機器規範中所定義的JVM公共記憶體模型,例如執行時資料區域、棧幀結構等基礎知識,如果讀者對這些內容 有疑問,可以先閱讀《Java虛擬機器規範(JavaSE 7 Editon)》[注1]第2章或《深入理解Java虛擬機器:JVM高階特性與最佳實踐》[注2]的第2、3章相關內容。
物件的建立
Java是一門面向物件的程式語言,Java程式執行過程中無時無刻都有物件被創建出來。在語言層面上,建立物件通常(例外:克隆、反序列化)僅僅是一個 new關鍵字而已,而在虛擬機器中,物件(本文中討論的物件限於普通Java物件,不包括陣列和Class物件等)的建立又是怎樣一個過程呢?
虛擬機器遇到一條new指令時,首先將去檢查這個指令的引數是否能在常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已被載入、解析和初始化過的。如果沒有,那必須先執行相應的類載入過程。
在類載入檢查通過後,接下來虛擬機器將為新生物件分配記憶體。物件所需記憶體的大小在類載入完成後便可完全確定,為物件分配空間的任務具體便等同於一塊確定大小 的記憶體從Java堆中劃分出來,怎麼劃呢?假設Java堆中記憶體是絕對規整的,所有用過的記憶體都被放在一邊,空閒的記憶體被放在另一邊,中間放著一個指標作 為分界點的指示器,那所分配記憶體就僅僅是把那個指標向空閒空間那邊挪動一段與物件大小相等的距離,這種分配方式稱為“指標碰撞”(Bump The Pointer)。如果Java堆中的記憶體並不是規整的,已被使用的記憶體和空閒的記憶體相互交錯,那就沒有辦法簡單的進行指標碰撞了,虛擬機器就必須維護一個列表,記錄上哪些記憶體塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給物件例項,並更新列表上的記錄,這種分配方式稱為“空閒列表”(Free List)。選擇哪種分配方式由Java堆是否規整決定,而Java堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。因 此在使用Serial、ParNew等帶Compact過程的收集器時,系統採用的分配演算法是指標碰撞,而使用CMS這種基於Mark-Sweep演算法的 收集器時(說明一下,CMS收集器可以通過UseCMSCompactAtFullCollection或 CMSFullGCsBeforeCompaction來整理記憶體),就通常採用空閒列表。
除如何劃分可用空間之外,還有另外一個需要考慮的問題是物件建立在虛擬機器中是非常頻繁的行為,即使是僅僅修改一個指標所指向的位置,在併發情況下也並不是 執行緒安全的,可能出現正在給物件A分配記憶體,指標還沒來得及修改,物件B又同時使用了原來的指標來分配記憶體。解決這個問題有兩個方案,一種是對分配記憶體空 間的動作進行同步——實際上虛擬機器是採用CAS配上失敗重試的方式保證更新操作的原子性;另外一種是把記憶體分配的動作按照執行緒劃分在不同的空間之中進行, 即每個執行緒在Java堆中預先分配一小塊記憶體,稱為本地執行緒分配緩衝區,(TLAB ,Thread Local Allocation Buffer),哪個執行緒要分配記憶體,就在哪個執行緒的TLAB上分配,只有TLAB用完,分配新的TLAB時才需要同步鎖定。虛擬機器是否使用TLAB,可以通過-XX:+/-UseTLAB引數來設定。
記憶體分配完成之後,虛擬機器需要將分配到的記憶體空間都初始化為零值(不包括物件頭),如果使用TLAB的話,這一個工作也可以提前至TLAB分配時進行。這 步操作保證了物件的例項欄位在Java程式碼中可以不賦初始值就直接使用,程式能訪問到這些欄位的資料型別所對應的零值。
接下來,虛擬機器要對物件進行必要的設定,例如這個物件是哪個類的例項、如何才能找到類的元資料資訊、物件的雜湊碼、物件的GC分代年齡等資訊。這些資訊存 放在物件的物件頭(Object Header)之中。根據虛擬機器當前的執行狀態的不同,如是否啟用偏向鎖等,物件頭會有不同的設定方式。
在上面工作都完成之後,在虛擬機器的視角來看,一個新的物件已經產生了。但是在Java程式的視角看來,物件建立才剛剛開始——<init>方 法還沒有執行,所有的欄位都為零值。因此一般來說(由位元組碼中是否跟隨有invokespecial指令所決定),new指令之後會接著就是執 行<init>方法,把物件按照程式設計師的意願進行初始化,這樣一個真正可用的物件才算完全創建出來。
下面程式碼是HotSpot虛擬機器bytecodeInterpreter.cpp中的程式碼片段(這個直譯器實現很少機會實際使用,大部分平臺上都使用模板 直譯器;當代碼通過JIT編譯器執行時差異就更大了。不過這段程式碼用於瞭解HotSpot的運作過程是沒有什麼問題的)。
01 |
// 確保常量池中存放的是已解釋的類 |
02 |
if (!constants->tag_at(index).is_unresolved_klass()) { |
03 |
// 斷言確保是klassOop和instanceKlassOop(這部分下一節介紹) |
04 |
oop entry = (klassOop) *constants->obj_at_addr(index); |
05 |
assert (entry->is_klass(), "Should be resolved klass" ); |
06 |
klassOop k_entry = (klassOop) entry; |
07 |
assert (k_entry->klass_part()->oop_is_instance(), "Should be instanceKlass" ); |
08 |
instanceKlass* ik = (instanceKlass*) k_entry->klass_part(); |
09 |
// 確保物件所屬型別已經經過初始化階段 |
10 |
if ( ik->is_initialized() && ik->can_be_fastpath_allocated() ) { |
11 |
// 取物件長度 |
12 |
size_t obj_size = ik->size_helper(); |
13 |
oop result = NULL; |
14 |
// 記錄是否需要將物件所有欄位置零值 |
15 |
bool need_zero = !ZeroTLAB; |
16 |
// 是否在TLAB中分配物件 |
17 |
if (UseTLAB) { |
18 |
result = (oop) THREAD->tlab().allocate(obj_size); |
19 |
} |
20 |
if (result == NULL) { |
21 |
need_zero = true ; |
22 |
// 直接在eden中分配物件 |
23 |
retry: |
24 |
HeapWord* compare_to = *Universe::heap()->top_addr(); |
25 |
HeapWord* new_top = compare_to + obj_size; |
26 |
// cmpxchg是x86中的CAS指令,這裡是一個C++方法,通過CAS方式分配空間,併發失敗的話,轉到retry中重試直至成功分配為止 |
27 |
if (new_top <= *Universe::heap()->end_addr()) { |
28 |
if (Atomic::cmpxchg_ptr(new_top, Universe::heap()->top_addr(), compare_to) != compare_to) { |
29 |
goto retry; |
30 |
} |
31 |
result = (oop) compare_to; |
32 |
} |
33 |
} |
34 |
if (result != NULL) { |
35 |
// 如果需要,為物件初始化零值 |
36 |
if (need_zero ) { |
37 |
HeapWord* to_zero = (HeapWord*) result + sizeof (oopDesc) / oopSize; |
38 |
obj_size -= sizeof (oopDesc) / oopSize; |
39 |
if (obj_size > 0 ) { |
40 |
memset (to_zero, 0, obj_size * HeapWordSize); |
41 |
} |
42 |
} |
43 |
// 根據是否啟用偏向鎖,設定物件頭資訊 |
44 |
if (UseBiasedLocking) { |
45 |
result->set_mark(ik->prototype_header()); |
46 |
} else { |
47 |
result->set_mark(markOopDesc::prototype()); |
48 |
} |
49 |
result->set_klass_gap(0); |
50 |
result->set_klass(k_entry); |
51 |
// 將物件引用入棧,繼續執行下一條指令 |
52 |
SET_STACK_OBJECT(result, 0); |
53 |
UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1); |
54 |
} |
55 |
} |
56 |
} |
物件的記憶體佈局
HotSpot虛擬機器中,物件在記憶體中儲存的佈局可以分為三塊區域:物件頭(Header)、例項資料(Instance Data)和對齊填充(Padding)。
HotSpot虛擬機器的物件頭包括兩部分資訊,第一部分用於儲存物件自身的執行時資料, 如雜湊碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等等,這部分資料的長度在32位和64位的虛擬機器(暫 不考慮開啟壓縮指標的場景)中分別為32個和64個Bits,官方稱它為“Mark Word”。物件需要儲存的執行時資料很多,其實已經超出了32、64位Bitmap結構所能記錄的限度,但是物件頭資訊是與物件自身定義的資料無關的額 外儲存成本,考慮到虛擬機器的空間效率,Mark Word被設計成一個非固定的資料結構以便在極小的空間記憶體儲儘量多的資訊,它會根據物件的狀態複用自己的儲存空間。例如在32位的HotSpot虛擬機器 中物件未被鎖定的狀態下,Mark Word的32個Bits空間中的25Bits用於儲存物件雜湊碼(HashCode),4Bits用於儲存物件分代年齡,2Bits用於儲存鎖標誌 位,1Bit固定為0,在其他狀態(輕量級鎖定、重量級鎖定、GC標記、可偏向)下物件的儲存內容如下表所示。
表1 HotSpot虛擬機器物件頭Mark Word
儲存內容 | 標誌位 | 狀態 |
物件雜湊碼、物件分代年齡 | 01 | 未鎖定 |
指向鎖記錄的指標 | 00 | 輕量級鎖定 |
指向重量級鎖的指標 | 10 | 膨脹(重量級鎖定) |
空,不需要記錄資訊 | 11 | GC標記 |
偏向執行緒ID、偏向時間戳、物件分代年齡 | 01 | 可偏向 |
物件頭的另外一部分是型別指標,即是物件指向它的類的元資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項。並不是所有的虛擬機器實現都必須在物件資料上保留型別指標,換句話說查詢物件的元資料資訊並不一定要經過物件本身。另外,如果物件是一個Java陣列,那在物件頭中還必須有一塊用於記錄陣列長度的資料,因為虛擬機器可以通過普通Java物件的元資料資訊確定Java物件的大小,但是從陣列的元資料中無法確定陣列的大小。
以下是HotSpot虛擬機器markOop.cpp中的程式碼(註釋)片段,它描述了32bits下MarkWord的儲存狀態:
1 |
// Bit-format of an object header (most significant first, big endian layout below): |
2 |
// |
3 |
// 32 bits: |
4 |
// -------- |
5 |
// hash:25 ------------>| age:4 biased_lock:1 lock:2 (normal object) |
6 |
// JavaThread*:23 epoch:2 age:4 biased_lock:1 lock:2 (biased object) |
7 |
// size:32 ------------------------------------------>| (CMS free block) |
8 |
// PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object) |
接下來例項資料部分是物件真正儲存的有效資訊,也既是我們在程式程式碼裡面所定義的各種型別的欄位內容,無論是從父類繼承下來的,還是在子類中定義的都需要記錄下來。 這部分的儲存順序會受到虛擬機器分配策略引數(FieldsAllocationStyle)和欄位在Java原始碼中定義順序的影響。HotSpot虛擬機器 預設的分配策略為longs/doubles、ints、shorts/chars、bytes/booleans、oops(Ordinary Object Pointers),從分配策略中可以看出,相同寬度的欄位總是被分配到一起。在滿足這個前提條件的情況下,在父類中定義的變數會出現在子類之前。如果 CompactFields引數值為true(預設為true),那子類之中較窄的變數也可能會插入到父類變數的空隙之中。
第三部分對齊填充並不是必然存在的,也沒有特別的含義,它僅僅起著佔位符的作用。由於HotSpot VM的自動記憶體管理系統要求物件起始地址必須是8位元組的整數倍,換句話說就是物件的大小必須是8位元組的整數倍。物件頭正好是8位元組的倍數(1倍或者2倍),因此當物件例項資料部分沒有對齊的話,就需要通過對齊填充來補全。
物件的訪問定位
建立物件是為了使用物件,我們的Java程式需要通過棧上的reference資料來操作堆上的具體物件。由於在Java虛擬機器規範裡面只規定了 reference型別 是一個指向物件的引用,並沒有定義這個引用應該通過什麼種方式去定位、訪問到堆中的物件的具體位置,物件訪問方式也是取決於虛擬機器實現而定的。主流的訪問方式有使用控制代碼和直接指標兩種。
如果使用控制代碼訪問的話,Java堆中將會劃分出一塊記憶體來作為控制代碼池,reference中儲存的就是物件的控制代碼地址,而控制代碼中包含了物件例項資料與型別資料的具體各自的地址資訊。如圖1所示。
如果使用直接指標訪問的話,Java堆物件的佈局中就必須考慮如何放置訪問型別資料的相關資訊,reference中儲存的直接就是物件地址,如圖2所示。
這兩種物件訪問方式各有優勢,使用控制代碼來訪問的最大好處就是reference中儲存的是穩定控制代碼地址,在物件被移動(垃圾收集時移動物件是非常普遍的行為)時只會改變控制代碼中的例項資料指標,而reference本身不需要被修改。
使用直接指標來訪問最大的好處就是速度更快,它節省了一次指標定位的時間開銷,由於物件訪問的在Java中非常頻繁,因此這類開銷積小成多也是一項非常可 觀的執行成本。從上一部分講解的物件記憶體佈局可以看出,就虛擬機器HotSpot而言,它是使用第二種方式進行物件訪問,但在整個軟體開發的範圍來看,各種 語言、框架中使用控制代碼來訪問的情況也十分常見。