你有認真瞭解過自己的“Java物件”嗎? 渣男

Java大猿帥發表於2020-07-13

物件在 JVM 中是怎麼儲存的

物件頭裡有什麼?

文章收錄在 GitHub JavaKeeper ,N線網際網路開發必備技能兵器譜,有你想要的。

作為一名 Javaer,生活中的我們可能暫時沒有物件,但是工作中每天都會建立大量的 Java 物件,你有試著去了解下自己的“物件”嗎?

我們從四個方面重新認識下自己的“物件”

  1. 建立物件的 6 種方式
  2. 建立一個物件在 JVM 中都發生了什麼
  3. 物件在 JVM 中的記憶體佈局
  4. 物件的訪問定位

一、建立物件的方式

  • 使用 new 關鍵字

    這是建立一個物件最通用、常規的方法,同時也是最簡單的方式。通過使用此方法,我們可以呼叫任何要呼叫的建構函式(預設使用無參建構函式)

    Person p = new Person();
    
  • 使用 Class 類的 newInstance(),只能呼叫空參的構造器,許可權必須為 public

    //獲取類物件
    Class aClass = Class.forName("priv.starfish.Person");
    Person p1 = (Person) aClass.newInstance();
    
  • Constructor 的 newInstance(xxx),對構造器沒有要求

    Class aClass = Class.forName("priv.starfish.Person");
    //獲取構造器
    Constructor constructor = aClass.getConstructor();
    Person p2 = (Person) constructor.newInstance();
    
  • clone()

    深拷貝,需要實現 Cloneable 介面並實現 clone(),不呼叫任何的構造器

    Person p3 = (Person) p.clone();
    
  • 反序列化

    通過序列化和反序列化技術從檔案或者網路中獲取物件的二進位制流。

    每當我們序列化和反序列化物件時,JVM 會為我們建立了一個獨立的物件。在 deserialization 中,JVM 不使用任何建構函式來建立物件。(序列化的物件需要實現 Serializable)

    //準備一個檔案用於儲存該物件的資訊
    File f = new File("person.obj");
    FileOutputStream fos = new FileOutputStream(f);
    ObjectOutputStream oos = new ObjectOutputStream(fos);
    //序列化物件,寫入到磁碟中
    oos.writeObject(p);
    //反序列化
    FileInputStream fis = new FileInputStream(f);
    ObjectInputStream ois = new ObjectInputStream(fis);
    //反序列化物件
    Person p4 = (Person) ois.readObject();
    
  • 第三方庫 Objenesls

    Java已經支援通過 Class.newInstance() 動態例項化 Java 類,但是這需要Java類有個適當的構造器。很多時候一個Java類無法通過這種途徑建立,例如:構造器需要引數、構造器有副作用、構造器會丟擲異常。Objenesis 可以繞過上述限制

二、建立物件的步驟

這裡討論的僅僅是普通 Java 物件,不包含陣列和 Class 物件(普通物件和陣列物件的建立指令是不同的。建立類例項的指令:new,建立陣列的指令:newarray,anewarray,multianewarray)

1. new指令

虛擬機器遇到一條 new 指令時,首先去檢查這個指令的引數是否能在 Metaspace 的常量池中定位到一個類的符號引用,並且檢查這個符號引用代表的類是否已被載入、解析和初始化過(即判斷類元資訊是否存在)。如果沒有,那麼須在雙親委派模式下,先執行相應的類載入過程。

2. 分配記憶體

接下來虛擬機器將為新生代物件分配記憶體。物件所需的記憶體的大小在類載入完成後便可完全確定。如果例項成員變數是引用變數,僅分配引用變數空間即可,即 4 個位元組大小。分配方式有“指標碰撞(Bump the Pointer)”和“空閒列表(Free List)”兩種方式,具體由所採用的垃圾收集器是否帶有壓縮整理功能決定。

  • 如果記憶體是規整的,就採用“指標碰撞”來為物件分配記憶體。意思是所有用過的記憶體在一邊,空閒的記憶體在另一邊,中間放著一個指標作為分界點的指示器,分配記憶體就僅僅是把指標指向空閒那邊挪動一段與物件大小相等的距離罷了。如果垃圾收集器採用的是 Serial、ParNew 這種基於壓縮演算法的,就採用這種方法。(一般使用帶整理功能的垃圾收集器,都採用指標碰撞)

  • 如果記憶體是不規整的,虛擬機器需要維護一個列表,這個列表會記錄哪些記憶體是可用的,在為物件分配記憶體的時候從列表中找到一塊足夠大的空間劃分給該物件例項,並更新列表內容,這種分配方式就是“空閒列表”。使用CMS 這種基於Mark-Sweep 演算法的收集器時,通常採用空閒列表。

我們都知道堆記憶體是執行緒共享的,那在分配記憶體的時候就會存在併發安全問題,JVM 是如何解決的呢?

一般有兩種解決方案:

  1. 對分配記憶體空間的動作做同步處理,採用 CAS 機制,配合失敗重試的方式保證更新操作的原子性

  2. 每個執行緒在 Java 堆中預先分配一小塊記憶體,然後再給物件分配記憶體的時候,直接在自己這塊"私有"記憶體中分配,當這部分割槽域用完之後,再分配新的"私有"記憶體。這種方案稱為 TLAB(Thread Local Allocation Buffer),這部分 Buffer 是從堆中劃分出來的,但是是本地執行緒獨享的。

    這裡值得注意的是,我們說 TLAB 是執行緒獨享的,只是在“分配”這個動作上是執行緒獨佔的,至於在讀取、垃圾回收等動作上都是執行緒共享的。而且在使用上也沒有什麼區別。另外,TLAB 僅作用於新生代的 Eden Space,物件被建立的時候首先放到這個區域,但是新生代分配不了記憶體的大物件會直接進入老年代。因此在編寫 Java 程式時,通常多個小的物件比大的物件分配起來更加高效。

    虛擬機器是否使用 TLAB 是可以選擇的,可以通過設定 -XX:+/-UseTLAB 引數來指定,JDK8 預設開啟。

3. 初始化

記憶體分配完成後,虛擬機器需要將分配到的記憶體空間都初始化為零值(不包括物件頭),這一步操作保證了物件的例項欄位在 Java 程式碼中可以不賦初始值就直接使用,程式能訪問到這些欄位的資料型別所對應的零值。如:byte、short、long 轉化為物件後初始值為 0,Boolean 初始值為 false。

4. 物件的初始設定(設定物件的物件頭)

接下來虛擬機器要對物件進行必要的設定,例如這個物件是哪個類的例項、如何才能找到類的後設資料資訊、物件的雜湊碼、物件的GC分代年齡等資訊。這些資訊存放在物件的物件頭(Object Header)之中。根據虛擬機器當前的執行狀態的不同,如對否啟用偏向鎖等,物件頭會有不同的設定方式。

5. <init>方法初始化

在上面的工作都完成了之後,從虛擬機器的角度看,一個新的物件已經產生了,但是從 Java 程式的角度看,物件建立才剛剛開始,<init>方法還沒有執行,所有的欄位都還為零。初始化成員變數,執行例項化程式碼塊,呼叫類的構造方法,並把堆內物件的地址賦值給引用變數。

所以,一般來說,執行 new 指令後接著執行 init 方法,把物件按照程式設計師的意願進行初始化(應該是將建構函式中的引數賦值給物件的欄位),這樣一個真正可用的物件才算完全產生出來。

三、物件的記憶體佈局

在 HotSpot 虛擬機器中,物件在記憶體中儲存的佈局可以分為 3 塊區域:物件頭(Header)、例項資料(Instance Data)、對其填充(Padding)。

物件頭

HotSpot 虛擬機器的物件頭包含兩部分資訊。

  • 第一部分用於儲存物件自身的執行時資料,如雜湊碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒ID、偏向時間戳等。
  • 物件的另一部分型別指標,即物件指向它的類後設資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項(並不是所有的虛擬機器實現都必須在物件資料上保留型別指標,也就是說,查詢物件的後設資料資訊並不一定要經過物件本身)。

如果物件是一個 Java 陣列,那在物件頭中還必須有一塊用於記錄陣列長度的資料。

後設資料:描述資料的資料。對資料及資訊資源的描述資訊。在 Java 中,後設資料大多表示為註解。

例項資料

例項資料部分是物件真正儲存的有效資訊,也是在程式程式碼中定義的各種型別的欄位內容,無論從父類繼承下來的,還是在子類中定義的,都需要記錄起來。這部分的儲存順序會受虛擬機器預設的分配策略引數和欄位在 Java 原始碼中定義的順序影響(相同寬度的欄位總是被分配到一起)。

規則:

  • 相同寬度的欄位總是被分配在一起
  • 父類中定義的變數會出現在子類之前
  • 如果 CompactFields 引數為 true(預設true),子類的窄變數可能插入到父類變數的空隙

對齊填充

對齊填充部分並不是必然存在的,也沒有特別的含義,它僅僅起著佔位符的作用。由於 HotSpot VM 的自動記憶體管理系統要求物件的起始地址必須是 8 位元組的整數倍,也就是說,物件的大小必須是 8 位元組的整數倍。而物件頭部分正好是 8 位元組的倍數(1倍或者2倍),因此,當物件例項資料部分沒有對齊時,就需要通過對齊填充來補全。

我們通過一個簡單的例子加深下理解

public class PersonObject {
    public static void main(String[] args) {
        Person person = new Person();
    }
}
public class Person {
    int id = 1008;
    String name;
    Department department;
    {
        name = "匿名使用者";   //name賦值為字串常量
    }
}
public class Department {
    int id;
    String name;
}

四、物件的訪問定位

我們建立物件的目的,肯定是為了使用它,那 JVM 是如何通過棧幀中的物件引用訪問到其記憶體的物件例項呢?

由於 reference 型別在 Java 虛擬機器規範裡只規定了一個指向物件的引用,並沒有定義這個引用應該通過哪種方式去定位,以及訪問到 Java 堆中的物件的具體位置,因此不同虛擬機器實現的物件訪問方式會有所不同,主流的訪問方式有兩種:

  • 控制程式碼訪問

    如果使用控制程式碼訪問方式,Java堆中會劃分出一塊記憶體來作為控制程式碼池,reference中儲存的就是物件的控制程式碼地址,而控制程式碼中包含了物件例項資料和型別資料各自的具體地址資訊。使用控制程式碼方式最大的好處就是reference中儲存的是穩定的控制程式碼地址,在物件被移動(垃圾收集時移動物件是非常普遍的行為)時只會改變控制程式碼中的例項資料指標,而reference本身不需要被修改。

  • 直接指標(Hotspot 使用該方式)

    如果使用該方式,Java堆物件的佈局就必須考慮如何放置訪問型別資料的相關資訊,reference中直接儲存的就是物件地址。使用直接指標方式最大的好處就是速度更快,他節省了一次指標定位的時間開銷

參考:

相關文章