Java Object Layout

Java Object Layout – Java對象的內存佈局

在 Java 程序中,我們擁有多種新建對象的方式。除了最爲常見的 new 語句之外,我們還可以通過反射機制、Object.clone 方法、反序列化以及 Unsafe.allocateInstance 方法來新建對象。

其中,Object.clone 方法和反序列化通過直接複製已有的數據,來初始化新建對象的實例字段。Unsafe.allocateInstance 方法則沒有初始化實例字段,而 new 語句和反射機制,則是通過調用構造器來初始化實例字段。

以 new 語句爲例,它編譯而成的字節碼將包含用來請求內存的 new 指令,以及用來調用構造器的 invokespecial 指令。

// Foo foo = new Foo(); 編譯而成的字節碼
  0 new Foo
  3 dup
  4 invokespecial Foo()
  7 astore_1

提到構造器,就不得不提到 Java 對構造器的諸多約束。首先,如果一個類沒有定義任何構造器的話, Java 編譯器會自動添加一個無參數的構造器。

// Foo 類構造器會調用其父類 Object 的構造器
public Foo();
  0 aload_0 [this]
  1 invokespecial java.lang.Object() [8]
  4 return

然後,子類的構造器需要調用父類的構造器。如果父類存在無參數構造器的話,該調用可以是隱式的,也就是說 Java 編譯器會自動添加對父類構造器的調用。但是,如果父類沒有無參數構造器,那麼子類的構造器則需要顯式地調用父類帶參數的構造器。

顯式調用又可分爲兩種,一是直接使用“super”關鍵字調用父類構造器,二是使用“this”關鍵字調用同一個類中的其他構造器。無論是直接的顯式調用,還是間接的顯式調用,都需要作爲構造器的第一條語句,以便優先初始化繼承而來的父類字段。(不過這可以通過調用其他生成參數的方法,或者字節碼注入來繞開。)

總而言之,當我們調用一個構造器時,它將優先調用父類的構造器,直至 Object 類。這些構造器的調用者皆爲同一對象,也就是通過 new 指令新建而來的對象。所以,通過 new 指令新建出來的對象,它的內存其實涵蓋了所有父類中的實例字段。也就是說,雖然子類無法訪問父類的私有實例字段,或者子類的實例字段隱藏了父類的同名實例字段,但是子類的實例還是會爲這些父類實例字段分配內存的。

這些字段在內存中的具體分佈是怎麼樣的呢?

壓縮指針

在 Java 虛擬機中,每個 Java 對象都有一個對象頭(object header),這個由標記字段(Mark Word)和類型指針(Klass Pointer)所構成。其中,標記字段用以存儲 Java 虛擬機有關該對象的運行數據,如哈希碼、GC 信息以及鎖信息,而類型指針則指向該對象的類。

在 64 位的 Java 虛擬機中,對象頭的標記字段佔 64 位,而類型指針又佔了 64 位。也就是說,每一個 Java 對象在內存中的額外開銷就是 16 個字節。以 Integer 類爲例,它僅有一個 int 類型的私有字段,佔 4 個字節。因此,每一個 Integer 對象的額外內存開銷至少是 400%。這也是爲什麼 Java 要引入基本類型的原因之一。

爲了儘量較少對象的內存使用量,64 位 Java 虛擬機引入了壓縮指針 [1] 的概念(對應虛擬機選項 -XX:+UseCompressedOops,默認開啓),將堆中原本 64 位的 Java 對象指針壓縮成 32 位的。

這樣一來,對象頭中的類型指針也會被壓縮成 32 位,使得對象頭的大小從 16 字節降至 12 字節。當然,壓縮指針不僅可以作用於對象頭的類型指針,還可以作用於引用類型的字段,以及引用類型數組。

那麼壓縮指針是什麼原理呢?

打個比方,路上停着的全是房車,而且每輛房車恰好佔據兩個停車位。現在,我們按照順序給它們編號。也就是說,停在 0 號和 1 號停車位上的叫 0 號車,停在 2 號和 3 號停車位上的叫 1 號車,依次類推。

原本的內存尋址用的是車位號。比如說我有一個值爲 6 的指針,代表第 6 個車位,那麼沿着這個指針可以找到 3 號車。現在我們規定指針裏存的值是車號,比如 3 指代 3 號車。當需要查找 3 號車時,我便可以將該指針的值乘以 2,再沿着 6 號車位找到 3 號車。

這樣一來,32 位壓縮指針最多可以標記 2 的 32 次方輛車,對應着 2 的 33 次方個車位。當然,房車也有大小之分。大房車佔據的車位可能是三個甚至是更多。不過這並不會影響我們的尋址算法:我們只需跳過部分車號,便可以保持原本車號 *2 的尋址系統。

上述模型有一個前提,就是每輛車都從偶數號車位停起。這個概念我們稱之爲內存對齊(對應虛擬機選項 -XX:ObjectAlignmentInBytes,默認值爲 8)。

默認情況下,Java 虛擬機堆中對象的起始地址需要對齊至 8 的倍數。如果一個對象用不到 8N 個字節,那麼空白的那部分空間就浪費掉了。這些浪費掉的空間我們稱之爲對象間的填充(padding)。

在默認情況下,Java 虛擬機中的 32 位壓縮指針可以尋址到 2 的 35 次方個字節,也就是 32GB 的地址空間(超過 32GB 則會關閉壓縮指針)。

在對壓縮指針解引用時,我們需要將其左移 3 位,再加上一個固定偏移量,便可以得到能夠尋址 32GB 地址空間的僞 64 位指針了。

此外,我們可以通過配置剛剛提到的內存對齊選項(-XX:ObjectAlignmentInBytes)來進一步提升尋址範圍。但是,這同時也可能增加對象間填充,導致壓縮指針沒有達到原本節省空間的效果。

舉例來說,如果規定每輛車都需要從偶數車位號停起,那麼對於佔據兩個車位的小房車來說剛剛好,而對於需要三個車位的大房車來說,也僅是浪費一個車位。

但是如果規定需要從 4 的倍數號車位停起,那麼小房車則會浪費兩個車位,而大房車至多可能浪費三個車位。

當然,就算是關閉了壓縮指針,Java 虛擬機還是會進行內存對齊。此外,內存對齊不僅存在於對象與對象之間,也存在於對象中的字段之間。比如說,Java 虛擬機要求 long 字段、double 字段,以及非壓縮指針狀態下的引用字段地址爲 8 的倍數。

字段內存對齊的其中一個原因,是讓字段只出現在同一 CPU 的緩存行中。如果字段不是對齊的,那麼就有可能出現跨緩存行的字段。也就是說,該字段的讀取可能需要替換兩個緩存行,而該字段的存儲也會同時污染兩個緩存行。這兩種情況對程序的執行效率而言都是不利的。

字段重排列

字段重排列,顧名思義,就是 Java 虛擬機重新分配字段的先後順序,以達到內存對齊的目的。Java 虛擬機中有三種排列方法(對應 Java 虛擬機選項 -XX:FieldsAllocationStyle,默認值爲 1),但都會遵循如下兩個規則。

其一,如果一個字段佔據 C 個字節,那麼該字段的偏移量需要對齊至 NC。這裏偏移量指的是字段地址與對象的起始地址差值。

以 long 類爲例,它僅有一個 long 類型的實例字段。在使用了壓縮指針的 64 位虛擬機中,儘管對象頭的大小爲 12 個字節,該 long 類型字段的偏移量也只能是 16,而中間空着的 4 個字節便會被浪費掉。

其二,子類所繼承字段的偏移量,需要與父類對應字段的偏移量保持一致。

在具體實現中,Java 虛擬機還會對齊子類字段的起始位置。對於使用了壓縮指針的 64 位虛擬機,子類第一個字段需要對齊至 4N;而對於關閉了壓縮指針的 64 位虛擬機,子類第一個字段則需要對齊至 8N。

class A {
  long l;
  int i;
}
 
class B extends A {
  long l;
  int i;
}

上面的這段代碼裏邊定義了兩個類 A 和 B,其中 B 繼承 A。A 和 B 各自定義了一個 long 類型的實例字段和一個 int 類型的實例字段。下面分別打印了 B 類在啓用壓縮指針和未啓用壓縮指針時,各個字段的偏移量。

# 啓用壓縮指針時,B 類的字段分佈
B object internals:
 OFFSET  SIZE   TYPE DESCRIPTION
      0     4        (object header)
      4     4        (object header)
      8     4        (object header)
     12     4    int A.i                                       0
     16     8   long A.l                                       0
     24     8   long B.l                                       0
     32     4    int B.i                                       0
     36     4        (loss due to the next object alignment)

當啓用壓縮指針時,可以看到 Java 虛擬機將 A 類的 int 字段放置於 long 字段之前,以填充因爲 long 字段對齊造成的 4 字節缺口。由於對象整體大小需要對齊至 8N,因此對象的最後會有 4 字節的空白填充。

# 關閉壓縮指針時,B 類的字段分佈
B object internals:
 OFFSET  SIZE   TYPE DESCRIPTION
      0     4        (object header)
      4     4        (object header)
      8     4        (object header)
     12     4        (object header)
     16     8   long A.l
     24     4    int A.i
     28     4        (alignment/padding gap)                  
     32     8   long B.l
     40     4    int B.i
     44     4        (loss due to the next object alignment)

當關閉壓縮指針時,B 類字段的起始位置需對齊至 8N。這麼一來,B 類字段的前後各有 4 字節的空白。那麼我們可不可以將 B 類的 int 字段移至前面的空白中,從而節省這 8 字節呢?

Java 8 還引入了一個新的註解 @Contended,用來解決對象字段之間的虛共享(false sharing)問題 。這個註解也會影響到字段的排列。

虛共享是怎麼回事呢?假設兩個線程分別訪問同一對象中不同的 volatile 字段,邏輯上它們並沒有共享內容,因此不需要同步。

然而,如果這兩個字段恰好在同一個緩存行中,那麼對這些字段的寫操作會導致緩存行的寫回,也就造成了實質上的共享。(volatile 字段和緩存行的故事我會在之後的篇章中詳細介紹。)

Java 虛擬機會讓不同的 @Contended 字段處於獨立的緩存行中,因此會看到大量的空間被浪費掉。具體的分佈算法屬於實現細節,隨着 Java 版本的變動也比較大。

可以通過 JOL 工具,來分析對象的字段分佈情況。

<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-core -->
<dependency>
    <groupId>org.openjdk.jol</groupId>
    <artifactId>jol-core</artifactId>
    <version>0.9</version>
</dependency>
String classLayout = ClassLayout.parseInstance(object).toPrintable();
System.out.println(classLayout);

歡迎關注我的公衆號,會定期分享Java、數據結構與算法、數據庫、計算機網絡、操作系統等方面的學習資源。
在這裏插入圖片描述

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章