Java物件記憶體佈局

JavaDog發表於2019-03-29

我們知道在Java中基本資料型別的大小,例如int型別佔4個位元組、long型別佔8個位元組,那麼Integer物件和Long物件會佔用多少記憶體呢?本文介紹一下Java物件在堆中的記憶體結構以及物件大小的計算。

物件的記憶體佈局

一個Java物件在記憶體中包括物件頭、例項資料和補齊填充3個部分:

物件頭

  • Mark Word:包含一系列的標記位,比如輕量級鎖的標記位,偏向鎖標記位等等。在32位系統佔4位元組,在64位系統中佔8位元組;
  • Class Pointer:用來指向物件對應的Class物件(其對應的後設資料物件)的記憶體地址。在32位系統佔4位元組,在64位系統中佔8位元組;
  • Length:如果是陣列物件,還有一個儲存陣列長度的空間,佔4個位元組;

物件實際資料

物件實際資料包括了物件的所有成員變數,其大小由各個成員變數的大小決定,比如:byte和boolean是1個位元組,short和char是2個位元組,int和float是4個位元組,long和double是8個位元組,reference是4個位元組(64位系統中是8個位元組)。

Primitive TypeMemory Required(bytes)
boolean1
byte1
short2
char2
int4
float4
long8
double8

對於reference型別來說,在32位系統上佔用4bytes, 在64位系統上佔用8bytes。

對齊填充

Java物件佔用空間是8位元組對齊的,即所有Java物件佔用bytes數必須是8的倍數。例如,一個包含兩個屬性的物件:int和byte,這個物件需要佔用8+4+1=13個位元組,這時就需要加上大小為3位元組的padding進行8位元組對齊,最終佔用大小為16個位元組。

注意:以上對64位作業系統的描述是未開啟指標壓縮的情況,關於指標壓縮會在下文中介紹。

物件頭佔用空間大小

這裡說明一下32位系統和64位系統中物件所佔用記憶體空間的大小:

  • 在32位系統下,存放Class Pointer的空間大小是4位元組,MarkWord是4位元組,物件頭為8位元組;
  • 在64位系統下,存放Class Pointer的空間大小是8位元組,MarkWord是8位元組,物件頭為16位元組;
  • 64位開啟指標壓縮的情況下,存放Class Pointer的空間大小是4位元組,MarkWord是8位元組,物件頭為12位元組;
  • 如果是陣列物件,物件頭的大小為:陣列物件頭8位元組+陣列長度4位元組+對齊4位元組=16位元組。其中物件引用佔4位元組(未開啟指標壓縮的64位為8位元組),陣列MarkWord為4位元組(64位未開啟指標壓縮的為8位元組);
  • 靜態屬性不算在物件大小內。

指標壓縮

從上文的分析中可以看到,64位JVM消耗的記憶體會比32位的要多大約1.5倍,這是因為物件指標在64位JVM下有更寬的定址。對於那些將要從32位平臺移植到64位的應用來說,平白無辜多了1/2的記憶體佔用,這是開發者不願意看到的。

從JDK 1.6 update14開始,64位的JVM正式支援了 -XX:+UseCompressedOops 這個可以壓縮指標,起到節約記憶體佔用的新引數。

什麼是OOP?

OOP的全稱為:Ordinary Object Pointer,就是普通物件指標。啟用CompressOops後,會壓縮的物件:

  • 每個Class的屬性指標(靜態成員變數);
  • 每個物件的屬性指標;
  • 普通物件陣列的每個元素指標。

當然,壓縮也不是所有的指標都會壓縮,對一些特殊型別的指標,JVM是不會優化的,例如指向PermGen的Class物件指標、本地變數、堆疊元素、入參、返回值和NULL指標不會被壓縮。

啟用指標壓縮

在Java程式啟動時增加JVM引數:-XX:+UseCompressedOops來啟用。

注意:32位HotSpot VM是不支援UseCompressedOops引數的,只有64位HotSpot VM才支援。

本文中使用的是JDK 1.8,預設該引數就是開啟的。

檢視物件的大小

接下來我們使用www.javamex.com/中提供的classmexer.jar來計算物件的大小。

執行環境:JDK 1.8,Java HotSpot(TM) 64-Bit Server VM

基本資料型別

對於基本資料型別來說,是比較簡單的,因為我們已經知道每個基本資料型別的大小。程式碼如下:

/**
 * VM options:
 * -javaagent:/Users/sangjian/dev/source-files/classmexer-0_03/classmexer.jar
 * -XX:+UseCompressedOops
 */
public class TestObjectSize {


    int a;
    long b;
    static int c;

    public static void main(String[] args) throws IOException {
        TestObjectSize testObjectSize = new TestObjectSize();
        // 列印物件的shallow size
        System.out.println("Shallow Size: " + MemoryUtil.memoryUsageOf(testObjectSize) + " bytes");
        // 列印物件的 retained size
        System.out.println("Retained Size: " + MemoryUtil.deepMemoryUsageOf(testObjectSize) + " bytes");
        System.in.read();
    }
}
複製程式碼

注意:在執行前需要設定javaagent引數,在JVM啟動引數中新增-javaagent:/path_to_agent/classmexer.jar來執行。

有關Shallow Size和Retained Size請參考blog.csdn.net/e5945/artic…

開啟指標壓縮的情況

執行檢視結果:

Shallow Size: 24 bytes
Retained Size: 24 bytes
複製程式碼

根據上文的分析可以知道,64位開啟指標壓縮的情況下:

  • 物件頭大小=Class Pointer的空間大小為4位元組+MarkWord為8位元組=12位元組;
  • 實際資料大小=int型別4位元組+long型別8位元組=12位元組(靜態變數不在計算範圍之內)

在MAT中分析的結果如下:

所以大小是24位元組。其實這裡並沒有padding,因為正好是24位元組。如果我們把long b;換成int b;之後,再來看一下結果:

Shallow Size: 24 bytes
Retained Size: 24 bytes
複製程式碼

大小並沒有變化,說明這裡做了padding,並且padding的大小是4位元組。

這裡的Shallow Size和Retained Size是一樣的,因為都是基本資料型別。

關閉指標壓縮的情況

如果要關閉指標壓縮,在JVM引數中新增-XX:-UseCompressedOops來關閉,再執行上述程式碼檢視結果:

Shallow Size: 24 bytes
Retained Size: 24 bytes
複製程式碼

分析一下在64位未開啟指標壓縮的情況下:

  • 物件頭大小=Class Pointer的空間大小為8位元組+MarkWord為8位元組=16位元組;
  • 實際資料大小=int型別4位元組+long型別8位元組=12位元組(靜態變數不在計算範圍之內);

這裡計算後大小為16+12=28位元組,這時候就需要padding來補齊了,所以padding為4位元組,最後的大小就是32位元組。

我們再把long b;換成int b;之後呢?通過上面的計算結果可以知道,實際資料大小就應該是int型別4位元組+int型別4位元組=8位元組,物件頭大小為16位元組,那麼不需要做padding,物件的大小為24位元組:

Shallow Size: 24 bytes
Retained Size: 24 bytes
複製程式碼

陣列型別

64位系統中,陣列物件的物件頭佔用24 bytes,啟用壓縮後佔用16位元組。比普通物件佔用記憶體多是因為需要額外的空間儲存陣列的長度。基礎資料型別陣列佔用的空間包括陣列物件頭以及基礎資料型別資料佔用的記憶體空間。由於物件陣列中存放的是物件的引用,所以陣列物件的Shallow Size=陣列物件頭+length * 引用指標大小,Retained Size=Shallow Size+length*每個元素的Retained Size。

程式碼如下:

/**
 * VM options:
 * -javaagent:/Users/sangjian/dev/source-files/classmexer-0_03/classmexer.jar
 * -XX:+UseCompressedOops
 */
public class TestObjectSize {


    long[] arr = new long[6];

    public static void main(String[] args) throws IOException {
        TestObjectSize testObjectSize = new TestObjectSize();
        // 列印物件的shallow size
        System.out.println("Shallow Size: " + MemoryUtil.memoryUsageOf(testObjectSize) + " bytes");
        // 列印物件的 retained size
        System.out.println("Retained Size: " + MemoryUtil.deepMemoryUsageOf(testObjectSize) + " bytes");
        System.in.read();
    }
}
複製程式碼

開啟指標壓縮的情況

結果如下:

Shallow Size: 16 bytes
Retained Size: 80 bytes
複製程式碼

Shallow Size比較簡單,這裡物件頭大小為12位元組, 實際資料大小為4位元組,所以Shallow Size為16。

對於Retained Size來說,要計算陣列佔用的大小,對於陣列來說,它的物件頭部多了一個用來儲存陣列長度的空間,該空間大小為4位元組,所以**陣列物件的大小=引用物件頭大小12位元組+儲存陣列長度的空間大小4位元組+陣列的長度*陣列中物件的Retained Size+padding大小**

下面分析一下上述程式碼中的long[] arr = new long[6];,它是一個長度為6的long型別的陣列,由於long型別的大小為8位元組,所以陣列中的實際資料是6*8=48位元組,那麼陣列物件的大小=12+4+6*8+0=64,最終的Retained Size=Shallow Size + 陣列物件大小=16+64=80。

