面試官:Java中new Object()到底占用幾個字節

前言

我們來分析一下堆內佈局以及Java對象在內存中的佈局吧。

對象的指向

先來看一段代碼:

package com.zwx.jvm;

public class HeapMemory {
  private Object obj1 = new Object();

  public static void main(String[] args) {
    Object obj2 = new Object();
  }
}

上面的代碼中,obj1 和obj2在內存中有什麼區別?

我們先來回憶一下JVM系列1的文章中有提到,方法區存儲每個類的結構,比如:運行時常量池、屬性和方法數據,以及方法和構造函數等數據。所以我們這個obj1是存在方法區的,而new會創建一個對象實例,對象實例是存儲在內的,於是就有瞭下面這幅圖(方法區指向堆):

而obj2 是屬於方法內的局部變量,存儲在Java虛擬機棧內的棧幀中的局部變量表內,這就是經典的棧指向堆:

這裡我們再來思考一下,我們一個變量指向瞭堆,而堆內隻是存儲瞭一個實例對象,那麼堆內的示例對象是如何知道自己屬於哪個Class,也就是說這個實例是如何知道自己所對應的類元信息的呢?這就涉及到瞭一個Java對象在內存中是如何佈局的。

Java內存模型

對象內存中可以分為三塊區域:對象頭(Header),實例數據(Instance Data)和對齊填充(Padding),以64位操作系統為例(未開啟指針壓縮的情況)Java對象佈局如下圖所示:

其中對象頭中的Mark Word中的詳細信息在文章synchronized鎖升級原理中有詳細介紹。上圖中的對齊填充不是一定有的,如果對象頭和實例數據加起來剛好是8字節的倍數,那麼就不需要對齊填充。

知道瞭Java內存佈局,那麼我們來看一個面試問題

Object obj=new Object()占用字節

這是網上很多人都會提到的一個問題,那麼結合上面的Java內存佈局,我們來分析下,以64位操作系統為例,new Object()占用大小分為兩種情況:

  • 未開啟指針壓縮        占用大小為:8(Mark Word)+8(Class Pointer)=16字節
  • 開啟瞭指針壓縮(默認是開啟的)        開啟指針壓縮後,Class Pointer會被壓縮為4字節,最終大小為:8(Mark Word)+4(Class Pointer)+4(對齊填充)=16字節

結果到底是不是這個呢?我們來驗證一下。首先引入一個pom依賴:

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

然後新建一個簡單的demo:

package com.zwx.jvm;

import org.openjdk.jol.info.ClassLayout;

public class HeapMemory {
  public static void main(String[] args) {
    Object obj = new Object();
    System.out.println(ClassLayout.parseInstance(obj).toPrintable());
  }
}

輸出結果如下:

最後的結果是16字節,沒有問題,這是因為默認開啟瞭指針壓縮,那我們現在把指針壓縮關閉之後再去試試。

-XX:+UseCompressedOops 開啟指針壓縮
-XX:-UseCompressedOops 關閉指針壓縮

再次運行,得到如下結果:

可以看到,這時候已經沒有瞭對齊填充部分瞭,但是占用大小還是16位。

下面我們再來演示一下如果一個對象中帶有屬性之後的大小。

新建一個類,內部隻有一個byte屬性:

package com.zwx.jvm;

public class MyItem {
  byte i = 0;
}

然後分別在開啟指針壓縮和關閉指針壓縮的場景下分別輸出這個類的大小。

package com.zwx.jvm;

import org.openjdk.jol.info.ClassLayout;

public class HeapMemory {
  public static void main(String[] args) {
    MyItem myItem = new MyItem();
    System.out.println(ClassLayout.parseInstance(myItem).toPrintable());
  }
}

開啟指針壓縮,占用16字節:

關閉指針壓縮,占用24字節:

這個時候就能看出來開啟瞭指針壓縮的優勢瞭,如果不斷創建大量對象,指針壓縮對性能還是有一定優化的。

對象的訪問

創建好一個對象之後,當然需要去訪問它,那麼當我們需要訪問一個對象的時候,是如何定位到對象的呢?目前最主流的訪問對象方式有兩種:句柄訪問和直接指針訪問。

句柄訪問        使用句柄訪問的話,Java虛擬機會在堆內劃分出一塊內存來存儲句柄池,那麼對象當中存儲的就是句柄地址,然後句柄池中才會存儲對象實例數據和對象類型數據地址。

直接指針訪問(Hot Spot虛擬機采用的方式)        直接指針訪問的話對象中就會直接存儲對象類型數據。

句柄訪問和直接指針訪問對比

上面圖形中我們很容易對比,就是如果使用句柄訪問的時候,會多瞭一次指針定位,但是他也有一個好處就是,假如一個對象被移動(地址改變瞭),那麼隻需要改變句柄池的指向就可以瞭,不需要修改reference對象內的指向,而如果使用直接指針訪問,就還需要到局部變量表內修改reference指向。

堆內存

上面我們提到,在Java對象頭當中的Mark Word存儲瞭對象的分代年齡,那麼什麼是分代年齡呢?

一個對象的分代年齡可以理解為垃圾回收次數,當一個對象經過一次垃圾回收之後還存在,那麼分代年齡就會加1,在64位的虛擬機中,分代年齡占瞭4位,最大值為15。分代年齡默認為0000,隨著垃圾回收次數,會逐漸遞增。

Java堆內存中按照分代年齡來劃分,分為Young區和Old區,對象分配首先會到Young區,達到一定分代年齡(-XX:MaxTenuringThreshold可以設置大小,默認為15)就會進入Old區(註意:如果一個對象太大,那麼就會直接進入Old區)。

之所以會這麼劃分是因為如果整個堆隻有一個區的話,那麼垃圾回收的時候每次都需要把堆內所有對象都掃描一遍,浪費性能。而其實大部分Java對象的生命周期都是很短的,一旦一個對象回收很多次都回收不掉,可以認為下一次垃圾回收的時候可能也回收不掉,所以Young區和Old區的垃圾回收可以分開進行,隻有當Young區在進行垃圾回收之後還是沒有騰出空間,那麼再去觸發Old區的垃圾回收。

Young區

現在拆分成瞭Young區,那我們看下面一個場景,下面的Young是經過垃圾回收之後的一個概圖:

假如說現在來瞭一個對象,要占用2個對象的大小,會發現放不下去瞭,這時候就會觸發GC(垃圾回收),但是一旦觸發瞭GC(垃圾回收),對用戶線程是有影響的,因為GC過程中為瞭確保對象引用不會不斷變化,需要停止所有用戶線程,Sun把這個事件稱之為:Stop the World(STW)。這些在下一篇講解垃圾回收的時候會詳細介紹,這裡先不深入。

所以說一般是越少GC越好,而實際上上圖中可以看到至少還可以放入3個對象,隻要按照對象都按照順序放好,那麼是可以放得下的,所以這就產生瞭問題瞭,明明有空間,但是因為空間不連續,導致對象申請內存失敗,導致觸發GC瞭,那麼如何解決這種問題呢?

解決的思路就是把Young區的對象按順序放好,所以就產生瞭一個方法,把Young區再次劃分一下,分為2個區:Eden區和Survivor區。

具體操作是:一個對象來瞭之後,先分配到Eden區,Eden區滿瞭之後,觸發GC,經過GC之後,為瞭防止空間不連續,把幸存下來的對象復制到Survivor區,然後Eden區就可以完整清理掉瞭,當然這麼做是有一個前提的,就是大部分對象都是生命周期極短的,基本一次垃圾回收就可以把Eden區大部分對象回收掉(這個前提是經過測試總結得到的)。

觸發GC的時候Survivor區也會一起回收,並不是說單獨隻觸發Eden區,但是這樣問題又來瞭,Eden區是保證空間基本連續瞭,但是Survivor區又可能產生空間碎片,導致不連續瞭,所以就又把Survivor區給一分為二瞭:

這個時候工作流程又變成這樣瞭:首先還是在Eden區分配空間,Eden區滿瞭之後觸發GC,GC之後把幸存對象 復制到S0區(S1區是空的),然後繼續在Eden區分配對象,再次觸發GC之後如果發現S0區放不下瞭(產生空間碎片,實際還有空間),那麼就把S0區對象復制到S1區,並把幸存對象也復制到S1區,這時候S0區是空的瞭,並依次反復操作,假如說S0區或者S1區空間對象復制移動瞭之後還是放不下,那就說明這時候是真的滿瞭,那就去老年區借點空間過來(這就是擔保機制,老年代需要提供這種空間分配擔保),假如說老年區空間也不夠瞭,那就會觸發Full GC,如果還是不夠,那就會拋出OutOfMemeoyError異常瞭。

註意:為瞭確保S0和S1兩個區域之間每次復制都能順利進行,S0和S1兩個區的大小必須要保持一致,而且同一時間有一個區域一定是空的。雖然說這種做法是會導致瞭一小部分空間的浪費,但是綜合其他性能的提升來說,是值得的。

Old區

當Young區的對象達到設置的分代年齡之後,對象會進入Old區,Old區滿瞭之後會觸發Full GC,如果還是清理不掉空間,那麼就拋出OutOfMemeoyError異常。

名詞掃盲

上面提到瞭很多新的名詞,而實際上很多這種名詞還有其他叫法,這個還是覺得有必要瞭解一下。

垃圾回收:簡稱GC。

Minor GC:針對新生代的GC

Major GC:針對老年代的GC,一般老年代觸發GC的同時也會觸發Minor GC,也就等於觸發瞭Full GC。

Full GC:新生代+老年代同時發生GC。

Young區:新生代

Old區:老年代

Eden區:暫時沒發現有什麼中文翻譯(伊甸園?)

Surcivor區:幸存區

S0和S1:也稱之為from區和to區,註意from和to兩個區是不斷互換身份的,且S0和S1一定要相等,並且保證一塊區域是空的

一個對象的人生軌跡圖

從上面的介紹大傢應該有一個大致的印象,一個對象會在Eden區,S0區,S1區,Old區不斷流轉(當然,一開始就會被回收的短命對象除外),我們可以得到下面的一個流程圖:

總結

本文主要介紹瞭一個Java對象在堆內是如何存儲的,並結合Java對象的內存佈局示范瞭一個普通對象占用大小問題,然後還分析瞭堆內的空間劃分以及劃分原因,本文中涉及到瞭GC相關知識均沒有深入講解,關於GC及GC算法和GC收集器等相關知識將放在下一篇進行詳細分析。

到此這篇關於面試官:Java中new Object()到底占用幾個字節的文章就介紹到這瞭,更多相關Java new Object()字節內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: