99.9%的Java程式設計師都說不清的問題:JVM中的物件記憶體佈局?

石杉的架構筆記發表於2019-05-29

作者:李瑞傑

目前就職於阿里巴巴,資深 JVM 研究人員


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

其中,Object.clone 方法和反序列化通過直接複製已有的資料,來初始化新建物件的例項欄位。

Unsafe.allocateInstance 方法則沒有初始化例項欄位,而 new 語句和反射機制,則是通過呼叫構造器來初始化例項欄位。

我們先來考察new語句,準備一個類,如下圖所示


99.9%的Java程式設計師都說不清的問題:JVM中的物件記憶體佈局?

讓我們編譯他的位元組碼:

99.9%的Java程式設計師都說不清的問題:JVM中的物件記憶體佈局?

可以看到,new語句編譯而成的位元組碼將包含用來請求記憶體的 new 指令,以及用來呼叫構造器的 invokespecial 指令。

本文不是專門介紹invoke系列指令的,我會在後面的文章中介紹invoke系列指令。

不過在這裡我多說一嘴,位元組碼中的invokespecial指令通常用於呼叫私有例項方法、構造器,以及使用super關鍵字呼叫父類的例項方法或構造器,和所實現介面的預設方法。

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

我們剛才的TestNew類,他的位元組碼編譯出來後,有下面的片段。

99.9%的Java程式設計師都說不清的問題:JVM中的物件記憶體佈局?

在JAVA原始碼中,我們沒有定義構造器,但是生成出來的位元組碼,已經自動幫我們新增了一個無引數的構造器。他使用的invokespecial方法最終呼叫的是其父類Object類的構造器方法。

我將講述JVM的構造器呼叫原則,那就是,如果子類的構造器需要呼叫父類的構造器。如果父類存在無引數構造器的話,該呼叫可以是隱式的。也就是說, Java 編譯器會自動新增對父類構造器的呼叫。

但是,如果父類沒有無引數構造器,那麼子類的構造器則需要顯式地呼叫父類帶引數的構造器。

顯式呼叫有兩種,一是直接使用“super”關鍵字呼叫父類構造器,二是使用“this”關鍵字呼叫同一個類中的其他構造器。

無論是直接的顯式呼叫,還是間接的顯式呼叫,都需要作為構造器的第一條語句,以便優先初始化繼承而來的父類欄位。

可以不優先初始化繼承來的父類欄位嗎?可以,如果你能使用位元組碼注入工具的話。

當我們呼叫一個構造器時,它將優先呼叫父類的構造器,直至 Object 類。這些構造器的呼叫者皆為同一物件,也就是通過 new 指令新建而來的物件。

事實上,我上面的陳述意味著:通過 new 指令新建出來的物件,它的記憶體其實涵蓋了所有父類中的例項欄位。

也就是說,雖然子類無法訪問父類的私有例項欄位,或者子類的例項欄位隱藏了父類的同名例項欄位,但是子類的例項還是會為這些父類例項欄位分配記憶體的。

下面我將介紹壓縮指標技術。在 Java 虛擬機器中,每個 Java 物件都有一個物件頭,它由標記欄位和型別指標所構成。

標記欄位用以儲存 Java 虛擬機器有關該物件的執行資料,如雜湊碼、GC 資訊以及鎖資訊,而型別指標則指向該物件的類。

在64位的JVM中,物件頭的標記欄位佔 64 位,而型別指標又佔了 64 位。也就是說,每一個 Java 物件在記憶體中的額外開銷就是 16 個位元組。

為了儘量較少物件的記憶體使用量,64位JVM引入了壓縮指標的概念,將堆中原本64位的Java物件指標壓縮成32位的。

這樣一來,物件頭中的型別指標也會被壓縮成32位,使得物件頭的大小從16位元組降至12位元組。

當然,壓縮指標不僅可以作用於物件頭的型別指標,還可以作用於引用型別的欄位,以及引用型別陣列。

它的原理是什麼?答案是記憶體對齊

我們規定,預設情況下,JVM堆中物件的起始地址需要對齊至8的倍數,如果一個物件用不到8N 個位元組,那麼空白的那部分空間就浪費掉了,這些浪費掉的空間我們稱之為物件間的填充。

大家知道,指標裡面存放的是地址,由於堆中物件的起始地址是對齊至8的倍數,所以指標存放一個引用(或者物件的類)的記憶體地址時,根本就不用存放最後的三位二進位制數。

因為所有物件或類的記憶體地址都對齊了8,所以他們的記憶體地址的最低三位總是0,32位的指標就可以定址到 2 的 35 次方個位元組,也就是 32GB 的地址空間(超過 32GB 則會關閉壓縮指標)。

我們可以通過配置虛擬機器的記憶體對齊選項來進一步提升定址範圍。但是,這同時也可能增加物件間填充,導致壓縮指標沒有達到原本節省空間的效果。

就算是關閉了壓縮指標,Java 虛擬機器還是會進行記憶體對齊。此外,記憶體對齊不僅存在於物件與物件之間,也存在於物件中的欄位之間。

比如說,Java 虛擬機器要求long欄位、double欄位,以及非壓縮指標狀態下的引用欄位地址為8的倍數。

這是為什麼呢?

CPU的快取行機制大家應該有所耳聞,如果欄位不是對齊的,那麼就有可能出現跨快取行的欄位。

該欄位的讀取可能需要替換兩個快取行,而該欄位的儲存也會同時汙染兩個快取行。

我們將在後期文章關於volatile關鍵詞的本質分析的過程中,再次考察到CPU快取行的相關機制。

最後我要提一句的是,欄位重排列技術,就是我剛才提到的,物件的欄位之間存在的記憶體對齊。這指的是重新分配欄位的先後順序,以達到記憶體對齊的目的

它有以下兩個規則:

其一,如果一個欄位佔據C個位元組,那麼該欄位的偏移量需要對齊至NC。這裡的偏移量指的是欄位地址與物件的起始地址差值。

以Long類為例,它僅有一個long型別的例項欄位。在使用了壓縮指標的 64 位虛擬機器中,儘管物件頭的大小為12個位元組,該 long 型別欄位的偏移量也只能是16,而中間空著的4個位元組便會被浪費掉。

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

說白了,比如B繼承了A,A是B的父類,A中所有的欄位,在B中都有,而且是先放A的欄位,再放B的欄位。而且B類物件放A類欄位時,需要與父類對應欄位的偏移量保持一致。

接下來我說一個擴充內容吧,什麼是虛共享?

假設兩個執行緒分別訪問同一物件中不同的 volatile 欄位,邏輯上它們並沒有共享內容,因此不需要同步。

如果這兩個欄位恰好在同一個快取行中,那麼對這些欄位的寫操作會導致快取行的寫回,也就造成了實質上的共享。

Java8還引入了一個新的註釋@Contended,用來解決物件欄位之間的虛共享。

Java 虛擬機器會讓不同的@Contended欄位處於獨立的快取行中,因此你會看到大量的空間被浪費掉,避免無謂的快取行同步操作。

具體的演算法屬於實現細節了,大家有興趣可以去用:

-XX:-RestrictContended

這個虛擬機器選項,檢視Contended欄位的記憶體佈局。

END


個人公眾號:石杉的架構筆記(ID:shishan100)

歡迎長按下圖關注公眾號:石杉的架構筆記!

公眾號後臺回覆資料,獲取作者獨家祕製學習資料

石杉的架構筆記,BAT架構經驗傾囊相授

99.9%的Java程式設計師都說不清的問題:JVM中的物件記憶體佈局?



相關文章