理解Java對象:要從內存布局及底層機制說起,話說….

前言

大家好,又見面了,今天是JVM專題的第二篇文章,在上一篇文章中我們說了Java的類和對象在JVM中的存儲方式,並使用HSDB進行佐證,沒有看過上一篇文章的小夥伴可以點這裡:《類和對象在JVM中是如何存儲的,竟然有一半人回答不上來!

這篇文章主要會對Java對象進行詳細分析,基於上一篇文章,對Java對象的布局及其底層的一些機制進行解讀,相信這些會對後期JVM調優有很大的幫助。

對象的內存布局

在上篇文章中我們提到,對象在JVM中是由一個Oop進行描述的。回顧一下,Oop由對象頭(_mark、_metadata)以及實例數據區組成,而對象頭中存在一個_metadata,其內部存在一個指針,指向類的元數據信息,就是下面這張圖:

理解Java對象:要從內存布局及底層機制說起,話說....

而今天要說的對象的內存布局,其底層實際上就是來自於這張圖。

了解過對象組成的同學應該明白,對象由三部分構成,分別是:對象頭實例數據對齊填充組成,而對象頭和示例數據,對應的就是Oop對象中的兩大部分,而對齊填充實際上是一個只在邏輯中存在的部分。

對象頭

我們可以對這三個部分分別進行更深入的了解,首先是對象頭

對象頭分為MarkWord類型指針,MarkWord就是Oop對象中的_mark,其內部用於存儲對象自身運行時的數據,例如:HashCode、GC分代年齡、鎖狀態標誌、持有鎖的線程、偏向線程Id、偏向時間戳等。

這是筆者在網上找的關於對象頭的內存布局(64位操作系統,無指針壓縮):

理解Java對象:要從內存布局及底層機制說起,話說....

對象頭佔用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位元組,而多出的這四個位元組,就是我們所說的對齊填充,筆者在這裡畫一張圖來描述一下:

理解Java對象:要從內存布局及底層機制說起,話說....

對象頭在不考慮指針壓縮的情況下,佔用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位元組:

理解Java對象:要從內存布局及底層機制說起,話說....

然後是開啟指針壓縮的情況,對齊填充為4位元組,對象大小依舊為16位元組:

理解Java對象:要從內存布局及底層機制說起,話說....

解釋一下為什麼兩種情況都是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八種基本類型中的某一種或某幾種類型,對象的大小如何計算?

不妨先來看看結果:

理解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());
    }
}

 

同樣,先看結果:

理解Java對象:要從內存布局及底層機制說起,話說....

可以看到,對象的實例數據區存在一個引用類型屬性,就像第一節中說的,只是保存了指向這個屬性的指針,這個指針在關閉指針壓縮的情況下,佔用8位元組,不妨也計算一下它的大小:

對象頭(關閉指針壓縮,佔用16位元組)+實例數據(1個對象指針8位元組)+ 對齊填充(無需進行填充)=24位元組

對象中存在引用類型(開啟指針壓縮)

那麼如果是開啟指針壓縮的情況呢?

理解Java對象:要從內存布局及底層機制說起,話說....

如果是開啟指針壓縮的情況,類型指針實例數據區的指針都僅佔用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());
    }
}

 

依舊是先看結果:

首先是第一種情況:對象中無屬性的數組

理解Java對象:要從內存布局及底層機制說起,話說....

同樣的一個打印對象操作,除了MarkWord、KlassPointer、實例數據對齊填充以外,多了一篇空間,我們可以發現,原先在普通對象的算法,已經不適用於數組的算法了,因為在數組中出現了一個很詭異而我們從沒有提到過的東西,那就是對象頭的第三部分——數組長度

數組長度究竟為何物?

如果對象是一個數組,它的內部除了我們剛才說的那些以外,還會存在一個數組長度屬性,用於記錄這個數組的大小,數組長度為32個Bit,也就是4個位元組,這裡也可以關聯上一個基礎知識,就是Java中數組最大可以設置為多大?跟計算內存地址的表示方式類似,由於其佔4個位元組,所以數組的長度最大為2^32

我們再來看看實例數據區的情況,由於其存放了三個對象,而我們在對象中存在引用類型這個情況中闡述過,即使存在對象,我們也只是保存了指向其內存地址的指針,這裡由於關閉了指針壓縮,所以每個指針佔用8個位元組,一共24位元組。

再回到圖上,在前幾個案例中,對齊填充都在實例數據區之後,但是這裡對齊填充是處於對象頭的第四部分。在實例數據區之前,也就是在數組對象中,出現了第二段的對齊填充,那麼數組對象的內存布局就應該變成下圖這樣:

理解Java對象:要從內存布局及底層機制說起,話說....

我們可以在另外兩種情況中驗證這個想法:

對象中存在兩個int型屬性的數組

理解Java對象:要從內存布局及底層機制說起,話說....

基本數據類型數組

理解Java對象:要從內存布局及底層機制說起,話說....

我們可以看到,即使對象中存在兩個int類型的數組,依舊保存其內存地址指針,所以依舊是4位元組,而在基本類型的數組中,其保存的是實例數據的大小,也就是int類型的長度4位元組,如果數組長度是3,這裡的實例數據就是12位元組,以此類推,而這種情況下,同樣出現了兩段填充的現象,由於我們代碼中的數組長度設置為1,所以這裡的對象大小為:

MarkWord(8B)+KlassPointer(8B)+數組長度(4B)+第一段對齊填充(4B)+實例數據區(4B)+第二段對齊填充(4B) = 32B

數組類型(開啟指針壓縮)

那麼如果開啟指針壓縮又會是什麼樣的狀況呢?有了上面的基礎,大家可以先考慮一下,我這裡就直接上圖了。

長度為1的基本類型數組

理解Java對象:要從內存布局及底層機制說起,話說....

對象中存在引用類型(開啟指針壓縮)中我說過只要開啟了指針壓縮,我們的類型指針就是佔用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());
    }
}

 

理解Java對象:要從內存布局及底層機制說起,話說....

可以看到其內部並沒有實例數據區,原因很簡單,我們也說過,大家要記住,只有類的非靜態屬性,在生成對象後,才是實例數據,而靜態變量不在其列。

總結

關於如何對象的大小,其實很簡單,我們首先關注是否是開啟了指針壓縮,然後關注其是普通對象還是數組對象,這裡做個總結。

如果是普通對象,那麼只需要計算: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在運行時是如何調度這些對象的,這些內容筆者會在下一篇文章中進行闡述。