這個問題一般會出現在稍微高端一點的 Java 面試環節。要求面試者不僅對 Java 基礎知識熟悉,更重要的是要了解內存模型。
Java 對象模型
HotSpot JVM 使用名為 oops (Ordinary Object Pointers) 的數據結構來表示對象。這些 oops 等同於本地 C 指針。 instanceOops 是一種特殊的 oop,表示 Java 中的對象實例。

在 Hotspot VM 中,對象在內存中的存儲布局分為 3 塊區域:
- 對象頭(Header)
- 實例數據(Instance Data)
- 對齊填充(Padding)
對象頭又包括三部分:MarkWord、元數據指針、數組長度。
- MarkWord:用於存儲對象運行時的數據,好比 HashCode、鎖狀態標志、GC分代年齡等。這部分在 64 位操作系統下占 8 字節,32 位操作系統下占 4 字節。
- 指針:對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪一個類的實例。
這部分就涉及到指針壓縮的概念,在開啟指針壓縮的狀況下占 4 字節,未開啟狀況下占 8 字節。 - 數組長度:這部分只有是數組對象才有,若是是非數組對象就沒這部分。這部分占 4 字節。
實例數據就不用說了,用於存儲對象中的各類類型的字段信息(包括從父類繼承來的)。
關於對齊填充,Java 對象的大小默認是按照 8 字節對齊,也就是說 Java 對象的大小必須是 8 字節的倍數。若是算到最后不夠 8 字節的話,那么就會進行對齊填充。
那么為何非要進行 8 字節對齊呢?這樣豈不是浪費了空間資源?
其實不然,由於 CPU 進行內存訪問時,一次尋址的指針大小是 8 字節,正好也是 L1 緩存行的大小。如果不進行內存對齊,則可能出現跨緩存行的情況,這叫做 緩存行污染。

由於當 obj1 對象的字段被修改后,那么 CPU 在訪問 obj2 對象時,必須將其重新加載到緩存行,因此影響了程序執行效率。
也就說,8字節對齊,是為了效率的提高,以空間換時間的一種方案。固然你還能夠 16 字節對齊,可是 8 字節是最優選擇。
正如我們之前看到的,JVM 為對象進行填充,使其大小變為 8 個字節的倍數。使用這些填充后,oops 中的最后三位始終為零。這是因為在二進制中 8 的倍數的數字總是以 000 結尾。

