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

內存是程序員逃不開的話題,固然Java由於有GC使得咱們不用手動申請和釋放內存,可是瞭解Java內存分配是作內存優化的基礎,若是不瞭解Java內存分配的知識,可能會帶偏咱們內存優化的方向。因此這篇文章咱們以「一個對象佔多少內存」爲引子來談談Java內存分配。 文章基於JDK版本:1.8.0_191java

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

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

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方法,我已經添加了註釋bash

// 加載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,因此指令是編譯期已經肯定了,虛擬機只須要根據指令執行就行,根本不關心它操做的這個地址是什麼類型的,因此也就不用額外的字段去存類型了,解答咱們前面提的問題!app

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

Java數據類型有哪些

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

  • 基礎數據類型(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虛擬機對象頭長度的差異。

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

最後仍是那句話:只有充分的瞭解Java的內存分配機制,才能正確的去作內存優化!!

相關文章
相關標籤/搜索