寫在前面
如果覺得有所收穫,記得點個關注和點個贊,感謝支持。
使用面嚮對象語言,離不開的就是對象,現在回過頭來思考一下,真的瞭解所使用語言的對象麼?我自己有點心虛,對於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 字節。