Java技術專題-JVM研究系列(6)HotSpot虛擬機器物件

語言: CN / TW / HK

物件的建立


語言層面上,建立物件通常(例外:克隆、反序列化)僅僅是一個 new 關鍵字而已,而在虛擬機器中,物件(本文中討論的物件限於普通 Java 物件,不包括陣列和 Class 物件等)的建立又是怎樣一個過程呢?


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


記憶體的分配

類載入通過後,接下來虛擬機器將為新生物件分配記憶體。物件所需記憶體的大小在類載入完成後便可完全確定(如何確定在下一節物件記憶體佈局時再詳細講解),為物件分配空間的任務具體便等同於一塊確定大小的記憶體從 Java 堆中劃分出來,怎麼劃呢?


指標碰撞

假設 Java 堆中記憶體是絕對規整的,所有用過的記憶體都被放在一邊,空閒的記憶體被放在另一邊,中間放著一個指標作為分界點的指示器,那所分配記憶體就僅僅是把那個指標向空閒空間那邊挪動一段與物件大小相等的距離,這種分配方式稱為“指標碰撞”(Bump The Pointer)。


空閒列表

Java堆中的記憶體並不是規整的,已被使用的記憶體和空閒的記憶體相互交錯,那就沒有辦法簡單的進行指標碰撞了,虛擬機器就必須維護一個列表,記錄上哪些記憶體塊是可用的,在分配的時候從列表中找到一塊足夠大的空間劃分給物件例項,並更新列表上的記錄,這種分配方式稱為“空閒列表”(Free List)。

記憶體分配選擇


選擇哪種分配方式由 Java 堆是否規整決定,而 Java 堆是否規整又由所採用的垃圾收集器是否帶有壓縮整理功能決定。 因此在使用 SerialParNew 等帶 Compact 過程的收集器時,系統採用的分配演算法是指標碰撞,而使用 CMS 這種基於 Mark-Sweep 演算法的收集器時(說明一下,CMS 收集器可以通過 UseCMSCompactAtFullCollectionCMSFullGCsBeforeCompaction 來整理記憶體),就通常採用空閒列表。


記憶體分配問題

如何劃分可用空間之外,還有另外一個需要考慮的問題是物件建立在虛擬機器中是非常頻繁的行為,即使是僅僅修改一個指標所指向的位置,在併發情況下也並不是執行緒安全的,可能出現正在給物件 A 分配記憶體,指標還沒來得及修改,物件 B 又同時使用了原來的指標來分配記憶體。**解決這個問題有兩個方案,一種是對分配記憶體空間的動作進行同步——實際上虛擬機器是採用 CAS 配上失敗重試的方式保證更新操作的原子性;另外一種是把記憶體分配的動作按照執行緒劃分在不同的空間之中進行,即每個執行緒在 Java 堆中預先分配一小塊記憶體,稱為本地執行緒分配緩衝,(TLAB ,Thread Local Allocation Buffer)哪個執行緒要分配記憶體,就在哪個執行緒的 TLAB 上分配, 只有 TLAB 用完,分配新的 TLAB 時才需要同步鎖定。虛擬機器是否使用 TLAB,可以通過 -XX:+/-UseTLAB 引數來設定。記憶體分配完成之後,虛擬機器需要將分配到的記憶體空間都初始化為零值(不包括物件頭),如果使用 TLAB 的話,這一個工作也可以提前至 TLAB 分配時進行。這步操作保證了物件的例項欄位在 Java 程式碼中可以不賦初始值就直接使用,程式能訪問到這些欄位的資料型別所對應的零值。


物件頭引數配置

虛擬機器要對物件進行必要的設定,例如這個物件是哪個類的例項、如何才能找到類的元資料資訊、物件的雜湊碼、物件的 GC 分代年齡等資訊。這些資訊存放在物件的物件頭(Object Header)之中。根據虛擬機器當前的執行狀態的不同,如是否啟用偏向鎖等,物件頭會有不同的設定方式

在虛擬機器的視角來看,一個新的物件已經產生了。 Java 程式的視角看來,物件建立才剛剛開始——方法還沒有執行,所有的欄位都為零呢。所以一般來說(由位元組碼中是否跟隨有 invokespecial 指令所決定),new 指令之後會接著就是執行方法,把物件按照程式設計師的意願進行初始化,這樣一個真正可用的物件才算完全產生出來。

下面程式碼是 HotSpot 虛擬機器 bytecodeInterpreter.cpp 中的程式碼片段(這個直譯器實現很少機會實際使用,大部分平臺上都使用模板直譯器;當代碼通過 JIT 編譯器執行時差異就更大了。不過這段程式碼用於瞭解 HotSpot 的運作過程是沒有什麼問題的)。


// 確保常量池中存放的是已解釋的類 
if (!constants->tag_at(index).is_unresolved_klass()) { 
   // 斷言確保是 klassOop 和 instanceKlassOop(這部分下一節介紹) 
   oop entry = (klassOop) *constants->obj_at_addr(index); 
   assert(entry->is_klass(), "Should be resolved klass"); 
   klassOop k_entry = (klassOop) entry; 
   assert(k_entry->klass_part()->oop_is_instance(), "Should be instanceKlass"); 
   instanceKlass* ik = (instanceKlass*) k_entry->klass_part(); 
   // 確保物件所屬型別已經經過初始化階段 
   if ( ik->is_initialized() && ik->can_be_fastpath_allocated() ) { 
       // 取物件長度 
       size_t obj_size = ik->size_helper(); 
       oop result = NULL; 
       // 記錄是否需要將物件所有欄位置零值 
       bool need_zero = !ZeroTLAB; 
       // 是否在 TLAB 中分配物件 
       if (UseTLAB) { 
           result = (oop) THREAD->tlab().allocate(obj_size); 
       } 
       if (result == NULL) { 
           need_zero = true; 
           // 直接在 eden 中分配物件 
           retry: 
               HeapWord* compare_to = *Universe::heap()->top_addr(); 
               HeapWord* new_top = compare_to + obj_size; 
               // cmpxchg 是 x86 中的 CAS 指令,這裡是一個 C++ 方法,通過 CAS 方式分配空間,併發失敗的話,轉到 retry 中重試直至成功分配為止 
               if (new_top <= *Universe::heap()->end_addr()) { 
                   if (Atomic::cmpxchg_ptr(new_top, Universe::heap()->top_addr(), compare_to) != compare_to) { 
                       goto retry; 
                   } 
                   result = (oop) compare_to; 
               } 
       } 
       if (result != NULL) { 
           // 如果需要,為物件初始化零值 
           if (need_zero ) { 
               HeapWord* to_zero = (HeapWord*) result + sizeof(oopDesc) / oopSize; 
               obj_size -= sizeof(oopDesc) / oopSize; 
               if (obj_size > 0 ) { 
                   memset(to_zero, 0, obj_size * HeapWordSize); 
               } 
           } 
           // 根據是否啟用偏向鎖,設定物件頭資訊 
           if (UseBiasedLocking) { 
               result->set_mark(ik->prototype_header()); 
           } else { 
               result->set_mark(markOopDesc::prototype()); 
           } 
           result->set_klass_gap(0); 
           result->set_klass(k_entry); 
           // 將物件引用入棧,繼續執行下一條指令 
           SET_STACK_OBJECT(result, 0); 
           UPDATE_PC_AND_TOS_AND_CONTINUE(3, 1); 
       } 
   } 
}

物件的記憶體佈局

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 標記、可偏向)下物件的儲存內容如下表所示。


虛擬機器物件頭

| 型別 | 32位JVM | 64位JVM|
| ------ ---- | ------------| --------- |
| markword | 32bit | 64bit |
| 型別指標 | 32bit |64bit ,開啟指標壓縮時為32bit |
| 陣列長度 | 32bit |32bit |
  1. 開啟指標壓縮時,markword佔用8bytes,型別指標佔用8bytes,共佔用16bytes;
  2. 未開啟指標壓縮時,markword佔用8bytes,型別指標佔用4bytes,但由於java記憶體地址按照8bytes對齊,長度必須是8的倍數,因此會從12bytes補全到16bytes;
  • 陣列長度為4bytes,同樣會進行對齊,補足到8bytes;

  • 如果物件沒有重寫hashcode方法,那麼預設是呼叫os::random產生hashcode,可以通過System.identityHashCode獲取;os::random產生
  • hashcode的規則為:next_rand = (16807seed) mod (2*31-1),因此可以使用31位儲存;另外一旦生成了hashcode,JVM會將其記錄在markword中;
  • GC年齡採用4位bit儲存,最大為15,例如MaxTenuringThreshold引數預設值就是15;
  • 當處於輕量級鎖、重量級鎖時,記錄的物件指標,根據JVM的說明,此時認為指標仍然是64位,最低兩位假定為0;當處於偏向鎖時,記錄的為獲得偏向鎖的執行緒指標,該指標也是64位;

