java一個對象佔用多少字節?

最近在讀《深入理解Java虛擬機》,對Java對象的內存佈局有了進一步的認識,於是腦子裏自然而然就有一個很普通的問題,就是一個Java對象到底佔用多大內存?

想弄清楚上面的問題,先補充一下基礎知識。

1、JAVA 對象佈局

在 HotSpot虛擬機中,對象在內存中的存儲的佈局可以分爲三塊區域:對象頭(Header),實例數據(Instance Data)和對齊填充(Padding)

1.1對象頭(Header)
Java中對象頭由 Markword + 類指針kclass(該指針指向該類型在方法區的元類型) 組成。
普通對象頭在32位系統上佔用8bytes,64位系統上佔用16bytes。64位機器上,數組對象的對象頭佔用24個字節,啓用壓縮之後佔用16個字節。

用於存儲對象自身的運行時數據,如哈希碼(HashCode)、GC分代年齡、鎖狀態標誌、線程持有的鎖、偏向線程ID、偏向時間戳等。在64位的虛擬機(未開啓壓縮指針)爲64bit。
Markword:
在這裏插入圖片描述
類指針kclass:
kclass存儲的是該對象所屬的類在方法區的地址,所以是一個指針,默認Jvm對指針進行了壓縮,用4個字節存儲,如果不壓縮就是8個字節。 關於Compressed Oops的知識,大家可以自行查閱相關資料來加深理解。

如果對象是一個Java數組,那在對象頭中還必須有一塊用於記錄數組長度的數據,這塊佔用4個字節。因爲虛擬機可以通過普通Java對象的元數據信息確定Java對象的大小,但是從數組的元數據中無法確定數組的大小。
(並不是所有的虛擬機實現都必須在對象數據上保留類型指針,換句話說,查找對象的元數據並不一定要經過對象本身,可參考對象的訪問定位)

1.2實例數據(Instance Data)
實例數據部分就是成員變量的值,其中包含父類的成員變量和本類的成員變量。也就是說,除去靜態變量和常量值放在方法區,非靜態變量的值是隨着對象存儲在堆中的。
因爲修改靜態變量會反映到方法區中class的數據結構中,故而推測對象保存的是靜態變量和常量的引用。

1.3對齊填充(Padding)
用於確保對象的總長度爲8字節的整數倍。
HotSpot要求對象的總長度必須是8字節的整數倍。由於對象頭一定是8字節的整數倍,但實例數據部分的長度是任意的。因此需要對齊補充字段確保整個對象的總長度爲8的整數倍。

2、Java數據類型有哪些

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

2.1基礎數據類型內存佔用如下
在這裏插入圖片描述2.2引用類型 內存佔用如下:
引用類型跟基礎數據類型不一樣,除了對象本身之外,還存在一個指向它的引用(指針),指針佔用的內存在64位虛擬機上8個字節,如果開啓指針壓縮是4個字節,默認是開啓了的。

2.3字段重排序
爲了更高效的使用內存,實例數據字段將會重排序。排序的優先級爲: long = double > int = float > char = short > byte > boolean > object reference
如下所示的類

class FieldTest{
        byte a;
        int c;
        boolean d;
        long e;
        Object f;
    }

將會重排序爲(開啓CompressedOops選項):

   OFFSET  SIZE               TYPE DESCRIPTION            
         16     8               long FieldTest.e            
         24     4                int FieldTest.c            
         28     1               byte FieldTest.a            
         29     1            boolean FieldTest.d            
         30     2              (alignment/padding gap)
         32     8   java.lang.Object FieldTest.f

3、驗證

講完了上面的概念,我們可以去驗證一下。
3.1有一個Fruit類繼承了Object類,我們分別新建一個object和fruit,那他們分別佔用多大的內存呢?

class Fruit extends Object {
     private int size;
}

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

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

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

	public static void main(String[] args) {
		System.out.print(ClassLayout.parseClass(Fruit.class).toPrintable());
		System.out.print(ClassLayout.parseClass(Object.class).toPrintable());
	}

輸出結果

com.zzx.algorithm.tst.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
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

可以看到輸出結果都是16 bytes,跟我們前面的分析結果一致。

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

	public static void main(String[] args) {
		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,跟我們分析的一致。
3.3 接下來看對象的實例數據部分:
爲了方便說明,我們新建一個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.zzx.algorithm.tst.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.zzx.algorithm.tst.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,只是編譯器層面的限制,在實際內存中不論是私有的,還是公開的,都按規則存放在一起,對虛擬機來說並沒有什麼分別!

4、方法內部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,所以效率低很多。
總結一下: 虛擬機指針逃逸分析是默認開啓的,對象不會逃逸的時候優先在棧上分配,否則在堆上分配。
到這裏,關於“一個對象佔多少內存?”這個問題,已經能回答的相當全面了。

5.看在Android ART虛擬機上面的分配情況

我們前面使用了jol工具來輸出對象頭的信息,但是這個jol工具只能用在hotspot虛擬機上,那我們如何在Android上面獲取對象頭大小呢?
可以使用sun.misc.Unsafe的objectFieldOffset方法,返回成員屬性在內存中的地址相對於對象內存地址的偏移量
根據前面的知識,普通對象的結構 就是 對象頭+實例數據+對齊字節,那如果我們能獲取到第一個實例數據的偏移地址,其實就是獲得了對象頭的字節大小
5.1 如何拿到並使用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.e(">>>>>", name + " offset: " + getVariableOffset(target, name));
        }
    }

輸出結果:

2019-06-25 15:57:59.176 6056-6056/com.zzx.readhub E/>>>>>: size offset: 8
2019-06-25 15:57:59.176 6056-6056/com.zzx.readhub E/>>>>>: brother offset: 12
2019-06-25 15:57:59.176 6056-6056/com.zzx.readhub E/>>>>>: create_time offset: 24
2019-06-25 15:57:59.177 6056-6056/com.zzx.readhub E/>>>>>: name offset: 16
2019-06-25 15:57:59.177 6056-6056/com.zzx.readhub E/>>>>>: size offset: 20

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

總結

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

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

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