理解Java對象:要從內存布局及底層機制說起,話說….
前言
大家好,又見面了,今天是JVM專題的第二篇文章,在上一篇文章中我們說了Java的類和對象在JVM中的存儲方式,並使用HSDB進行佐證,沒有看過上一篇文章的小夥伴可以點這裡:《類和對象在JVM中是如何存儲的,竟然有一半人回答不上來!》
這篇文章主要會對Java對象進行詳細分析,基於上一篇文章,對Java對象的布局及其底層的一些機制進行解讀,相信這些會對後期JVM調優有很大的幫助。
對象的內存布局
在上篇文章中我們提到,對象在JVM中是由一個Oop進行描述的。回顧一下,Oop由對象頭(_mark、_metadata)以及實例數據區組成,而對象頭中存在一個_metadata,其內部存在一個指針,指向類的元數據信息,就是下面這張圖:
而今天要說的對象的內存布局,其底層實際上就是來自於這張圖。
了解過對象組成的同學應該明白,對象由三部分構成,分別是:對象頭、實例數據、對齊填充組成,而對象頭和示例數據,對應的就是Oop對象中的兩大部分,而對齊填充實際上是一個只在邏輯中存在的部分。
對象頭
我們可以對這三個部分分別進行更深入的了解,首先是對象頭:
對象頭分為MarkWord和類型指針,MarkWord就是Oop對象中的_mark,其內部用於存儲對象自身運行時的數據,例如:HashCode、GC分代年齡、鎖狀態標誌、持有鎖的線程、偏向線程Id、偏向時間戳等。
這是筆者在網上找的關於對象頭的內存布局(64位操作系統,無指針壓縮):
對象頭佔用128位,也就是16位元組,其中MarkWord佔8位元組,Klass Point(類型指針)佔8位元組,MarkWord中所存儲的信息,是這個對象最基本的一些信息,例如GC分代年齡,可以讓JVM判斷當前對象是否應該進入老年代,鎖狀態標誌,在處理並發的過程中,可以判斷當前要以什麼級別的手段來保證線程安全,從而優化同步操作的性能,其他的相信大家都比較了解,這裡就暫時先不一一列舉了。當然,對象頭在之後的並發專題依舊會有所提及。
而對象頭的另外8位元組,是KlassPoint,類型指針,在上一篇文章的Oop模型中,提到類型指針指向Klass對象,用於在運行時獲取對象所屬的類的元信息。
實例數據
何為實例數據,顧名思義,就是對象中的字段,用更嚴謹一點的話來說,類的非靜態屬性,在生成對象後,就是實例數據,而實例數據這部分的大小,就是實實在在的多個屬性所佔的空間的和,例如有下面這樣一個類:
public class Test{ private int a; private double b; private boolean c; }
那麼在new Test()
操作之後,這個對象的實例數據區所佔的空間就是4+8+1 = 13位元組,以此類推。
而在Java中,基本數據類型都有其大小:
boolean — 1B
byte — 1B
short — 2B
char — 2B
int — 4B
float — 4B
double — 8B
long — 8B
除了上述的八個基本數據類型以外,類中還可以包含引用類型對象,那麼這部分如何計算呢?
這裡需要分情況討論,由於還沒有說到指針壓縮,那麼大家就先記下好了:
如果是32位機器,那麼引用類型占4位元組。
如果是64位機器,那麼引用類型占8位元組。
如果是64位機器,且開啟了指針壓縮,那麼引用類型占4位元組。
如果對象的實例數據區,存在別的引用類型對象,實際上只是保存了這個對象的地址,理解了這個概念,就可以對這三種情況進行理解性記憶了。
為什麼32位機器的引用類型佔4個位元組,而64位機器引用類型佔8位元組?
這裡就要提到一個尋址的概念,既然保存了內存地址,那就是為了日後方便尋址,而32位機器的含義就是,其地址是由32個Bit位組成的,所以要記錄其內存地址,需要使用4位元組,64位同理,需要8位元組。
對齊填充
我們提到對象是由三部分構成,但是上文只涉及了兩部分,還有一部分就是對齊填充,這個是比較特殊的一個部分,只存在於邏輯中,這裡需要科普一下,JVM中的對象都有一個特性,那就是8位元組對齊,什麼叫8位元組對齊呢,就是一個對象的大小,只能是8的整數倍,如果一個對象不滿8的整數倍,則會對其進行填充。
看到這裡可能有同學就會心存疑惑,那假設一個對象的內容只佔20位元組,那麼根據8位元組對齊特性,這個對象不就會變成24位元組嗎?那豈不是浪費空間了?根據8位元組對其的邏輯,這個問題的答案是肯定的,假設一個對象只有20位元組,那麼就會填充變成24位元組,而多出的這四個位元組,就是我們所說的對齊填充,筆者在這裡畫一張圖來描述一下:
對象頭在不考慮指針壓縮的情況下,佔用16個位元組,實例數據區,我們假設是一個int類型的數據,佔用4個位元組,那麼這裡一共是20位元組,那麼由於8位元組對齊特性,對象就會填充到24位元組。
那麼為什麼要這麼去設計呢?,剛開始筆者也有這樣的疑惑,這樣設計會有很多白白浪費掉的空間,畢竟填充進來的數據,在邏輯上是沒有任何意義的,但是如果站在一個設計者的角度上看,這樣的設計在日後的維護中是最為方便的。假設對象沒有8位元組對齊,而是隨機大小分佈在內存中,由於這種不規律,會造成設計者的代碼邏輯變得異常複雜,因為設計者根本不知道你這個對象到底有多大,從而沒有辦法完整地取出一整個對象,還有可能在這種不確定中,取到其它對象的數據,造成系統混亂。
當然,有些同學覺得設計上的問題總能克服,這點原因還不足以讓我們浪費內存,這就是我理解的第二點原因,這麼設計還會有一種好處,就是提升性能,假設對象是不等長的,那麼為了獲取一個完整的對象,就必須一個位元組一個位元組地去讀,直到讀到結束符,但是如果8位元組對齊後,獲取對象就可以以8個位元組為單位進行讀取,快速獲取到一個對象,也不失為一種以空間換時間的設計方案。
那麼又有同學要問了,那既然8位元組可以提升性能,那為什麼不16位元組對齊呢,這樣豈不是性能更高嗎?答案是:沒有必要,有兩個原因,第一,我們對象頭最大是16位元組,而實例數據區最大的數據類型是8個位元組,所以如果選擇16位元組對齊,假設有一個18位元組的對象,那麼我們需要將其填充成為一個32位元組的對象,而選擇8位元組填充則只需要填充到24位元組即可,這樣不會造成更大的空間浪費。第二個原因,允許我在這裡賣一下關子,在之後的指針壓縮中,我們再詳細進行說明。
關於對象內存布局的證明方式
證明方式有兩種,一種是使用代碼的方式,還有一種就是使用上一篇文章中我們提到的,使用HSDB,可以直接了當地查看對象的組成,由於HSDB在上一篇文章中已經說過了,所以這裡只說第一種方式。
首先,我們需要引入一個maven依賴:
<!-- //mvnrepository.com/artifact/org.openjdk.jol/jol-core --> <dependency> <groupId>org.openjdk.jol</groupId> <artifactId>jol-core</artifactId> <version>0.10</version> </dependency>
引入這個依賴之後,我們就可以在控制台中查看對象的內存布局了,代碼如下:
public class Blog { public static void main(String[] args) { Blog blog = new Blog(); System.out.println(ClassLayout.parseInstance(blog).toPrintable()); } }
首先是關閉指針壓縮的情況,對齊填充為0位元組,對象大小為16位元組:
然後是開啟指針壓縮的情況,對齊填充為4位元組,對象大小依舊為16位元組:
解釋一下為什麼兩種情況都是16位元組:
開啟指針壓縮,對象大小(16位元組) = MarkWord(8位元組)+ KlassPointer(4位元組)+ 數組長度(0位元組) + 實例數據(0位元組)+ 對齊填充(4位元組) 關閉指針壓縮,對象大小(16位元組)= MarkWord(8位元組)+ KlassPointer(8位元組)+ 數組長度(0位元組)+ 實例數據(0位元組) + 對齊填充(0位元組)
如何計算對象的內存佔用
在第一節中我們已經詳細闡述了對象在內存中的布局,主要分為三部分,對象頭、實例數據、對齊填充,並且進行了證明。這一節中來帶大家計算對象的內存佔用。
實際上在剛才對內存布局的闡述中,應該有很多同學都對如何計算對象內存佔用有了初步的了解,其實這也並不難,無非就是把三個區域的佔用求和,但是上文中我們只是說了幾種簡單的情況,所以這裡主要來說說我們上文中沒有考慮到的,我們將分情況進行討論並證明。
對象中只存在基本數據類型
public class Blog { private int a = 10; private long b = 20; private double c = 0.0; private float d = 0.0f; public static void main(String[] args) { Blog blog = new Blog(); System.out.println(ClassLayout.parseInstance(blog).toPrintable()); } }
這種情況是除了空對象以外的最簡單的一種情況,假設對象中存在的屬性全都是Java八種基本類型中的某一種或某幾種類型,對象的大小如何計算?
不妨先來看看結果:
對於這種情況,我們只需要簡單地將對象頭+示例數據+對齊填充即可,由於我們在對象中存在四個屬性,分別為int(4位元組)+long(8位元組)+double(8位元組)+float(4位元組),可以得出實例數據為24位元組,而對象頭為12位元組(指針壓縮開啟),那麼一共就是36位元組,但是由於Java中的對象必須得是8位元組對齊,所以對齊填充會為其補上4位元組,所以整個對象就是:
對象頭(12位元組)+實例數據(24位元組)+對齊填充(4位元組) = 40位元組
對象中存在引用類型(關閉指針壓縮)
那麼對象中存在引用類型,該如何計算?這裡涉及到開啟指針壓縮和關閉指針壓縮兩種情況,我們先來看看關閉指針壓縮的情況,究竟有何不同。
public class Blog { Map<String,Object> objMap = new HashMap<>(16); public static void main(String[] args) { Blog blog = new Blog(); System.out.println(ClassLayout.parseInstance(blog).toPrintable()); } }
同樣,先看結果:
可以看到,對象的實例數據區存在一個引用類型屬性,就像第一節中說的,只是保存了指向這個屬性的指針,這個指針在關閉指針壓縮的情況下,佔用8位元組,不妨也計算一下它的大小:
對象頭(關閉指針壓縮,佔用16位元組)+實例數據(1個對象指針8位元組)+ 對齊填充(無需進行填充)=24位元組
對象中存在引用類型(開啟指針壓縮)
那麼如果是開啟指針壓縮的情況呢?
如果是開啟指針壓縮的情況,類型指針和實例數據區的指針都僅佔用4位元組,所以其內存大小為:
MarkWord(8B)+KlassPointer(4B)+實例數據區(4B)+對齊填充(0B) = 16B
數組類型(關閉指針壓縮)
如果是數組類型的對象呢?由於在上文中已經形成的定向思維,大家可能已經開始使用原先的套路開始計算數組對象的大小了,但是這裡的情況就相對比普通對象要複雜很多,出現的一些現象可能要讓大家大跌眼鏡了。
我們這裡枚舉三種情況:
public class Blog { private int a = 10; private int b = 10; public static void main(String[] args) { //對象中無屬性的數組 Object[] objArray = new Object[3]; //對象中存在兩個int型屬性的數組 Blog[] blogArray = new Blog[3]; //基本類型數組 int[] intArray = new int[1]; System.out.println(ClassLayout.parseInstance(blogArray).toPrintable()); System.out.println(ClassLayout.parseInstance(objArray).toPrintable()); System.out.println(ClassLayout.parseInstance(intArray).toPrintable()); } }
依舊是先看結果:
首先是第一種情況:對象中無屬性的數組:
同樣的一個打印對象操作,除了MarkWord、KlassPointer、實例數據對齊填充以外,多了一篇空間,我們可以發現,原先在普通對象的算法,已經不適用於數組的算法了,因為在數組中出現了一個很詭異而我們從沒有提到過的東西,那就是對象頭的第三部分——數組長度。
數組長度究竟為何物?
如果對象是一個數組,它的內部除了我們剛才說的那些以外,還會存在一個數組長度屬性,用於記錄這個數組的大小,數組長度為32個Bit,也就是4個位元組,這裡也可以關聯上一個基礎知識,就是Java中數組最大可以設置為多大?跟計算內存地址的表示方式類似,由於其佔4個位元組,所以數組的長度最大為2^32。
我們再來看看實例數據區的情況,由於其存放了三個對象,而我們在對象中存在引用類型這個情況中闡述過,即使存在對象,我們也只是保存了指向其內存地址的指針,這裡由於關閉了指針壓縮,所以每個指針佔用8個位元組,一共24位元組。
再回到圖上,在前幾個案例中,對齊填充都在實例數據區之後,但是這裡對齊填充是處於對象頭的第四部分。在實例數據區之前,也就是在數組對象中,出現了第二段的對齊填充,那麼數組對象的內存布局就應該變成下圖這樣:
我們可以在另外兩種情況中驗證這個想法:
對象中存在兩個int型屬性的數組:
基本數據類型數組:
我們可以看到,即使對象中存在兩個int類型的數組,依舊保存其內存地址指針,所以依舊是4位元組,而在基本類型的數組中,其保存的是實例數據的大小,也就是int類型的長度4位元組,如果數組長度是3,這裡的實例數據就是12位元組,以此類推,而這種情況下,同樣出現了兩段填充的現象,由於我們代碼中的數組長度設置為1,所以這裡的對象大小為:
MarkWord(8B)+KlassPointer(8B)+數組長度(4B)+第一段對齊填充(4B)+實例數據區(4B)+第二段對齊填充(4B) = 32B
數組類型(開啟指針壓縮)
那麼如果開啟指針壓縮又會是什麼樣的狀況呢?有了上面的基礎,大家可以先考慮一下,我這裡就直接上圖了。
長度為1的基本類型數組:
在對象中存在引用類型(開啟指針壓縮)中我說過只要開啟了指針壓縮,我們的類型指針就是佔用4個位元組,由於是數組,對象頭中依舊多了一個存放對象的指針,但是對象頭中的對齊填充消失了,所以其大小為:
MarkWord(8B)+KlassPointer(4B)+數組長度(4B)+實例數據區(4B)+對齊填充(4B) = 24B
僅存在靜態變量
最後一種情況,假設類中僅存在一個靜態變量(開啟指針壓縮):
public class Blog { private static Map<String,Object> mapObj = new HashMap<>(16); public static void main(String[] args) { Blog blog = new Blog(); int[] intArray = new int[1]; System.out.println(ClassLayout.parseInstance(blog).toPrintable()); } }
可以看到其內部並沒有實例數據區,原因很簡單,我們也說過,大家要記住,只有類的非靜態屬性,在生成對象後,才是實例數據,而靜態變量不在其列。
總結
關於如何對象的大小,其實很簡單,我們首先關注是否是開啟了指針壓縮,然後關注其是普通對象還是數組對象,這裡做個總結。
如果是普通對象,那麼只需要計算:MarkWord+KlassPointer(8B)+實例數據+對齊填充。
如果是數組對象,則需要分兩種情況,如果是開啟指針壓縮的情況,那麼分為五段:MarkWord+KlassPointer(4B)+第一段對齊填充+實例數據+第二段對齊填充。
如果對象中存在引用類型數據,則保存的只是指向這個數據的指針,在開啟指針壓縮的情況下,為4位元組,關閉指針壓縮為8位元組。
如果對象中存在基本數據類型,那麼保存的就是其實體,這就需要按照8中基本數據類型的大小來靈活計算了。
指針壓縮
在本篇文章中我們和指針壓縮打過多次交道,那麼究竟是什麼指針壓縮?
簡單來說,指針壓縮就是一種節約內存的技術,並且可以增強內存尋址的效率,由於在64位系統中,對象中的指針佔用8位元組,也就是64Bit,我們再來回顧一下,8位元組指針可以表示的內存大小是多少?
2^64 = 18446744073709552000Bit = 2147483648GB
很顯然,站在內存的角度,首先,在當前的硬件條件下,我們幾乎不可能達到這種內存級別。其次,64位對象引用需要佔用更多的對空間,留給其他數據的空間將會減少,從而加快GC的發生。站在CPU的角度,對象引用變大了,CPU能緩存的對象也就少了,每次使用時都需要去內存中取,降低了CPU的效率。所以,在設計時,就引入了指針壓縮的概念。
指針壓縮原理
我們都知道,指針壓縮會將原先的8位元組指針,壓縮到4位元組,那麼4位元組能表示的內存大小是多少?
2^32 = 4GB
這個內存級別,在當前64位機器的大環境下,在大多數的生產環境下已經是不夠用了,需要更大的尋址範圍,但是剛才我們看到,指針壓縮之後,對象指針的大小就是4個位元組,那麼我們需要了解的就是,JVM是如何在指針壓縮的條件下,提升尋址範圍的呢?
需要注意的一點是:由於32位操作系統,能夠識別的最大內存地址就是4GB,所以指針壓縮後也依舊夠用,所以32位操作系統不在這個討論範疇內,這裡只針對64位操作系統進行討論。
首先我們來看看,指針壓縮之後,對象的內存地址存在何種規律:
假設這裡有三個對象,分別是對象A 8位元組,對象B 16位元組,對象C 24位元組。
那麼其內存地址(假設從00000000)開始,就是:
A:00000000 00000000 00000000 00000000 0x00000000
B:00000000 00000000 00000000 00001000 0x00000008
C:00000000 00000000 00000000 00010000 0x00000010
由於Java中對象存在8位元組對齊的特性,所以所有對象的內存地址,後三位永遠是0。那麼這裡就是JVM在設計上解決這個問題的精妙之處。
首先,在存儲的時候,JVM會將對象內存地址的後三位的0抹去(右移3位),在使用的時候,將對象的內存地址後三位補0(左移3位),這樣做有什麼好處呢。
按照這種邏輯,在存儲的時候,假設有一個對象,所在的內存地址已經達到了8GB,超出了4GB,那麼其內存地址就是:**00000010 00000000 00000000 00000000 00000000 **
很顯然,這已經超出了32位(4位元組)能表示的最大範圍,那麼依照上文中的邏輯,在存儲的時候,JVM將對象地址右移三位,變成01000000 00000000 00000000 00000000,而在使用的時候,在後三位補0(左移3位),這樣就又回到了最開始的樣子:**00000010 00000000 00000000 00000000 00000000 **,就又可以在內存中找到對象,並加載到寄存器中進行使用了。
由於8位元組對齊,內存地址後三位永遠是0這一特殊的規律,JVM使用這一巧妙地設計,將僅佔有32位的對象指針,變成實際上可以使用35位,也就是最大可以表示32GB的內存地址,這一精妙絕倫的設計,筆者嘆為觀止。
當然,這裡只是說JVM在開啟指針壓縮下的尋址能力,而實際上64位操作系統的尋址能力是很強大的,如果JVM被分配的內存大於32GB,那麼會自動關閉指針壓縮,使用8位元組的指針進行尋址。
解答遺留問題:為什麼不使用16位元組對齊
第一節的遺留問題,為什麼不用16位元組對齊的第二個原因,其實學習完指針壓縮之後,答案已經很明了了,我們在使用8位元組對齊時並開啟指針壓縮的情況下,最大的內存表示範圍已經達到了32GB,如果大於32GB,關閉指針壓縮,就可以獲取到非常強大的尋址能力。
當然,如果假設JVM中沒有指針壓縮,而是開始就設定了對象指針只有8位元組,那麼此時如果需要又超過32GB的內存尋址能力,那麼就需要使用16位元組對齊,原理和上面說的相同,如果是16位元組對齊,那麼對象的內存地址後4位一定為0,那麼我們在存儲和讀取的時候分別左移右移4位,就可以僅用32位的指針,獲取到36位的尋址能力,尋址能力也就可以達到64GB了。
結語
本篇文章是JVM系列的第二篇,主要基於上一篇的《類和對象在JVM中是如何存儲的,竟然有一半人回答不上來!》來解構Java對象,主要闡述了Java對象的內存布局,對其進行了分情況討論,並在代碼中進行佐證,最後深入淺出地談了談關於指針壓縮技術的技術場景及實現原理。
那麼JVM在宏觀上,究竟是一種怎樣的結構,由什麼區域構成,以及JVM在運行時是如何調度這些對象的,這些內容筆者會在下一篇文章中進行闡述。