由於 JVM 已經知道最后三位始終為零,因此在堆中存儲那些零是沒有意義的。相反假設它們存在並存儲 3 個其他更重要的位,以此來模擬 35 位的內存地址。現在我們有一個帶有 3 個右移零的 32 位地址,所以我們將 35 位指針壓縮成 32 位指針。這意味着我們可以在不使用 64 位引用的情況下使用最多 32 GB :
(2(32+3)=235=32 GB) 的堆空間。
當 JVM 需要在內存中找到一個對象時,它將指針向左移動 3 位。另一方面當堆加載指針時,JVM 將指針向右移動 3 位以丟棄先前添加的零。雖然這個操作需要 JVM 執行更多的計算以節省一些空間,不過對於大多數CPU來說,位移是一個非常簡單的操作。
要啟用 oop 壓縮,我們可以使用標志 -XX:+UseCompressedOops 進行調整,只要最大堆大小小於 32 GB。當最大堆大小超過32 GB時,JVM將自動關閉 oop 壓縮。
當 Java 堆大小大於 32GB 時也可以使用壓縮指針。雖然默認對象對齊是 8 個字節,但可以使用 -XX:ObjectAlignmentInBytes 配置字節值。指定的值應為 2 的冪,並且必須在 8 和 256 的范圍內。
我們可以使用壓縮指針計算最大可能的堆大小,如下所示:
4 GB * ObjectAlignmentInBytes
例如,當對象對齊為 16 個字節時,通過壓縮指針最多可以使用 64 GB 的堆空間。
基本類型占用存儲空間和指針壓縮
基礎對象占用存儲空間
Java 基礎對象在內存中占用的空間如下:
| 類型 | 占用空間(byte) |
|---|---|
| boolean | 1 |
| byte | 1 |
| short | 2 |
| char | 2 |
| int | 4 |
| float | 4 |
| long | 8 |
| double | 8 |
另外,引用類型在 32 位系統上每個引用對象占用 4 byte,在 64 位系統上每個引用對象占用 8 byte。
對於 32 位系統,內存地址的寬度就是32位,這就意味着我們最大能獲取的內存空間是 2^32(4 G)字節。在 64 位的機器中,理論上我們能獲取到的內存容量是 2^64 字節。
當然這只是一個理論值,現實中因為有一堆有關硬件和軟件的因素限制,我們能得到的內存要少得多。比如說,Windows 7 Home Basic 64 位最大僅支持 8GB 內存、Home Premium 為 192GB,此外高端的Enterprise、Ultimate 等則支持支持 192GB 的最大內存。
因為系統架構限制,Windows 32位系統能夠識別的內存最大在 3.235GB 左右,也就是說 4GB 的內存條有 0.5GB 左右用不了。2GB 內存條或者 2GB+1GB 內存條用 32 位系統絲毫沒有影響。
現在一般都是使用 64 位的系統,雖然能支持更大的內存空間,但是也會有另一些問題。
像引用類型在 64 位系統上占用 8 個字節,那么引用對象將會占用更多的堆空間。從而加快了 GC 的發生。其次會降低CPU緩存的命中率,緩存大小是固定的,對象越大能緩存的對象個數就越少。
Java 中基礎數據類型是在棧上分配還是在堆上分配?
我們繼續深究一下,基本數據類占用內存大小是固定的,那具體是在哪分配的呢,是在堆還是棧還是方法區?大家不妨想想看! 要解答這個問題,首先要看這個數據類型在哪里定義的,有以下三種情況。
- 如果在方法體內定義的,這時候就是在棧上分配的
- 如果是類的成員變量,這時候就是在堆上分配的
- 如果是類的靜態成員變量,在方法區上分配的
指針壓縮
引用類型在 64 位系統上占用 8 個字節,雖然一個並不大,但是耐不住多。
所以為了解決這個問題,JDK 1.6 開始 64 bit JVM 正式支持了 -XX:+UseCompressedOops (需要jdk1.6.0_14) ,這個參數可以壓縮指針。
啟用 CompressOops 后,會壓縮的對象包括:
- 對象的全局靜態變量(即類屬性);
- 對象頭信息:64 位系統下,原生對象頭大小為 16 字節,壓縮后為 12 字節;
- 對象的引用類型:64 位系統下,引用類型本身大小為 8 字節,壓縮后為 4 字節;
- 對象數組類型:64 位平台下,數組類型本身大小為 24 字節,壓縮后 16 字節。
當然壓縮也不是萬能的,針對一些特殊類型的指針 JVM是不會優化的。 比如:
- 指向非 Heap 的對象指針
- 局部變量、傳參、返回值、NULL指針。
CompressedOops 工作原理
32 位內最多可以表示 4GB,64 位地址為 堆的基地址 + 偏移量,當堆內存 < 32GB 時候,在壓縮過程中,把 偏移量 / 8 后保存到 32 位地址。在解壓再把 32 位地址放大 8 倍,所以啟用 CompressedOops 的條件是堆內存要在 4GB * 8=32GB 以內。
JVM 的實現方式是,不再保存所有引用,而是每隔 8 個字節保存一個引用。例如,原來保存每個引用 0、1、2...,現在只保存 0、8、16...。因此,指針壓縮后,並不是所有引用都保存在堆中,而是以 8 個字節為間隔保存引用。
在實現上,堆中的引用其實還是按照 0x0、0x1、0x2... 進行存儲。只不過當引用被存入 64 位的寄存器時,JVM 將其左移 3 位(相當於末尾添加 3 個0),例如 0x0、0x1、0x2... 分別被轉換為 0x0、0x8、0x10。而當從寄存器讀出時,JVM 又可以右移 3 位,丟棄末尾的 0。(oop 在堆中是 32 位,在寄存器中是 35 位,2的 35 次方 = 32G。也就是說使用 32 位,來達到 35 位 oop 所能引用的堆內存空間)。
Java 對象到底占用多大內存
前面我們分析了 Java 對象到底都包含哪些東西,所以現在我們可以開始剖析一個 Java 對象到底占用多大內存。
由於現在基本都是 64 位的虛擬機,所以后面的討論都是基於 64 位虛擬機。 首先記住公式,對象由 對象頭 + 實例數據 + padding 填充字節組成,虛擬機規范要求對象所占內存必須是 8 的倍數,padding 就是干這個的。
上面說過對象頭由 Markword + 類指針kclass(該指針指向該類型在方法區的元類型) 組成。
Markword
Hotspot 虛擬機文檔 “oops/oop.hp” 有對 Markword 字段的定義:
64 bits:
--------
unused:25 hash:31 -->| unused:1 age:4 biased_lock:1 lock:2 (normal object)
JavaThread:54 epoch:2 unused:1 age:4 biased_lock:1 lock:2 (biased object)
PromotedObject:61 --------------------->| promo_bits:3 ----->| (CMS promoted object)
size:64 ----------------------------------------------------->| (CMS free block)
這里簡單解釋下這幾種 object:
- normal object,初始 new 出來的對象都是這種狀態
- biased object,當某個對象被作為同步鎖對象時,會有一個偏向鎖,其實就是存儲了持有該同步鎖的線程 id,關於偏向鎖的知識這里就不再贅述了,大家可以自行查閱相關資料。
- CMS promoted object 和 CMS free block 我也不清楚到底是啥,但是看名字似乎跟CMS 垃圾回收器有關,這里我們也可以暫時忽略它們
我們主要關注 normal object, 這種類型的 Object 的 Markword 一共是 8 個字節(64位),其中 25 位暫時沒有使用,31 位存儲對象的 hash 值(注意這里存儲的 hash 值對根據對象地址算出來的 hash 值,不是重寫 hashcode 方法里面的返回值),中間有 1 位沒有使用,還有 4 位存儲對象的 age(分代回收中對象的年齡,超過 15 晉升入老年代),最后三位表示偏向鎖標識和鎖標識,主要就是用來區分對象的鎖狀態(未鎖定,偏向鎖,輕量級鎖,重量級鎖)
biased object 的對象頭 Markword 前 54 位來存儲持有該鎖的線程 id,這樣就沒有空間存儲 hashcode了,所以 對於沒有重寫 hashcode 的對象,如果 hashcode 被計算過並存儲在對象頭中,則該對象作為同步鎖時,不會進入偏向鎖狀態,因為已經沒地方存偏向 thread id 了,所以我們在選擇同步鎖對象時,最好重寫該對象的 hashcode 方法,使偏向鎖能夠生效。
我們來 new 一個空對象:
class ObjA {
}
理論上一個空對象占用內存大小只有對象頭信息,對象頭占 12 個字節。那么 ObjA.class 應該占用的存儲空間就是 12 字節,考慮到 8 字節的對齊填充,那么會補上 4 字節填充到 8 的 2倍,總共就是 16字節。怎么驗證我們的結論呢?JDK 提供了一個工具,JOL 全稱為 Java Object Layout,是分析 JVM 中對象布局的工具,該工具大量使用了 Unsafe、JVMTI 來解碼布局情況。下面我們就使用這個工具來獲取一個 Java 對象的大小。
首先引入 Maven 依賴:
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-core</artifactId>
<version>0.14</version>
</dependency>
我們來看使用:
package com.trace.agent;
import org.openjdk.jol.info.ClassLayout;
import java.util.List;
/**
* @author rickiyang
* @date 2020-12-27
* @Desc TODO
*/
public class ObjSiZeTest {
public static void main(String[] args) {
ClassLayout classLayout = ClassLayout.parseInstance(new ObjA());
System.out.println(classLayout.toPrintable());
}
}
class ObjA {
}
輸出:
com.trace.agent.ObjA object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
12 4 (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
從上面的結果能看到對象頭是 12 個字節,還有 4 個字節的 padding,一共 16 個字節。我們的推測結果沒有錯。
接着看另一個案例:
package com.trace.agent;
import org.openjdk.jol.info.ClassLayout;
/**
* @author rickiyang
* @date 2020-12-27
* @Desc TODO
*/
public class ObjSiZeTest {
public static void main(String[] args) {
ClassLayout classLayout = ClassLayout.parseInstance(new ObjA());
System.out.println(classLayout.toPrintable());
}
}
class ObjA {
private int i;
private double d;
private Integer io;
}
一共三個屬性:
int 類型占 4 個字節 ,double 類型占 8 個字節,Integer 是引用類型,64 位系統占 4 個字節。一共 16 個字節。
加上對象頭 12 字節,顯然不夠 8 的倍數,所以還得 4 字節的填充,加起來就是 32 字節
接着使用 JOL 來分析一下:
com.trace.agent.ObjA object internals:
OFFSET SIZE TYPE DESCRIPTION VALUE
0 4 (object header) 01 00 00 00 (00000001 00000000 00000000 00000000) (1)
4 4 (object header) 00 00 00 00 (00000000 00000000 00000000 00000000) (0)
8 4 (object header) 43 c1 00 f8 (01000011 11000001 00000000 11111000) (-134168253)
12 4 int ObjA.i 0
16 8 double ObjA.d 0.0
24 4 java.lang.Integer ObjA.io null
28 4 (loss due to the next object alignment)
Instance size: 32 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
一共 32 字節。

