高端面試必備:一個Java對象占用多大內存


這個問題一般會出現在稍微高端一點的 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 字節。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM