「深入學習JVM 02」HotSpot虛擬機對象探祕

虛擬機運行時數據區域描述了虛擬機管理的內存劃分情況,但是目前我們對於虛擬機還是有很多困惑,比如:

  • 問題1:如何為對象分配內存?
  • 問題2:對象內存模型是怎樣的?
  • 問題3:是怎樣訪問內存中的對象的?
  • 問題4:分配內存的時候如果遇到併發問題,怎麼保證分配操作的線程安全性?

為了搞清楚這些問題,我們先從虛擬機是如何創建對象開始講起。

一、對象創建過程

當虛擬機遇到一條new 指令時,便會進行對象的創建過程。

創建對象的過程如下:

  • 1.檢查常量池中有沒有這個類的符號引用,並且檢查這個符號引用代表的類有沒有被虛擬機加載過。

如果沒有被加載過,則執行類加載過程,然後進入下一步; 如果已加載,則進入下一步。

  • 2.根據方法區中類的信息,在堆區劃分一塊確定大小的內存給對象。

(經過類加載後,類的信息被保存在方法區中,一個類的對象所需的內存大小也固定下來。)

  • 3.為對象的成員變量賦初始值

內存分配完成之後,需要對分配的內存空間部分區域的內容都初始化為零值。 這一步保證了對象成員變量在java代碼中可以不賦初始值。

  • 4.設置 對象頭 中的信息
  • 關於對象頭是什麼, 別急,繼續往下看。
  • 5.調用 方法進行初始化
  • 別再問 是什麼了,先往下看。

二、問題1解惑:

在堆區分配內存有兩種方式。

  • 指針碰撞法

如果堆中內存是規整的,即所有用過的內存都放在一邊,空閒的內存放在另一邊,中間用一個指針做分界點的指示器。

分配內存的過程,實際上就是 指針向空閒空間那邊移動一段與對象大小相等的距離

  • 空閒列表法

java堆中的內存如果不是規整的,就需要使用空閒列表的分配方式。

空閒列表概念:虛擬機維護了一個列表,用於記錄哪些內存塊是可用的。

在分配的時候, 從列表中找到一塊滿足對象大小的內存空間劃分給對象實例,同時會更新列表上的記錄 。

  • 關於兩種分配方式的選擇

選擇哪種分配方式取決於java堆是否規整。

而java堆是否規整取決於所採用的垃圾收集器是否帶有壓縮整理的功能。

因此,選擇哪種分配方式最終 取決於使用了哪種垃圾收集器

  • 使用了指針碰撞的垃圾收集器有哪些?

serial、ParNew等基於複製算法或標記整理(Mark Compact)算法的收集器,不會導致內存碎片,因此使用的是指針碰撞。

  • 採用空閒鏈表垃圾收集器有哪些?

CMS等基於Mark-Sweep(標記清除)算法的收集器,會產生內存碎片,所以使用空閒列表法。

三、問題2解惑

對象在內存中的數據除了實例本身的數據外,還包括 對象頭對齊填充

3.1 實例數據

實例數據存儲的是成員變量的值,包括從父類繼承下來的成員變量。

成員變量在內存中的順序:相同寬度的字段會分配在一起,父類定義的變量會出現在子類之前, 默認情況下,子類中較窄的變量可能會被插入到父類變量的間隙中。反正就是不一定按定義的順序來分配。

3.2 對象頭是什麼?

對象頭的作用是記錄對象在運行過程中所需的數據。

比如對象屬於哪個類的實例、所屬類的信息在方法區中的位置(類型指針)、對象的哈希碼、對象的GC分代年齡等信息。這些信息就保存在對象頭中(Object Header)

3.3 對齊填充又是什麼?

對齊填充是用於確保對象的內存的總長度為8字節的整數倍。

為什麼要是確保是8字節的整數倍呢?

因為hotspot要求對象起始地址為8字節的整數倍以便於自動內存管理, 換句話說,對象的總長度要為8字節的整數倍才能保證如此。 而又因為對象頭正好是8字節(32位或64位)的整數倍,但是實例數據長度是任意的,因此需要對齊補充來確保整個對象總長度為8字節的整數倍。

四、問題3解惑

java程序需要通過引用來操作堆上的具體數據。 根據引用存放的地址類型的不同,對象有不同的訪問方式

主要有兩種訪問方式:

  • 使用句柄訪問
  • 使用直接指針訪問

4.1 使用句柄訪問

「深入學習JVM 02」HotSpot虛擬機對象探秘

堆中會劃分一塊內存用來做句柄池。引用中存儲的就是對象的句柄地址。句柄包含了對象實例數據和對象類型的數據的指針。

通過引用訪問對象的時候,會首先根據引用找到對象的句柄,然後根據句柄中對象的地址來訪問對象。

4.2 使用直接指針訪問

「深入學習JVM 02」HotSpot虛擬機對象探秘

引用中存儲的直接是對象的地址,直接通過引用來訪問對象。

喜歡的朋友們可以關注我,私信回覆555,持續分享後端技術乾貨,包括虛擬機基礎,多線程編程,高性能框架,異步、緩存和消息中間件,分佈式和微服務,架構學習和進階等學習資料和文章。

4.3 兩種方式對比

  • 使用句柄
  • 優點:引用中存儲的是穩定的句柄地址,發生垃圾收集時可能會移動對象,這時候只需要改變句柄中實例數據的指針指向新對象,而引用的值不需要改。
  • 缺點:需要兩次尋址。
  • 使用直接指針
  • 優點:速度快,一次尋址即可。
  • 缺點:需要在對象實例的內存中保存一個指向方法區中該類型數據的指針。不過使用句柄方式句柄中也需要保存類型指針。

直接指針的速度快,hotspot採用就是直接指針的方式

五、問題4解惑

對象分配內存不是線程安全的,比如給對象A分配內存,還沒來得及修改指針的指向, 另一個線程創建對象B也用了原來的指針,這樣就會出問題的。

如何解決?

  • 方案1: 對分配內存空間的動作進行同步處理

實際上虛擬機採用CAS配上失敗重試的方式保證更新指針操作的原子性。

  • 方案2:把內存分配的動作按照線程劃分在不同的空間中進行

即:每個線程在java堆中預分配一小塊內存, 這一小塊內存稱作“本地線程分配緩衝"(Thread Local Allocation Buffer, TLAB)

內存分配的過程就可以總結為:不同線程使用指針碰撞或者空閒列表的方式在各自的 TLAB 上分配內存。

當線程的 TLAB 用完需要分配新的 TLAB ,這時候才需要同步內存分配操作。

虛擬機是否需要使用 TLAB ,可以通過 -XX:+/-UseTLAB 參數來決定。

六、遺留問題:方法是個啥?

從上面對象的創建過程,我們可以瞭解到,在內存分配完成之後,所有成員變量的值都還只是零值。

對於虛擬機來說,對象創建已經完畢,但是,對於java程序來說,對象的初始化才剛開始。

成員變量的初始化工作交由 方法的來完成。

編譯器收集了成員變量上的賦值操作,實例初始化代碼塊的賦值操作,以及構造方法中的賦值操作,構成了 方法,並執行,對象就得到了初始化。

學習過java基礎的人都知道,對象初始化的順序為: 成員變量上的賦值-->實例初始化塊-->構造方法。

方法就解釋了為什麼是這個過程。

七、講點對象頭

對象頭的內存模型分三部分:

  • Mark Word
  • 類型指針
  • 記錄數組的長度

7.1 Mark Word

存放hashCode、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程id、偏向時間戳等。 長度為32位或者64位(32位虛擬機和64位虛擬機)。

mark word是一個非固定的數據結構,在不同情況下結構會有所變化。

比如:在32位的虛擬機中,如果對象處於未被鎖定的狀態, mark Word的32位空間將有25位用於存儲hashcode, 4位用於存儲對象的分代年齡,2位用於存儲對象上鎖 標誌,1位固定為0

這些東西我就不一個個介紹他們是用來幹嘛的,講多了反而複雜,大概瞭解就行,有興趣的可以百度。

7.2 類型指針

一個指向類元數據的指針,通過這個指針,可以確定對象是哪個類的實例。記住,這個指針是在對象頭中,但不是在Mark Word中的。


分享到:


相關文章: