高階面試必備:一個Java物件佔用多大記憶體

rickiyang發表於2020-12-29

這個問題一般會出現在稍微高階一點的 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 個位元組,但可以使用 -XXObjectAlignmentInBytes 配置位元組值。指定的值應為 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 位元組。

相關文章