1. 程式人生 > >理解Java物件:要從記憶體佈局及底層機制說起,話說….

理解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依賴:

<!-- https://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在執行時是如何排程這些物件的,這些內容筆者會在下一篇文章中進行闡