你會算對象的大小麼?不會的話就看看這篇文章吧

寫在前面

如果覺得有所收穫,記得點個關注和點個贊,感謝支持。
使用面嚮對象語言,離不開的就是對象,現在回過頭來思考一下,真的瞭解所使用語言的對象麼?我自己有點心虛,對於Java的對象,我瞭解的尚且還不夠深入,對於一些底層的東西,還有待進一步瞭解學習。這一篇博文,來講講Java對象的大小,學習如何計算Java對象的大小。如果你想深入Java 對象的內存設計,以及在做內存優化時,需要知道每個對象佔用的內存的大小,所以這一點還是很重要的,需要好好理解。要計算Java對象佔用內存的大小,首先需要了解Java對象在內存中的實際存儲方式和存儲格式。接下來,我們就來學習相關的知識。

思考以及預備知識

搞清楚存儲在內存中的對象,它具體是如何存儲的,存儲時都需要存哪些信息,以及存這些信息的意義是什麼。比如看下面這段代碼,下面這兩行代碼當中的 list 對象是如何存儲起來的?

List list = new ArrayList;
list.add("hello, world!");

要學習對象是怎麼存儲在內存當中的,就要從很原始的地方說起,先學習 JVM 的內存結構,這篇文章不講很具體的JVM內存結構的知識,我這邊只是提一下稍後需要用到的一些知識點。JVM 在運行 Java 程序時,會管理一塊內存區域,這一片區域被稱爲運行時數據區域,從結構上可以分爲五個部分,分別是:

  • Java 虛擬機棧:線程私有,存儲局部變量等
  • 本地方法棧:線程私有,存儲本地方法的變量等
  • 程序計數器:線程私有,存儲字節碼的地址(程序執行到第幾行了)
  • 堆:線程共享,存儲幾乎所有對象
  • 方法區:線程共享,存儲類的結構信息(字段、構造方法等等)

在這裏插入圖片描述
我們今天要說的,只是棧和堆(棧指的是 Java 虛擬機棧)。非常淺薄地講,棧存放的是局部變量以及對象的地址,堆存放的是對象的實體。(看書發現,棧中存放的並不一定是對象地址,但這是最常見的尋找堆對象的方式)簡單製作了一張圖,描述了代碼、棧、堆之間的關係。
在這裏插入圖片描述
這裏要注意一點的是,內存結構和內存模型並不是一個概念:當我們說內存結構時,通常是指JVM 內存結構,這是真實存在的,指的是上文介紹的 Java 虛擬機棧、堆、本地方法棧等等那五部分構成的 JVM 運行時數據區域,這是在結構上把 JVM 的內存分成了多個部分。

當我們說內存模型時,通常是指Java 內存模型,這是虛擬存在的,指的是面對併發時 Java 是如何實現內存訪問一致的,牽扯到了主內存和工作內存等知識,這是在模型和概念上,屏蔽各種硬件和操作系統的內存訪問差異,來實現併發內存一致性。

如何計算對象的大小

如何計算對象的大小。這個問題實際上可以拆成兩個問題:

  • 對象由哪些部分組成?
  • 每部分各佔多少字節?

在這兩個問題的基礎上,自然會問出第三個問題:

  • 組成對象的這些基礎部分,各自是做什麼的?

這裏要多說一下的是,本篇文章提到的內容,實際上是 HotSpot 虛擬機的實現,而並非所有 Java 虛擬機的實現,但是目前基本上所有的 Java 程序都跑在 HostSpot 虛擬機上面。

所有對象都可以籠統地切分成兩部分:對象頭(Header)和對象內容(Instance Data)。舉一個實際的例子:

class Person {
    private String name;
    private int age;
}

對於上面這個 Person 類,它實例化出來的對象同樣具有對象頭和對象內容兩部分,name 和 age 都是對象的內部變量,屬於對象內容,而對象頭是其餘一些輔助信息。我繪製了一張圖,畫出了在最常見情況下(64 位虛擬機開啓指針壓縮),對象在內存中的結構,後文都是在解釋這個結構的具體信息。
在這裏插入圖片描述

對象內容

對象內容準確地講應該叫做實例數據(Instance Data),比較簡單,因此我們先講完。正如之前提到的 Person 對象的例子,對象內的屬性(包括基本數據類型 int age 和引用的另一個對象 String name),這些屬性所佔的內容大小,就是對象內容的大小。在該例子中,int 類型的 age 佔 4 個字節(即 32 位),引用另一個對象時,存儲的是對象的地址,地址是一個 int 類型的指針,因此 String 類型的 name 存儲在 Person 對象中也佔 4 個字節(即 32 位),兩個屬性加起來一共佔 8 個字節。因此計算對象內容的大小,實際上就是分兩部分,基本數據類型一類,佔內容大小加起來,引用別的對象佔一類,引用一個就是 4 字節(int 的大小),引用 N 個對象就佔 N*4 個字節。下面列舉了 8 種基本數據類型的大小。

基本數據類型 大小(字節)
byte 1
char 2(表示一個 UTF-16be 編碼單元,生僻字用兩個char)
short 2
int 4
float 4
long 8
double 8
boolean 通常是1

此外還要注意的一點是,如果 A 類繼承自 B 類,那麼計算 A 類的對象內容大小時,繼承來的 B 類的屬性也是要算在內的。比如計算 ArrayList 對象大小的時候,它的父類 AbstractList 中的屬性,也是要計算在內的。

對象頭

對象頭(Header)比較複雜,它包含着對象的“冗餘信息”,這些信息或實現併發鎖,或幫助垃圾分類,或包含類的信息。從整體上看,對象頭包含三部分的信息,分別是

  • 標記字段
  • 地址
  • 數組長度

標記字段

標記字段(Mark Word)是對象頭中最複雜的內容,需要對照上面繪製的圖來看。由於內存空間寸土寸金,在希望對象能夠記錄更多信息的同時,還要儘可能地壓縮空間,在這種背景之下,32 位虛擬機的對象標記字段長 4 字節,64 位虛擬機的對象標記字段長 8 字節(現在基本都是 64 位了吧),並且都有着動態定義的數據結構,以便在極小的空間內存儲儘量多的數據。32 位和 64 位的存儲長度不同,僅僅是因爲地址指針長度引起的變化,在存儲的內容類型方面沒有區別。(具體的標記字段信息可見文末的備註)。以我當下的理解,標記字段主要實現了三個事情:

  • 對併發情況下的 synchronized 支持
  • GC 垃圾回收
  • 保存 hashcode

標記字段共有五種狀態,分別是對應於 synchronized 的四種狀態(無鎖、偏向鎖、輕量級鎖和重量級鎖),以及一種 GC 狀態,這五種狀態通過 2 位標誌位實現(無鎖和偏向鎖的標誌位相同)。因此,瞭解標記字段的具體信息,實際上就是在瞭解 synchronized 鎖和垃圾回收的原理。這兩部分都有點難,本文暫時不討論了。

地址信息

對象頭中有一部分是地址信息,它實際上是一個類型指針,指向了該對象類型的地址。例如 person 對象的對象頭中的地址信息,指向了 Person 類的地址(類在方法區)。在這種設計下,可以通過對象找到類,比如在 main() 方法中實例化一個 Person 對象 person,在內存中尋址的過程爲:

  • main() 方法的 Java 棧中記錄着 person 對象的地址;
  • 根據這個地址在堆中找到了 person 對象;
  • person 對象的頭部又記錄着 Person 類的地址,根據這個地址在方法區中找到了 Person 類;

(實際上,在對象的頭部中保留類的地址信息,通過對象找到類的位置,這種設計是 HotSpot 虛擬機的設計,也有別的虛擬機不這麼設計,對象頭中並不包含類的地址,不通過對象找類。)地址信息的大小並不是固定的,這跟系統位數有關,32 位的虛擬機,指針是 32 位長,地址信息只需要 32 (即 4 字節),但是對於 64 位的虛擬機,指針是 64 位長,因此地址信息也需要擴增到 64 位(即 8 字節)。

32 位的虛擬機,理論上只能尋址到 4 GB 的內存空間(2^32 byte = 4 GB),而 64 位的虛擬機能尋址到更多地址。這樣的提升是有代價的,一方面內存佔用量變大了,原來只需要 4 個字節存儲一個地址,現在需要 8 個字節了(如果不需要比 4GB 更多的內存,用這麼大的空間是沒有意義的),另一方面尋址時操作位數更長的指針,主內存和各級緩存移動數據時,佔用的帶寬也會增加。Java 虛擬機爲了處理這個問題,提出了指針壓縮。

指針壓縮的簡易原理是這樣的:32 位的指針,當然只能找到 4 GB 個內存位置,如果我有一塊更大的內存區域,比如 10 GB,32 位的指針就不能指向這 10 GB 中的所有位置,但實際上並不需要找到這塊內存中的所有位置,它只需要找到要操作的開始位置就可以了。這意味着 32 位的指針可以引用 40 億個對象,而不是 40 億個字節。Java 對象的大小如果一定是 8 字節的整數倍(這個後文有講),那麼就可以使原來只能尋址 4 GB 的內存擴大 8 倍,到 32 GB 的內存。

因此對於分配內存低於 4 GB 的虛擬機,默認開啓指針壓縮,指針大小就是 32 位長,對於分配內存在 4 - 32 GB 之間的虛擬機,可以開啓指針壓縮算法,使指針大小依舊維持在 32 位長,但是對於更大的內存,無法開啓指針壓縮,指針大小必須是 64 位長。(因此分配內存並不是越大越好,32 GB 處會有一個門檻)指針壓縮並非毫無缺陷,這畢竟是多出來的算法,會增加 JVM 的計算量。

總結:對象頭中的地址信息大小,跟系統位數以及是否開啓指針壓縮有關,32 位系統、開啓了指針壓縮的 64 位系統的地址信息長 4 字節,普通 64 位系統的地址信息長 8 字節。

數組大小

數組大小並不是必須的,數組纔有,非數組沒有。因爲數組是 new 出來的,需要在堆上分配內存,在這個意義上講,數組就是對象的一種。數組的長度是需要記錄下來的,長度爲 4 字節。int 也是 4 字節,這就很容易讓人聯想在一起。Java 中 int 是有符號整型數,是有負值的,int 的最大值是 2^31 - 1,用二進制表示爲 01111111111111111111111111111111。數組的理論最大長度,也應該是 int 的最大值。

實際的使用中可能會小一點。例如 ArrayList 內部維護的數組,它的最大長度是 Integer.MAX_VALUE - 8,註釋稱這是因爲虛擬機的限制。又例如 HashMap 內部維護的數組,它的最大程度是 1 << 30,這是 1 位運算之後能獲得到的最大值(二進制爲 01000000000000000000000000000000)。

補充

在計算完對象頭和對象內容的大小之後,二者加起來並不一定是最終佔內存的大小,還要考慮內存對齊的問題。所有對象的字節大小,必須是 8 的整數倍,如果對象頭+對象內容算出來是 15 字節,那麼最終對象大小爲 16 字節,如果是 20 字節,那麼最終對象大小是 24 字節,總之如果不滿 8 的整數倍,都填充到 8 的整數倍,填充的部分叫做對齊填充(Padding),實際上就是佔位符。對齊填充的原因在於,HotSpot 虛擬機的自動內存管理系統,要求對象的起始地址必須是 8 字節的整數倍(這樣尋址更高效,而且實現了指針壓縮),因此對象的大小也就必須是 8 字節的整數倍。

三種情況(32 位虛擬機、64 位虛擬機、64 位虛擬機開啓指針壓縮)下,對象頭的具體存儲內容:

32 位虛擬機

在這裏插入圖片描述

64 位虛擬機

在這裏插入圖片描述

64 位虛擬機開啓指針壓縮

在這裏插入圖片描述

舉例計算對象大小

最後用一個例子檢驗上文中的內容,計算一個 HashMap 對象的大小。HashMap 類不是數組,在 64 位開啓指針壓縮的情況下,對象頭只包括 8 字節的標記字段和 4 字節的地址指針,總共 12 字節。

HashMap 類中分別有下列屬性:

  • entrySet (對象)
  • hashSeed (int)
  • loadFactor (float)
  • modCount (int)
  • size (int)
  • table (數組,當對象處理)
  • threshold (int)

檢查 HashMap 的所有父類,在 AbstractMap 中發現了兩個新的屬性:

  • keySet (對象)
  • values (對象)

算下來一共是 9 個屬性,每個屬性很巧都是 4 字節,一共是 9×4 = 36 字節,因此 HashMap 的對象內容爲 36 字節。HashMap 對象的對象頭 12 字節 + 對象內容 36 字節總共是 48 字節,是 8 字節的倍數,無需對齊填充。因此一個 HashMap 對象的大小是 48 字節。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章