Java-一個對象到底佔多少內存?

內存是程序員逃不開的話題,當然Java因爲有GC使得我們不用手動申請和釋放內存,但是瞭解Java內存分配是做內存優化的基礎,如果不瞭解Java內存分配的知識,可能會帶偏我們內存優化的方向。所以這篇文章我們以“一個對象佔多少內存”爲引子來談談Java內存分配。
文章基於JDK版本:1.8.0_191

 

文章標題提出的問題是”一個對象到底佔多少內存“,看似很簡單,但想說清楚並不容易,希望本文的探討能讓你有收穫。

在開始之前我還是決定先提一個曾經陰魂不散,困擾我很久的問題,瞭解這個問題的答案有助於我們理解接下來的內容。

Java虛擬機如何在運行時知道每一塊內存存儲數據的類型的?

  • 我們知道Java中int佔4個字節,short佔2個字節,引用類型在64位機器上佔4個字節(不開啓指針壓縮是8個字節,指針壓縮是默認開啓的),那JVM如何在運行時知道某一塊內存存的值的類型是int還是short或者其他基礎類型,亦或者是引用的地址?比如以int爲例,4個字節只夠存儲int數據本身,並沒有多餘的空間存儲數據的類型!

想解答這個問題,需要從字節碼入手,還需要我們瞭解一些Java虛擬機規範的知識,
來看一個簡單的例子

public class Apple extends Fruit{
    private int color;
    private String name;
    private Apple brother;
    private long create_time;

    public void test() {
        int color = this.color;
        String name = this.name;
        Apple brother = this.brother;
        long create_time = this.create_time;
    }
}

很簡單的一個Apple類,繼承於Fruit,有一個test方法,將類成員變量賦值給方法本地變量,還是老套路,javac,javap 查看字節碼

javac Fruit.java Apple.java
javap -verbose Apple.class

// 輸出Apple字節碼
public class com.company.alloc.Apple extends com.company.alloc.Fruit
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref          #9.#25         // com/company/alloc/Fruit."<init>":()V
   #2 = Fieldref           #8.#26         // com/company/alloc/Apple.color:I
   #3 = Fieldref           #8.#27         // com/company/alloc/Apple.name:Ljava/lang/String;
   #4 = Fieldref           #8.#28         // com/company/alloc/Apple.brother:Lcom/company/alloc/Apple;
   #5 = Fieldref           #8.#29         // com/company/alloc/Apple.create_time:J
   // 省略......
{
 // 省略......
  public void test();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=4, locals=6, args_size=1
         0: aload_0
         1: getfield      #2                  // Field color:I
         4: iconst_1
         5: iadd
         6: istore_1
         7: aload_0
         8: getfield      #3                  // Field name:Ljava/lang/String;
        11: astore_2
        12: aload_0
        13: getfield      #4                  // Field brother:Lcom/company/alloc/Apple;
        16: astore_3
        17: aload_0
        18: getfield      #5                  // Field create_time:J
        21: ldc2_w        #6                  // long 3l
        24: lsub
        25: lstore        4
        27: return
        // 省略......
}

我們重點看Apple類的test方法,我已經添加了註釋

         // 加載Apple對象本身到棧
         0: aload_0
         // 獲取字段,#2 對應常量池中的序列,
         // #2 = Fieldref           #8.#26         // com/company/alloc/Apple.color:I
         // 存儲的類型是int類型
         1: getfield      #2                  // Field color:I
         // 加載1這個常量進棧
         4: iconst_1
         // 執行加法
         5: iadd
         // 將棧頂的值存到本地變量表1的位置
         6: istore_1
         // 加載Apple對象本身到棧
         7: aload_0
         // 獲取字段,#3 對應常量池中的序列,
         8: getfield      #3                  // Field name:Ljava/lang/String;
         // 將棧頂的值存到本地變量表2的位置
        11: astore_2
         // .......

可以看到對於對象的成員變量,會存在一個常量池,保存該對象所屬類的所有字段的索引表,根據這個常量池可以查詢到變量的類型,而字節碼指令對於操作各種類型都有專門的指令,比如存儲int是istore,存儲對象是astore,存儲long是lstore,所以指令是編譯期已經確定了,虛擬機只需要根據指令執行就行,根本不關心它操作的這個地址是什麼類型的,所以也就不用額外的字段去存類型了,解答我們前面提的問題!



我們開始步入正題,要說內存分配,首先就要了解我們分配的對象,那Java中分配的對象有哪些類型呢?

 

Java數據類型有哪些

在Java中數據類型分爲二大類。

  • 基礎數據類型(primitive type)
  • 引用類型 (reference type)

基礎數據類型

Java中基礎數據類型有8種,分別是byte(1), short(2), int(4), long(8), float(4), double(8), char(2), boolean(1), 括號裏面是它們佔用的字節數,所以對於基礎數據類型,它們所佔用的內存是很確定的,也就沒什麼好說的, 簡單的記憶一下每種類型存儲所需的字節數即可。

Java中基礎數據類型是在棧上分配還是在堆上分配?
我們繼續深究一下,基本數據類佔用內存大小是固定的,那具體是在哪分配的呢,是在堆還是棧還是方法區?大家不妨想想看!
要解答這個問題,首先要看這個數據類型在哪裏定義的,有以下三種情況。

  • 如果在方法體內定義的,這時候就是在棧上分配的
  • 如果是類的成員變量,這時候就是在堆上分配的
  • 如果是類的靜態成員變量,在方法區上分配的

引用類型

引用類型跟基礎數據類型不一樣,除了對象本身之外,還存在一個指向它的引用(指針),指針佔用的內存在64位虛擬機上8個字節,如果開啓指針壓縮是4個字節,默認是開啓了的。
爲了方便說明,還是以代碼爲例

class Kata {
  // str1和它指向的對象 都在堆上
  String str1 = new String();
  // str2和它指向的對象都在方法區上
  static String str2 = new String();
  
  public void methodTest() {
     // str3 在棧上,它指向的對象在堆上(也有可能在棧上,後面會說明)
     String str3 = new String();
  }
}
}

Java對象到底佔多大內存?

指針的長度是固定的,不去說它了,重點看它所指向的對象在內存中佔多少內存。
Java對象有三大類

  • 類對象
  • 數組對象
  • 接口對象

Java虛擬機規範定義了對象類型在內存中的存儲規範,由於現在基本都是64位的虛擬機,所以後面的討論都是基於64位虛擬機。
首先記住公式,對象由 對象頭 + 實例數據 + padding填充字節組成,虛擬機規範要求對象所佔內存必須是8的倍數,padding就是幹這個的

對象頭

而Java中對象頭由 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晉升入老年代),最後三位表示偏向鎖標識和鎖標識,主要就是用來區分對象的鎖狀態(未鎖定,偏向鎖,輕量級鎖,重量級鎖)

// 無其他線程競爭的情況下,由normal object變爲biased object
synchronized(object)

biased object的對象頭Markword前54位來存儲持有該鎖的線程id,這樣就沒有空間存儲hashcode了,所以
對於沒有重寫hashcode的對象,如果hashcode被計算過並存儲在對象頭中,則該對象作爲同步鎖時,不會進入偏向鎖狀態,因爲已經沒地方存偏向thread id了,所以我們在選擇同步鎖對象時,最好重寫該對象的hashcode方法,使偏向鎖能夠生效。

kclass

kclass存儲的是該對象所屬的類在方法區的地址,所以是一個指針,默認Jvm對指針進行了壓縮,用4個字節存儲,如果不壓縮就是8個字節。
關於Compressed Oops的知識,大家可以自行查閱相關資料來加深理解。
Java虛擬機規範要求對象所佔空間的大小必須是8字節的倍數,之所以有這個規定是爲了提高分配內存的效率,我們通過實例來做說明

class Fruit extends Object {
     private int size;
}

Object object = new Object();
Fruit fruit = new Fruit();

有一個Fruit類繼承了Object類,我們分別新建一個object和fruit,那他們分別佔用多大的內存呢?

  • 先來看object對象,通過上面的知識,它的Markword是8個字節,kclass是4個字節, 加起來是12個字節,加上4個字節的對齊填充,所以它佔用的空間是16個字節。
  • 再來看fruit對象,同樣的,它的Markword是8個字節,kclass是4個字節,但是它還有個size成員變量,int類型佔4個字節,加起來剛好是16個字節,所以不需要對齊填充。

那該如何驗證我們的結論呢?畢竟我們還是相信眼見爲實!很幸運Jdk提供了一個工具jol-core可以讓我們來分析對象頭佔用內存信息。
jol的使用也很簡單

// 打印對象頭信息代碼
System.out.println(ClassLayout.parseClass(Object.class).toPrintable());
System.out.println(ClassLayout.parseClass(Fruit.class).toPrintable());

// 輸出結果
java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0    12        (object header)                           N/A
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

com.aliosuwang.jol.Fruit object internals:
 OFFSET  SIZE   TYPE DESCRIPTION                               VALUE
      0    12        (object header)                           N/A
     12     4    int Fruit.size                                N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

可以看到輸出結果都是16 bytes,跟我們前面的分析結果一致。
除了類類型和接口類型的對象,Java中還有數組類型的對象,數組類型的對象除了上面表述的字段外,還有4個字節存儲數組的長度(所以數組的最大長度是Integer.MAX)。所以一個數組對象佔用的內存是 8 + 4 + 4 = 16個字節,當然這裏不包括數組內成員的內存。
我們也運行驗證一下。

String[] strArray = new String[0];
System.out.println(ClassLayout.parseClass(strArray.getClass()).toPrintable());

// 輸出結果
[Ljava.lang.String; object internals:
 OFFSET  SIZE               TYPE DESCRIPTION                               VALUE
      0    16                    (object header)                           N/A
     16     0   java.lang.String String;.<elements>                        N/A
Instance size: 16 bytes
Space losses: 0 bytes internal + 0 bytes external = 0 bytes total

輸出結果object header的長度也是16,跟我們分析的一致。到這裏對象頭部分的內存分配我們就瞭解的差不多了,接下來看對象的實例數據部分。

對象的實例數據(成員變量)的分配規則

爲了方便說明,我們新建一個Apple類繼承上面的Fruit類

public class Apple extends Fruit{
    private int size;
    private String name;
    private Apple brother;
    private long create_time;
}

// 打印Apple的對象分佈信息
System.out.println(ClassLayout.parseClass(Apple.class).toPrintable());

// 輸出結果
com.aliosuwang.jol.Apple object internals:
 OFFSET  SIZE                       TYPE DESCRIPTION                               VALUE
      0    12                           (object header)                           N/A
     12     4                       int Fruit.size                                N/A
     16     8                      long Apple.create_time                         N/A
     24     4                       int Apple.size                                N/A
     28     4          java.lang.String Apple.name                                N/A
     32     4   com.company.alloc.Apple Apple.brother                             N/A
     36     4                            (loss due to the next object alignment)
Instance size: 40 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

可以看到Apple的對象頭12個字節,然後分別是從Fruit類繼承來的size屬性(雖然Fruit的size是private的,還是會被繼承,與Apple自身的size共存),還有自己定義的4個屬性,基礎數據類型直接分配,對象類型都是存的指針佔4個字節(默認都是開啓了指針壓縮),最終是40個字節,所以我們new一個Apple對象,直接就會佔用堆棧中40個字節的內存,清楚對象的內存分配,讓我們在寫代碼時心中有數,應當時刻有內存優化的意識!
這裏又引出了一個小知識點,上面其實已經標註出來了。

父類的私有成員變量是否會被子類繼承?

答案當然是肯定的,我們上面分析的Apple類,父類Fruit有一個private類型的size成員變量,Apple自身也有一個size成員變量,它們能夠共存。注意劃重點了,類的成員變量的私有訪問控制符private,只是編譯器層面的限制,在實際內存中不論是私有的,還是公開的,都按規則存放在一起,對虛擬機來說並沒有什麼分別!

方法內部new的對象是在堆上還是棧上?

我們常規的認識是對象的分配是在堆上,棧上會有個引用指向該對象(即存儲它的地址),到底是不是呢,我們來做個試驗!
我們在循環內創建一億個Apple對象,並記錄循環的執行時間,前面已經算過1個Apple對象佔用40個字節,總共需要4GB的空間。

public static void main(String[] args) {
     long startTime = System.currentTimeMillis();
     for (int i = 0; i < 100000000; i++) {
         newApple();
     }
     System.out.println("take time:" + (System.currentTimeMillis() - startTime) + "ms");
}

public static void newApple() {
     new Apple();
}

我們給JVM添加上-XX:+PrintGC運行配置,讓編譯器執行過程中輸出GC的log日誌

// 運行結果,沒有輸出任何gc的日誌
take time:6ms

1億個對象,6ms就分配完成,而且沒有任何GC,顯然如果對象在堆上分配的話是不可能的,其實上面的實例代碼,Apple對象全部都是在棧上分配的,這裏要提出一個概念指針逃逸,newApple方法中新建的對象Apple並沒有在外部被使用,所以它被優化爲在棧上分配,我們知道方法執行完成後該棧幀就會被清空,所以也就不會有GC。
我們可以設置虛擬機的運行參數來測試一下。

// 虛擬機關閉指針逃逸分析
-XX:-DoEscapeAnalysis
// 虛擬機關閉標量替換
-XX:-EliminateAllocations

在VM options裏面添加上面二個參數,再運行一次

[GC (Allocation Failure)  236984K->440K(459776K), 0.0003751 secs]
[GC (Allocation Failure)  284600K->440K(516608K), 0.0004272 secs]
[GC (Allocation Failure)  341432K->440K(585216K), 0.0004835 secs]
[GC (Allocation Failure)  410040K->440K(667136K), 0.0004655 secs]
[GC (Allocation Failure)  491960K->440K(645632K), 0.0003837 secs]
[GC (Allocation Failure)  470456K->440K(625152K), 0.0003598 secs]
take time:5347ms

可以看到有很多GC的日誌,而且運行的時間也比之前長了很多,因爲這時候Apple對象的分配在堆上,而堆是所有線程共享的,所以分配的時候肯定有同步機制,而且觸發了大量的gc,所以效率低很多。
總結一下: 虛擬機指針逃逸分析是默認開啓的,對象不會逃逸的時候優先在棧上分配,否則在堆上分配。
到這裏,關於“一個對象佔多少內存?”這個問題,已經能回答的相當全面了。但是畢竟我們分析的只是Hotspot虛擬機,我們不妨延伸一下,看在Android ART虛擬機上面的分配情況

獲取Android ART虛擬機上面的對象頭大小

我們前面使用了jol工具來輸出對象頭的信息,但是這個jol工具只能用在hotspot虛擬機上,那我們如何在Android上面獲取對象頭大小呢?

方法的靈感來源

辦法肯定是有的,我這裏介紹的辦法,靈感的主角就是AtomicInteger,我是受到它的啓發,這個類我們知道是線程安全的int的包裝類。它的實現原理是利用了Unsafe包提供的CAS能力,不妨看下它的源碼實現

    private static final sun.misc.Unsafe U = sun.misc.Unsafe.getUnsafe();
    private static final long VALUE;

    static {
        try {
            VALUE = U.objectFieldOffset
                (AtomicInteger.class.getDeclaredField("value"));
        } catch (ReflectiveOperationException e) {
            throw new Error(e);
        }
    }

    private volatile int value;

    /**
     * Atomically increments by one the current value.
     *
     * @return the previous value
     */
    public final int getAndIncrement() {
        return U.getAndAddInt(this, VALUE, 1);
    }

我們知道普通int對象的++操作不是原子性的,AtomicInteger提供了getAndIncrement()它卻能保證原子性,這一部分知識不是我們這篇要講的知識點,就不去說它們了。
getAndIncrement()方法內部調用了Unsafe對象的getAndAddInt()方法,第二個參數是VALUE,這個VALUE大有玄機,它表示成員變量在對象內存中的便宜地址,根據前面的知識,普通對象的結構 就是 對象頭+實例數據+對齊字節,那如果我們能獲取到第一個實例數據的偏移地址,其實就是獲得了對象頭的字節大小。

如何拿到並使用Unsafe

因爲Unsafe是不可見的類,而且它在初始化的時候有檢查當前類的加載器,如果不是系統加載器會報錯。但是好消息是,AtomicInteger中定義了一個Unsafe對象,而且是靜態的,我們可以直接通過反射來得到。

    public static Object getUnsafeObject() {
        Class clazz = AtomicInteger.class;
        try {
            Field uFiled = clazz.getDeclaredField("U");
            uFiled.setAccessible(true);
            return uFiled.get(null);
        } catch (NoSuchFieldException e) {
            e.printStackTrace();
        } catch (IllegalAccessException e) {
            e.printStackTrace();
        }
        return null;
    }

拿到了Unsafe,我們就可以通過調用它的objectFieldOffset靜態方法來獲取成員變量的內存偏移地址。

    public static long getVariableOffset(Object target, String variableName) {
        Object unsafeObject = getUnsafeObject();
        if (unsafeObject != null) {
            try {
                Method method = unsafeObject.getClass().getDeclaredMethod("objectFieldOffset", Field.class);
                method.setAccessible(true);
                Field targetFiled = target.getClass().getDeclaredField(variableName);
                return (long) method.invoke(unsafeObject, targetFiled);
            } catch (NoSuchMethodException e) {
                e.printStackTrace();
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            } catch (InvocationTargetException e) {
                e.printStackTrace();
            } catch (NoSuchFieldException e) {
                e.printStackTrace();
            }
        }
        return -1;
    }

    public static void printObjectOffsets(Object target) {
        Class targetClass = target.getClass();
        Field[] fields = targetClass.getDeclaredFields();
        for (Field field : fields) {
            String name = field.getName();
            Log.d("offset", name + " offset: " + getVariableOffset(target, name));
        }
    }

我們來使用上面的工具測試打印之前的Fruit和Apple,

        Log.d("offset", "------start print fruit offset!------");
        Utils.printObjectOffsets(new Fruit());

        Log.d("offset", "------start print apple offset!------");
        Utils.printObjectOffsets(new Apple());

        // 輸出結果 (Android 8.0模擬器)
        offset: ------start print fruit offset!------
        offset: size offset: 8
        offset: ------start print apple offset!------
        offset: brother offset: 12
        offset: create_time offset: 24
        offset: id offset: 20
        offset: name offset: 16

通過輸出結果,看出在 Android8.0 ART 虛擬機上,對象頭的大小是8個字節,這跟hotspot虛擬機不同(hotspot是12個字節默認開啓指針壓縮),根據輸出的結果目前只發現這一點差別,各種數據類型佔用的字節數都是一樣的,比如int佔4個字節,指針4個字節,long8個字節等,都一樣。

總結

全文我們總結了以下幾個知識點

  • Java虛擬機通過字節碼指令來操作內存,所以可以說它並不關心數據類型,它只是按指令行事,不同類型的數據有不同的字節碼指令。
  • Java中基本數據類型和引用類型的內存分配知識,重點分析了引用類型的對象頭,並介紹了JOL工具的使用
  • 延伸到Android平臺,介紹了一種獲取Android中對象的對象頭信息的方法,並對比了ART和Hotspot虛擬機對象頭長度的差別。

瞭解這些並不是爲了裝逼炫技,說實話,寫代碼做工程的沒什麼好裝的,用的都是別人的輪子,我只會感謝我知道這些還不算太晚,所以我把它們寫出來分享給大家。
 

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章