通過MAT檢視如下:

關閉指標壓縮的情況

結果如下:

Shallow Size: 24 bytes
Retained Size: 96 bytes
複製程式碼

這個結果大家應該能自己分析出來了,因為這時引用物件頭為16位元組,那麼陣列的大小=16+4+6*8+4=72,(這裡最後一個4是padding),所以Retained Size=Shallow Size + 陣列物件大小=24+72=96。

通過MAT檢視如下:

包裝型別

包裝類(Boolean/Byte/Short/Character/Integer/Long/Double/Float)佔用記憶體的大小等於物件頭大小加上底層基礎資料型別的大小。

包裝型別的Retained Size佔用情況如下:

Numberic Wrappers+useCompressedOops-useCompressedOops
Byte, Boolean16 bytes24 bytes
Short, Character16 bytes24 bytes
Integer, Float16 bytes24 bytes
Long, Double24 bytes24 bytes

程式碼如下:

/**
 * VM options:
 * -javaagent:/Users/sangjian/dev/source-files/classmexer-0_03/classmexer.jar
 * -XX:+UseCompressedOops
 */
public class TestObjectSize {


    Boolean a = new Boolean(false);
    Byte b = new Byte("1");
    Short c = new Short("1");
    Character d = new Character('a');
    Integer e = new Integer(1);
    Float f = new Float(2.5);
    Long g = new Long(123L);
    Double h = new Double(2.5D);

    public static void main(String[] args) throws IOException {
        TestObjectSize testObjectSize = new TestObjectSize();
        // 列印物件的shallow size
        System.out.println("Shallow Size: " + MemoryUtil.memoryUsageOf(testObjectSize) + " bytes");
        // 列印物件的 retained size
        System.out.println("Retained Size: " + MemoryUtil.deepMemoryUsageOf(testObjectSize) + " bytes");
        System.in.read();
    }
}

複製程式碼

開啟指標壓縮的情況

結果如下:

Shallow Size: 48 bytes
Retained Size: 192 bytes
複製程式碼

MAT中的結果如下:

關閉指標壓縮的情況

結果如下:

Shallow Size: 80 bytes
Retained Size: 272 bytes
複製程式碼

MAT中的結果如下:

String型別

在JDK1.7及以上版本中,java.lang.String中包含2個屬性,一個用於存放字串資料的char[], 一個int型別的hashcode, 部分原始碼如下:

public final class String
    implements java.io.Serializable, Comparable<String>, CharSequence {
    /** The value is used for character storage. */
    private final char value[];

    /** Cache the hash code for the string */
    private int hash; // Default to 0
    ...
}
複製程式碼

因此,在關閉指標壓縮時,一個String物件的大小為:

  • Shallow Size=物件頭大小16位元組+int型別大小4位元組+陣列引用大小8位元組+padding4位元組=32位元組

  • Retained Size=Shallow Size+char陣列的Retained Size

在開啟指標壓縮時,一個String物件的大小為:

  • Shallow Size=物件頭大小12位元組+int型別大小4位元組+陣列引用大小4位元組+padding4位元組=24位元組

  • Retained Size=Shallow Size+char陣列的Retained Size

程式碼如下:

/**
 * VM options:
 * -javaagent:/Users/sangjian/dev/source-files/classmexer-0_03/classmexer.jar
 * -XX:+UseCompressedOops
 */
public class TestObjectSize {


    String s = "test";

    public static void main(String[] args) throws IOException {
        TestObjectSize testObjectSize = new TestObjectSize();
        // 列印物件的shallow size
        System.out.println("Shallow Size: " + MemoryUtil.memoryUsageOf(testObjectSize) + " bytes");
        // 列印物件的 retained size
        System.out.println("Retained Size: " + MemoryUtil.deepMemoryUsageOf(testObjectSize) + " bytes");
        System.in.read();
    }
}
複製程式碼

開啟指標壓縮的情況

結果如下:

Shallow Size: 16 bytes
Retained Size: 64 bytes
複製程式碼

MAT中的結果如下:

關閉指標壓縮的情況

結果如下:

Shallow Size: 24 bytes
Retained Size: 88 bytes
複製程式碼

MAT中的結果如下:

其他引用型別的大小

根據上面的分析,可以計算出一個物件在記憶體中的佔用空間大小情況,其他的引用型別可以參考分析計算過程來計算記憶體的佔用情況。

關於padding

思考這樣一個問題,是不是padding都加到物件的後面呢,如果物件頭佔12個位元組,物件中只有1個long型別的變數,那麼該long型別的變數的偏移起始地址是在12嗎?用下面一段程式碼測試一下:

@SuppressWarnings("ALL")
public class PaddingTest {

    long a;

    private static Unsafe UNSAFE;

    static {
        try {
            Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
            theUnsafe.setAccessible(true);
            UNSAFE = (Unsafe) theUnsafe.get(null);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws NoSuchFieldException {
        System.out.println(UNSAFE.objectFieldOffset(PaddingTest.class.getDeclaredField("a")));
    }

}
複製程式碼

這裡使用Unsafe類來檢視變數的偏移地址,執行後結果如下:

16
複製程式碼

如果是換成int型別的變數呢?結果是12。

現在一般的CPU一次直接操作的資料可以到64位,也就是8個位元組,那麼字長就是64,而long型別本身就是佔64位,如果這時偏移地址是12,那麼需要分兩次讀取該資料,而如果偏移地址從16開始只需要通過一次讀取即可。int型別的資料佔用4個位元組,所以可以從12開始。

把上面的程式碼修改一下:

@SuppressWarnings("ALL")
public class PaddingTest {

    long a;

    byte b;

    byte c;

    private static Unsafe UNSAFE;

    static {
        try {
            Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
            theUnsafe.setAccessible(true);
            UNSAFE = (Unsafe) theUnsafe.get(null);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws NoSuchFieldException {
        System.out.println(UNSAFE.objectFieldOffset(PaddingTest.class.getDeclaredField("a")));
        System.out.println(UNSAFE.objectFieldOffset(PaddingTest.class.getDeclaredField("b")));
        System.out.println(UNSAFE.objectFieldOffset(PaddingTest.class.getDeclaredField("c")));
    }

}
複製程式碼

執行結果如下:

16
12
13
複製程式碼

在本例中,如果變數的大小小於等於4個位元組,那麼在分配記憶體的時候會先優先分配,因為這樣可以減少padding,比如這裡的b和c變數;如果這時達到了16個位元組,那麼其他的變數按照型別所佔記憶體的大小降序分配。

再次修改程式碼:

/**
 * VM options: -javaagent:D:\source-files\classmexer.jar
 */
@SuppressWarnings("ALL")
public class PaddingTest {

    boolean a;
    byte b;

    short c;
    char d;

    int e;
    float f;

    long g;
    double h;

    private static Unsafe UNSAFE;

    static {
        try {
            Field theUnsafe = Unsafe.class.getDeclaredField("theUnsafe");
            theUnsafe.setAccessible(true);
            UNSAFE = (Unsafe) theUnsafe.get(null);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    public static void main(String[] args) throws NoSuchFieldException {
        System.out.println("field a --> "+ UNSAFE.objectFieldOffset(PaddingTest.class.getDeclaredField("a")));
        System.out.println("field b --> "+ UNSAFE.objectFieldOffset(PaddingTest.class.getDeclaredField("b")));
        System.out.println("field c --> "+ UNSAFE.objectFieldOffset(PaddingTest.class.getDeclaredField("c")));
        System.out.println("field d --> "+ UNSAFE.objectFieldOffset(PaddingTest.class.getDeclaredField("d")));
        System.out.println("field e --> "+ UNSAFE.objectFieldOffset(PaddingTest.class.getDeclaredField("e")));
        System.out.println("field f --> "+ UNSAFE.objectFieldOffset(PaddingTest.class.getDeclaredField("f")));
        System.out.println("field g --> "+ UNSAFE.objectFieldOffset(PaddingTest.class.getDeclaredField("g")));
        System.out.println("field h --> "+ UNSAFE.objectFieldOffset(PaddingTest.class.getDeclaredField("h")));

        PaddingTest paddingTest = new PaddingTest();

        System.out.println("Shallow Size: "+ MemoryUtil.memoryUsageOf(paddingTest));
        System.out.println("Retained Size: " + MemoryUtil.deepMemoryUsageOf(paddingTest));
    }

}
複製程式碼

結果如下:

field a --> 40
field b --> 41
field c --> 36
field d --> 38
field e --> 12
field f --> 32
field g --> 16
field h --> 24
Shallow Size: 48
Retained Size: 48
複製程式碼

可以看到,先分配的是int型別的變數e,因為它正好是4個位元組,其餘的都是先從g和h變數開始分配的,因為這兩個變數是long型別和double型別的,佔64位,最後分配的是a和b,它們只佔一個位元組。

如果分配到最後,這時位元組數不是8的倍數,則需要padding。這裡實際的大小是42位元組,所以padding6位元組,最終佔用48位元組。

Java物件記憶體佈局


相關文章