JVM總結-java對象的內存布局


創建對象的多種方法:new語句,反射,clone, 反序列化 等

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

構造器

1. 如果一個類沒有定義任何構造器的話, Java 編譯器會自動添加一個無參數的構造器。

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

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

總而言之,當我們調用一個構造器時,它將優先調用父類的構造器,直至 Object 類。這些構造器的調用者皆為同一對象,也就是通過 new 指令新建而來的對象。

你應該已經發現了其中的玄機:通過 new 指令新建出來的對象,它的內存其實涵蓋了所有父類中的實例字段。也就是說,雖然子類無法訪問父類的私有實例字段,或者子類的實例字段隱藏了父類的同名實例字段,但是子類的實例還是會為這些父類實例字段分配內存的。

這些字段在內存中的具體分布是怎么樣的呢?今天我們就來看看對象的內存布局。

壓縮指針

在 Java 虛擬機中,每個 Java 對象都有一個對象頭(object header),這個由標記字段類型指針所構成。其中,標記字段用以存儲 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 的緩存行中。如果字段不是對齊的,那么就有可能出現跨緩存行的字段。也就是說,該字段的讀取可能需要替換兩個緩存行,而該字段的存儲也會同時污染兩個緩存行。這兩種情況對程序的執行效率而言都是不利的。

下面我來介紹一下對象內存布局另一個有趣的特性:字段重排列。

其一,如果一個字段占據 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    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)

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

我認為是可以的,並且我修改過后的 Java 虛擬機也沒有跑崩。由於 HotSpot 中的這塊代碼年久失修,公司的同事也已經記不得是什么原因了,那么姑且先認為是一些歷史遺留問題吧。

 

總結和實踐

今天我介紹了 Java 虛擬機構造對象的方式,所構造對象的大小,以及對象的內存布局。

常見的 new 語句會被編譯為 new 指令,以及對構造器的調用。每個類的構造器皆會直接或者間接調用父類的構造器,並且在同一個實例中初始化相應的字段。

Java 虛擬機引入了壓縮指針的概念,將原本的 64 位指針壓縮成 32 位。壓縮指針要求 Java 虛擬機堆中對象的起始地址要對齊至 8 的倍數。Java 虛擬機還會對每個類的字段進行重排列,使得字段也能夠內存對齊。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM