博客 / 詳情

返回

瞭解代碼中的內存佔用

1. 前言

平時在寫代碼的時候,我們很多人基本都不太關注應用中佔用的內存,因為通常業務場景中,內存佔用量也就2、3G,不會很大。

如果併發量很高,臨時對象創建的很多,總體的內存佔用量瞬間就上去了。雖然每次請求完成後對象的引用關係解除了,對象內存會在Jvm的下一次GC中被釋放掉。但如果一直併發度高,整體來看內存佔用量不會因為GC而減少。

另外有些業務中會基於內存做緩存(如:Map、Caffeine等等),因為在查詢性能上比RocksDB、Redis更高。但無論是佔用堆內內存,還是堆外內存,它們不會像前面的臨時對象一樣被Jvm釋放,除非在業務中主動刪除。這類的內存佔用幾乎是永駐的,更需要我們精簡內存結構。

一定還會有一些其他的場景,這裏就不一一列舉了。當你的系統有內存瓶頸時,在寫代碼時就需要好好思考一下內存結構。

2. 對象內存-基本

2.1. 內存結構

一個 Java 對象的內存結構一般包括以下幾個部分:

  • 對象頭 (Object Header)

    • Mark Word:通常是 8 字節,用於存儲對象的哈希碼、GC 狀態、鎖狀態等信息。
    • Class Pointer:通常是 4 字節或 8 字節(取決於 JVM 是否開啓壓縮指針),指向對象的類元數據。
  • 實例數據 (Instance Data):存儲對象的字段(包括從父類繼承的字段)。
  • 對齊填充 (Padding):JVM 要求對象大小是 8 字節的倍數,因此可能會有一些填充字節。

2.2. 測試工具

使用JOL計算對象大小,依賴:

        <dependency>
            <groupId>org.openjdk.jol</groupId>
            <artifactId>jol-core</artifactId>
            <version>0.16</version>
        </dependency>

如下,在 getObject() 方法中返回對象,main 方法中打印該對象佔用內存。

    public static void main(String[] args) {
        // 使用 JOL 計算對象大小
        long size = GraphLayout.parseInstance((getObject())).totalSize();
        System.out.println("Object size: " + size + " bytes");
    }
    
    private static Object getObject() {
        MemoryObj obj = new MemoryObj();
        return obj;
    }

2.3. 空對象內存1

已經知道對象的內存組成了,如果定義一個空類:

@Data
public class MemoryObj {
}

執行上述方法,結果為 16 bytes

解釋
  • 對象頭:12 bytes
  • 實例數據:0
  • 對齊填充:4 bytes

因為類沒有任何屬性,即沒有 實例數據 的內存,所有內存為 對象頭,共佔用內存12 bytes(已開啓壓縮指針),經過 對齊填充 後為 16 bytes

2.4. 空對象內存2

現在我們要給類加些屬性了,先加1個 int 屬性,先告訴你一個 int 佔用內存 4 bytes

@Data
public class MemoryObj {
    private int attr1;
}

執行上述方法,結果為 16 bytes

  • 對象頭:12 bytes
  • 實例數據:4 bytes
  • 對齊填充:0 bytes

如上 對象頭實例數據 的內存加起來已經有 16 bytes(8 bytes 的倍數),因此不需要 對齊填充

2.5. 空對象內存3

@Data
public class MemoryObj {
    private int attr1;
    private int attr2;
}

執行上述方法,結果為 24 bytes

  • 對象頭:12 bytes
  • 實例數據:8 bytes
  • 對齊填充:4 bytes

此時2個 int 屬性, 對象頭實例數據 的內存加起來有 20 bytes,需要 對齊填充

3. 基本數據類型及包裝類內存

3.1. 基本數據類型

  • byte:1 字節
  • boolean:1 字節
  • char:2 字節
  • short:2 字節
  • int:4 字節
  • float:4 字節
  • long:8 字節
  • double:8 字節

3.2. 包裝類

首先,包裝類是類,所以計算它的內存佔用,就用對象內存佔用來計算。

另外,每種基本數據類型對應的包裝類,都只包含一個屬性,就是對應的基本數據類型。

因此各基本數據類型的包裝類對象內存佔用分別如下。

1、Byte

類方法:

public final class Byte {
    private final byte value;
    ...
}

對象總內存 16 bytes

  • 對象頭:12 bytes
  • 實例數據:1 bytes
  • 對齊填充:3 bytes
2、Boolean

類方法:

public final class Boolean {
    private final boolean value;
    ...
}

對象總內存 16 bytes

  • 對象頭:12 bytes
  • 實例數據:1 bytes
  • 對齊填充:3 bytes
3、Character

類方法:

public final class Character {
    private final char value;
    ...
}

對象總內存 16 bytes

  • 對象頭:12 bytes
  • 實例數據:2 bytes
  • 對齊填充:2 bytes
4、Short

類方法:

public final class Short {
    private final short value;
    ...
}

對象總內存 16 bytes

  • 對象頭:12 bytes
  • 實例數據:2 bytes
  • 對齊填充:2 bytes
5、Integer

類方法:

public final class Integer {
    private final int value;
    ...
}

對象總內存 16 bytes

  • 對象頭:12 bytes
  • 實例數據:4 bytes
  • 對齊填充:0 bytes
6、Float

類方法:

public final class Float {
    private final float value;
    ...
}

對象總內存 16 bytes

  • 對象頭:12 bytes
  • 實例數據:4 bytes
  • 對齊填充:0 bytes
7、Long

類方法:

public final class Long {
    private final long value;
    ...
}

對象總內存 24 bytes

  • 對象頭:12 bytes
  • 實例數據:8 bytes
  • 對齊填充:4 bytes
8、Double

類方法:

public final class Double {
    private final double value;
    ...
}

對象總內存 24 bytes

  • 對象頭:12 bytes
  • 實例數據:8 bytes
  • 對齊填充:4 bytes

4. 對象內存-類屬性

我們再重新回到對象的內存計算,前面對象的屬性是基本數據類型,但很多時候屬性同樣也為類對象,還包含包裝類、集合類。

4.1. 類屬性1-引用

@Data
public class Memory2Obj {
    private int attr1;
}


@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemoryObj {
    private Memory2Obj attr1;
    private int attr2;
}


    private static Object getObject() {
        Memory2Obj attr1 = new Memory2Obj(1);
        MemoryObj obj = new MemoryObj(attr1,2);
        return obj;
    }
引用類型的內存佔用

在Java中,當一個對象的屬性是引用類型時,該屬性在內存中的佔用實際上是一個指針(也稱為引用)。這個引用指向堆內存中的實際對象,而不是直接存儲對象的數據。引用類型可以包括類實例、接口類型、數組等。

因此在計算包含引用類型的內存佔用時,要分開算各自對象的內存佔用。

首先看Memory2Obj 對象的內存結果為 16 bytes,這個前面已經算過了。

  • 對象頭:12 bytes
  • 實例數據:4 bytes
  • 對齊填充:0 bytes

MemoryObj 對象的內存結果為 24 bytes

  • 對象頭:12 bytes
  • 實例數據

    • 引用類型(attr1):4 bytes,指向 Memory2Obj 對象的指針
    • 基本數據類型(attr2):4 bytes
  • 對齊填充:4 bytes

總內存佔用是 40 bytes(16 bytes + 24 bytes)。

4.2. 類屬性2-null

還是上面的 MemoryObjMemory2Obj,我們修改一下 getObject() 方法:

    private static Object getObject() {
        MemoryObj obj = new MemoryObj(null,2);
        return obj;
    }

這裏因為沒有創建 Memory2Obj 對象,所以沒有該對象的內存佔用,只有 MemoryObj24 bytes

  • 對象頭:12 bytes
  • 實例數據

    • 引用類型(attr1):4 bytes
    • 基本數據類型(attr2):4 bytes
  • 對齊填充:4 bytes

這裏 attr1 的值為 null,但它作為一個實例字段,無論它是否指向一個有效的對象,它都需要在內存中佔有一個空間來存儲這個引用。

因此總內存佔用是 24 bytes

4.3. 類屬性3-多引用

我們修改一下,MemoryObj類中有兩個 Memory2Obj類型屬性:

@AllArgsConstructor
@NoArgsConstructor
@Data
public class Memory2Obj {
    private int attr1;
}

@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemoryObj {
    private Memory2Obj attr1;
    private Memory2Obj attr2;
    private int attr3;
}

    private static Object getObject() {
        Memory2Obj attr = new Memory2Obj(1);
        MemoryObj obj = new MemoryObj(attr, attr, 2);
        return obj;
    }

首先看Memory2Obj 對象的內存結果為 16 bytes

  • 對象頭:12 bytes
  • 實例數據:4 bytes
  • 對齊填充:0 bytes

MemoryObj 對象的內存結果為 24 bytes

  • 對象頭:12 bytes
  • 實例數據

    • 引用類型(attr1):4 bytes,指向 Memory2Obj 對象的指針
    • 引用類型(attr2):4 bytes,指向 Memory2Obj 對象的指針
    • 基本數據類型(attr3):4 bytes
  • 對齊填充:0 bytes

總內存佔用是 40 bytes(16 bytes + 24 bytes)。

雖然 MemoryObj 對象有2個 Memory2Obj對象屬性,但兩個屬性指針指向的對象地址是同一個,所以只是多了一個指針的內存(4 bytes)。

5. 特殊對象

5.1. 數組

5.1.1. 數組也是類

數組在Java中是一個特殊的對象類型,具有一些與類類似的特徵,但也有其獨特的屬性。

1、對象性質:數組在Java中是對象。你可以使用instanceof操作符檢查一個變量是否是數組類型。例如:

int[] array = new int[10];
System.out.println(array instanceof Object); // 輸出 true

2、類加載:數組類型是由JVM在運行時動態生成的,每種數組類型都有一個與之對應的類。你可以通過調用getClass()方法來獲取數組的類信息:

int[] array = new int[10];
System.out.println(array.getClass().getName()); // 輸出 [I

其中,[I表示一個整數(int)數組。在Java中,類名的表示方式是以方括號開頭的

3、類層次結構:所有的數組類型都是Object類的子類,並且實現了Serializable和Cloneable接口:

int[] array = new int[10];
System.out.println(array instanceof Object);       // true
System.out.println(array instanceof Serializable); // true
System.out.println(array instanceof Cloneable);    // true

5.1.2. 內存結構

  • 對象頭 (Object Header)

    • Mark Word:通常是 8 字節,用於存儲對象的哈希碼、GC 狀態、鎖狀態等信息。
    • Class Pointer:通常是 4 字節或 8 字節(取決於 JVM 是否開啓壓縮指針),指向對象的類元數據。
  • 數組長度字段:數組對象包含一個4字節的 int 類型字段,用於存儲數組的長度。
  • 數組元素

    • 基本類型數組:每個元素的內存佔用等於基本類型的大小。

      • boolean[]:每個元素1字節。
      • byte[]:每個元素1字節。
      • char[]:每個元素2字節。
      • short[]:每個元素2字節。
      • int[]:每個元素4字節。
      • float[]:每個元素4字節。
      • long[]:每個元素8字節。
      • double[]:每個元素8字節。
    • 引用類型數組:每個元素的內存佔用為引用的大小。啓用了指針壓縮的64位JVM中,每個引用佔用4字節。
  • 對齊填充 (Padding):JVM 要求對象大小是 8 字節的倍數,因此可能會有一些填充字節。
數組長度有上限

在Java中,數組的長度是一個非負的int值。這意味着數組的最大長度是Integer.MAX_VALUE,即 2147483647。這是因為int類型的數據範圍是從 -2^31 到 2^31 - 1,但長度不能為負數,所以數組的最大長度為 2^31 - 1,在大多數場景已經夠用了。

5.1.3. 示例

1、long[5]

假設我們有一個長度為5的long數組:

long[] longArray = new long[5];

內存結構如下:

| 對象頭 (12字節) | 數組長度 (4字節) | 元素1 (8字節) | 元素2 (8字節) | 元素3 (8字節) | 元素4 (8字節) | 元素5 (8字節) |

具體內存佔用:

  • 對象頭:12 bytes
  • 數組長度字段:4 bytes
  • 數組元素:8 bytes * 5 = 40 bytes
  • 對齊填充:0 bytes

總內存佔用:12 + 4 + 40 = 56 bytes

2、Long[5]

假設我們有一個長度為5的Long數組:

Long[] longArray = new Long[5];

內存結構如下:

| 對象頭 (12字節) | 數組長度 (4字節) | 元素1 (4字節) | 元素2 (4字節) | 元素3 (4字節) | 元素4 (4字節) | 元素5 (4字節) |

具體內存佔用:

  • 對象頭:12 bytes
  • 數組長度字段:4 bytes
  • 數組元素:4 bytes * 5 = 20 bytes
  • 對齊填充:4 bytes

總內存佔用:12 + 4 + 20 = 40 bytes

5.2. String

以下是String類在JDK 8中的簡化定義:

public final class String implements java.io.Serializable, Comparable<String>, CharSequence {
    private final char[] value; // 實際存儲字符串的字符數組
    private int hash;           // 緩存的哈希碼
    // 構造器和其他方法省略
}

5.2.1. 內存結構

  • 對象頭 (Object Header)

    • Mark Word:通常是 8 字節,用於存儲對象的哈希碼、GC 狀態、鎖狀態等信息。
    • Class Pointer:通常是 4 字節或 8 字節(取決於 JVM 是否開啓壓縮指針),指向對象的類元數據。
  • char[] value:用於存儲字符串的字符數據。不過數組是對象,這裏存儲的是引用類型的指針,所以固定為 4 字節。
  • int hash:緩存字符串的哈希碼,用於提高哈希操作的效率。int類型 4 字節。
  • 對齊填充 (Padding):JVM 要求對象大小是 8 字節的倍數,因此可能會有一些填充字節。
String對象內存固定

由上可知,無論 String 存儲的值是什麼,String對象 的內存固定為 24 bytes。 真正可變的,是 char[] 數組佔用的內存大小。

另外前面説過數組最大長度是 2^31 - 1,這決定了 String 能存儲的最大字符串長度也是有限制的,其實也夠了。由於每個char佔用2字節,因此存儲接近最大長度的字符串需要接近4GB的內存,僅用於存儲字符數組。

5.2.2. 示例

1、Hello World

我們看看 "Hello World" 這個字符串佔用內存數。

    private static Object getObject() {
        return "Hello World";
    }

首先算 String 對象 的內存:

  • 對象頭:12 bytes
  • char[] value:數組的引用指針,4 bytes。
  • int hash:int類型 4 bytes。
  • 對齊填充:4 bytes

String 對象 的內存為 24 bytes

再計算 char[] 數組對象 的內存:

  • 對象頭:12 bytes
  • 數組長度字段:4 bytes
  • 數組元素char 類型每個元素 2 bytes,2 bytes * 11 = 22 bytes
  • 對齊填充:2 bytes

char[] 數組對象 的內存 為 40 bytes

因此總內存佔用為 64 bytes(24 bytes + 40 bytes)

6. 常量池

6.1. 整數常量池

先看總結:

  • Integer、Long、Byte、Short:這些類在範圍 -128127 之間提供緩存機制,通過valueOf方法返回緩存中的對象。
  • Float和Double:沒有提供類似的緩存機制,每次創建都會生成新的對象。
  • Character:在範圍 0127 之間提供緩存機制。
示例1

Java提供了對於某些範圍內的整數進行緩存的機制,這個範圍通常是-128到127。這個機制是在JVM啓動時通過IntegerCache類實現的。

如果值在-128到127之間,會返回緩存中的對象,而不是創建一個新的對象。

@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemoryObj {
    private Integer attr1;
    private Integer attr2;
}

    private static Object getObject() {
        return new MemoryObj(127,127);
    }

MemoryObj 對象內存為24 bytes

  • 對象頭:12 bytes
  • 實例數據:2個指向 Integer 對象的指針,4 bytes * 2 = 8 bytes
  • 對齊填充:4 bytes

Integer 對象內存為16 bytes

  • 對象頭:12 bytes
  • 實例數據:int 值 4 bytes
  • 對齊填充:0 bytes

因為 Integer 的值沒有超過 127,所以兩個屬性的指針都指向常量池內同一個對象,所以總內存為:40 bytes(24 bytes + 16 bytes)

示例2

    private static Object getObject() {
        return new MemoryObj(128,128);
    }

因為 Integer 的值超過了 127,所以每次都需要創建一個新的對象,所以總內存為:56 bytes(24 bytes + 16 bytes * 2)

6.2. 字符串常量池

字符串常量池是Java中專門用於存儲字符串字面量的區域,在元空間(JDK 1.7及之後)中。

有2種方式可以使用字符串常量池:

  • 字符串字面量:在編譯時確定的字符串,例如 "hello"
  • Interned字符串:通過調用 String.intern() 方法顯式加入池中的字符串。

特點

  • 字符串常量池中的字符串是不可變的。
  • 如果一個字符串已經存在於常量池中,創建相同字符串時不會再分配新的內存。
public class StringPoolExample {
    public static void main(String[] args) {
        String str1 = "hello";
        String str2 = "hello";
        System.out.println(str1 == str2); // 輸出 true

        String str3 = new String("hello");
        String str4 = str3.intern();
        System.out.println(str1 == str4); // 輸出 true
    }
}
示例1
@Data
@NoArgsConstructor
@AllArgsConstructor
public class MemoryObj {
    private String attr1;
    private String attr2;
}

    private static Object getObject() {
        return new MemoryObj("hello","hello");
    }

MemoryObj 對象內存為24 bytes

  • 對象頭:12 bytes
  • 實例數據:2個指向 String 對象的指針,4 bytes * 2 = 8 bytes
  • 對齊填充:4 bytes

String 對象 的內存之前已經説過了,固定為 24 bytes

最後計算 char[] 數組對象 的內存:

  • 對象頭:12 bytes
  • 數組長度字段:4 bytes
  • 數組元素char 類型每個元素 2 bytes,2 bytes * 5 = 10 bytes
  • 對齊填充:6 bytes

char[] 數組對象 的內存 為 32 bytes

由於2個字符串都是在編譯時確定的,而且相同,所以兩個屬性的指針指向字符串常量池同一個對象,總內存為:80 bytes(24 bytes + 24 bytes + 32 bytes)

示例2
        private static Object getObject() {
        for (int i = 0; i < 10; i++) {
            String attr1 = new String("hell" + i);
            String attr2 = "hell0";
            return new MemoryObj(attr1, attr2);
        }
        return null;
    }

雖然我們知道輸出的結果一定是兩個 hell0 字符串屬性,但因為在編譯時確定不了(引入了變量i),所以 attr1 的值不會進入常量池,從而 attr2 需要重新場景。
這裏我們手動創建的2個String對象,所以總內存為:136 bytes(24 bytes + 24 bytes 2 + 32 bytes 2)

示例3
       private static Object getObject() {
        for (int i = 0; i < 10; i++) {
            String attr1 = new String("hell" + i);
            attr1.intern();
            String attr2 = "hell0";
            return new MemoryObj(attr1, attr2);
        }
        return null;
    }

這裏我們手動將 attr1 加入常量池,這樣 attr2 就直接從常量池中讀數據了。

這裏我們手動創建的2個String對象,所以總內存為:80 bytes(24 bytes + 24 bytes + 32 bytes)

user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.