詳解物件的建立,佈局,定位,物件存活判斷

CryFace發表於2020-08-04

我們在建立普通物件的時候只需要new關鍵字就解決了,但是在new的背後到底經歷了什麼呢?我們建立一個物件的過程到底是什麼樣子呢?

1 物件的建立

我們的Java虛擬機器在遇到一條位元組碼new指令時,首先經歷以下的步驟:

我們先不介紹類載入過程,後面如果出了相關博文會在這裡給一個超連結(點選跳轉)。

在我們的類檢查通過後,也就是到了我們的虛擬機器為我們的新生物件分配記憶體。我們的記憶體分配方式有兩種

1.1 指標碰撞

假設Java堆中記憶體是絕對規整的,所有被使用過的記憶體都放在一邊,空閒的記憶體被放在另一邊,中間放著一個指標作為分界點的指示器,那所分配的記憶體就僅僅是把那個指標向空閒方向挪動一段與物件大小相等的舉例,這種分配方式稱為“指標碰撞”(Bump ThePointer)。一般使用Serial、ParNew等帶壓縮整理過程的收集器。

1.2 空閒列表

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

注意,因為我們的虛擬機器建立物件是非常頻繁的,所以僅僅只是修改一個指標的位置,在併發裡也是不安全的。比如給A物件分配記憶體,指標還沒來得及修改,物件B又同時使用了原來的指標來分配記憶體的情況。我們針對這種併發安全的問題也提出了兩種解決方案:

  • 同步處理。對我們的分配記憶體空間的操作進行同步處理,採用CAS配上自旋的方式保證更新操作的原子性。
  • 本地執行緒分配緩衝。把我們分配記憶體的動作按照執行緒劃分在不同的空間之中進行,也就是每個執行緒在Java堆中都預先分配一小塊記憶體,稱為本地執行緒分配緩衝區。哪個執行緒要分配記憶體,就在哪個執行緒的本地緩衝區分配,只有本地緩衝區用完了,分配新的緩衝區時才需要同步鎖定。虛擬機器是否使用緩衝區,可以通過引數-XX:+/-UseTLAB引數來設定。

分配完記憶體就需要將我們的記憶體空間都初始化為零值了。然後開始往我們的物件的物件頭裡填充一些資訊,比如該物件是哪個類的例項、如何才能找到類的後設資料資訊、物件的雜湊碼、物件的GC分代年齡等資訊。至此,我們從虛擬機器的角度來看,一個物件已經產生了。但是從Java程式來看,我們還需要進行建構函式(()),按照按程式設計師的意願對物件進行初始化,一個真正可用的物件才算完全被構造出來。

(補充:為了能在多數情況下能夠更快的分配記憶體,設計了一個叫作LinearAllocation Buffer的分配緩衝區,通過空閒列表拿到一大塊分配緩衝區之後,在它裡面仍然可以使用指標碰撞方式來分配。)

2 物件的佈局

我們上面介紹了到了在虛擬機器中一個物件的建立,我們接下來介紹的就是物件在堆記憶體中的儲存佈局。可以劃分為三個部分:物件頭(Header)、例項資料(Instance Data)和對齊填充(Padding)。

2.1物件頭

我們的物件頭主要包括了兩類資訊。

  • 第一類是用於儲存物件自身的執行時資料,如雜湊碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等,簡稱“Mark Word”。
  • 第二類便是型別指標,即物件指向它的型別後設資料的指標,Java虛擬機器通過這個指標來確定該物件是哪個類的例項。(但是並不是所有物件都會保留的,在下面定位的時候會具體談到!)

關於我們的Mark Word會根據物件的狀態來複用空間,也就是處於什麼狀態,就會如何分配我們的位元儲存空間。比如處於物件未被同步鎖鎖定的狀態下(無鎖態),Mark Word的32個位元儲存空間中的25個位元用於儲存物件雜湊碼,4個位元用於儲存物件分代年齡,2個位元用於儲存鎖標誌位,1個位元固定為0。下面給上其他狀態的空間分佈:

2.2例項資料

例項資料是我們物件真正儲存的有效資訊,也就是我們在程式程式碼裡面所定義的各種型別的欄位內容,無論是從父類繼承下來的,還是在子類中定義的欄位都必須記錄起來。具體的儲存順序可以受到虛擬機器分配策略引數(-XX:FieldsAllocationStyle引數)和欄位在Java原始碼中定義順序的影響。

2.3 對齊填充

沒有特別的含義,它僅僅起著佔位符的作用。由於HotSpot虛擬機器的自動記憶體管理系統要求物件起始地址必須是8位元組的整數倍,換句話說就是任何物件的大小都必須是8位元組的整數倍。物件頭部分已經被精心設計成正好是8位元組的倍數(1倍或者2倍),因此,如果物件例項資料部分沒有對齊的話,就需要通過對齊填充來補全。

3 物件的定位

我們建立物件是為了後續使用物件,Java程式會通過棧上的reference資料(指向物件的引用)來操作堆上的具體物件。具體的主流物件訪問方式主要使用控制程式碼和直接指標兩種。

3.1 控制程式碼訪問

如果使用控制程式碼訪問的話,Java堆中將可能會劃分出一塊記憶體來作為控制程式碼池,reference中儲存的就是物件的控制程式碼地址,而控制程式碼中包含了物件例項資料與型別資料各自具體的地址資訊,結構如下:

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

3.2 直接指標

如果使用直接指標的話,那麼我們的Java堆中物件的記憶體佈局就必須考慮如何放置訪問型別資料的相關資訊,reference中儲存的直接就是物件例項資料,如果只是訪問物件本身的話,就會了少了一次間接訪問的開銷,結構如下:

使用直接指標訪問的優點是速度快,少了一次指標定位的時間開銷。如果物件訪問十分頻繁的話,那麼便是極為可觀的執行成本!

4 物件存活判斷

我們的物件在被垃圾回收器回收的時候會進行判斷物件是否存活,然後才選擇是否回收。而我們進行判斷的兩種方式如下:

4.1 引用計數演算法

在物件中新增一個引用計數器,如果被引用計數器加 1,引用失效時計數器減 1,如果計數器為 0 則被標記為垃圾。原理簡單,效率高,但是在 Java 中很少使用,因為存在物件間迴圈引用的問題,導致計數器無法清零。

4.2 可達性分析演算法

主流語言的記憶體管理都使用可達性分析判斷物件是否存活。基本思路是通過一系列稱為 GC Roots 的根物件作為起始節點集,從這些節點開始,根據引用關係向下搜尋,搜尋過程走過的路徑稱為引用鏈,如果某個物件到 GC Roots 沒有任何引用鏈相連,則會被標記為垃圾。可作為 GC Roots 的物件包括虛擬機器棧和本地方法棧中引用的物件、類靜態屬性引用的物件、常量引用的物件。

4.3 談談四種引用

在上面物件定位說到了reference是傳統的某塊記憶體、物件的引用。但是在JDK1.2之後,我們的引用被細分成了四種引用,通過強弱依次遞減分別是,強引用,軟引用,弱引用,虛引用四種。

  • 強引用。最常見的引用,例如 Object obj = new Object() 就屬於強引用。只要物件有強引用指向且 GC Roots 可達,在記憶體回收時即使瀕臨記憶體耗盡也不會被回收。
  • 軟引用。弱於強引用,描述非必需物件。在系統將發生記憶體溢位前,會把軟引用關聯的物件加入回收範圍以獲得更多記憶體空間。用來快取伺服器中間計算結果及不需要實時儲存的使用者行為等。
  • 弱引用。弱於軟引用,描述非必需物件。弱引用關聯的物件只能生存到下次 YGC (Young GC)前,當垃圾收集器開始工作時無論當前記憶體是否足夠都會回收只被弱引用關聯的物件。由於 YGC 具有不確定性,因此弱引用何時被回收也不確定。
  • 虛引用。最弱的引用,定義完成後無法通過該引用獲取物件。唯一目的就是為了能在物件被回收時收到一個系統通知。虛引用必須與引用佇列聯合使用,垃圾回收時如果出現虛引用,就會在回收物件前把這個虛引用加入引用佇列。

5 參考資料

深入理解Java虛擬機器:JVM高階特性(第三版)

相關文章