標記欄位

32 bits:

  hash:25 ------------>| age:4    biased_lock:1 lock:2 (normal object)
  JavaThread*:23 epoch:2 age:4    biased_lock:1 lock:2 (biased object)
  size:32 ------------------------------------------>| (CMS free block)
  PromotedObject*:29 ---------->| promo_bits:3 ----->| (CMS promoted object)

64 bits:

  unused:25 hash:31 -->| unused:1   age:4    biased_lock:1 lock:2 (normal object)
  JavaThread*:54 epoch:2 unused:1   age:4    biased_lock:1 lock:2 (biased object)
  PromotedObject*:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
  size:64 ----------------------------------------------------->| (CMS free block)
 unused:25 hash:31 -->| cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && normal object)
 JavaThread*:54 epoch:2 cms_free:1 age:4    biased_lock:1 lock:2 (COOPs && biased object)
 narrowOop:32 unused:24 cms_free:1 unused:4 promo_bits:3 ----->| (COOPs && CMS promoted object)
 unused:21 size:35 -->| cms_free:1 unused:7 ------------------>| (COOPs && CMS free block)

型別指標

物件頭的另外一部分是型別指標,即是物件指向它的類元資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項並不是所有的虛擬機器實現都必須在物件資料上保留型別指標,換句話說查詢物件的元資料資訊並不一定要經過物件本身,這點我們在下一節討論。另外,如果物件是一個 Java 陣列,那在物件頭中還必須有一塊用於記錄陣列長度的資料因為虛擬機器可以通過普通 Java 物件的元資料資訊確定 Java 物件的大小但是從陣列的元資料中無法確定陣列的大小

以下是 HotSpot 虛擬機器 markOop.cpp 中的程式碼(註釋)片段,它描述了 32bits 下 MarkWord 的儲存狀態:

例項資料

接下來例項資料部分是物件真正儲存的有效資訊,也既是我們在程式程式碼裡面所定義的各種型別的欄位內容,無論是從父類繼承下來的,還是在子類中定義的都需要記錄襲來。

這部分的儲存順序會受到虛擬機器分配策略引數(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 資料來操作堆上的具體物件。由於 reference 型別在 Java 虛擬機器規範裡面只規定了是一個指向物件的引用,並沒有定義這個引用應該通過什麼種方式去定位、訪問到堆中的物件的具體位置,物件訪問方式也是取決於虛擬機器實現而定的。主流的訪問方式有使用控制代碼和直接指標兩種。

如果使用控制代碼訪問的話,Java 堆中將會劃分出一塊記憶體來作為控制代碼池,reference 中儲存的就是物件的控制代碼地址,而控制代碼中包含了物件例項資料與型別資料的具體各自的地址資訊。如圖 1 所示。

如果使用直接指標訪問的話,Java堆物件的佈局中就必須考慮如何放置訪問型別資料的相關資訊,reference中儲存的直接就是物件地址,如圖 2 所示。

這兩種物件訪問方式各有優勢,使用控制代碼來訪問的最大好處就是 reference 中儲存的是穩定控制代碼地址,在物件被移動(垃圾收集時移動物件是非常普遍的行為)時只會改變控制代碼中的例項資料指標,而 reference 本身不需要被修改。

使用直接指標來訪問最大的好處就是速度更快,它節省了一次指標定位的時間開銷,由於物件訪問的在 Java 中非常頻繁,因此這類開銷積小成多也是一項非常可觀的執行成本。從上一部分講解的物件記憶體佈局可以看出,就虛擬機器 HotSpot 而言,它是使用第二種方式進行物件訪問,但在整個軟體開發的範圍來看,各種語言、框架中使用控制代碼來訪問的情況也十分常見。

分享到:
「其他文章」