天天看點

JVM 系列(4)一看就懂的對象記憶體布局

請點贊關注,你的支援對我意義重大。

🔥 Hi,我是小彭。本文已收錄到 GitHub · AndroidFamily 中。這裡有 Android 進階成長知識體系,有志同道合的朋友,關注公衆号 [彭旭銳] 帶你建立核心競争力。

前言

Java 中一切皆對象,同時對象也是 Java 程式設計中接觸最多的概念,深入了解 Java 對象能夠更幫助我們深入地掌握 Java 技術棧。在這篇文章裡,我們将從記憶體的視角,帶你深入了解 Java 對象在虛拟機中的表現形式。

學習路線圖:

1. 對象在哪裡配置設定?

在 Java 虛拟機中,Java 堆和方法區是配置設定對象的主要區域,但是也存在一些特殊情況,例如 TLAB、棧上配置設定、标量替換等。 這些特殊情況的存在是虛拟機為了進一步優化對象配置設定和回收的效率而采用的特殊政策,可以作為知識儲備。

  • 1、Java 堆(Heap): Java 堆是絕大多數對象的配置設定區域,現代虛拟機會采用分代收集政策,是以 Java 堆又分為新生代、老生代和永生代。如果新生代使用複制算法,又可以分為 Eden 區、From Survivor 區和 To Survivor 區。除了這些每個線程都可以配置設定對象的區域,如果虛拟機開啟了 TLAB 政策,那麼虛拟機會在堆中為每個線程預先配置設定一小塊記憶體,稱為線程本地配置設定緩沖(Thread Local Allocation Buffer,TLAB)。在 TLAB 上配置設定對象不需要同步鎖定,可以加快對象配置設定速度(TLAB 中的對象依然是線程共享讀取的,隻是不允許其他線程在該區域配置設定對象);
  • 2、方法區(Method Area): 方法區也是線程共享的區域,堆中存放的是生命周期較短的對象,而方法區中存放的是生命周期較長的對象,通常是一些支撐虛拟機執行的必要對象,将兩種對象分開存儲展現的是動靜分離的思想,有利于記憶體管理。存儲在方法區中的資料包括已加載的 Class 對象、靜态字段(本質上是 Class 對象中的執行個體字段,下文會解釋)、常量池(例如 String.intern())和即時編譯代碼等;
  • 3、棧上配置設定(Stack Allocation): 如果 Java 虛拟機通過逃逸分析後判斷一個對象的生命周期不會逃逸到方法外,那麼可以選擇直接在棧上配置設定對象,而不是在堆上配置設定。棧上配置設定的對象會随着棧幀出棧而銷毀,不需要經過垃圾收集,能夠緩解垃圾收集器的壓力。
  • 4、标量替換(Scalar Replacement): 在棧上配置設定政策的基礎上,虛拟機還可以選擇将對象分解為多個局部變量再進行棧上配置設定,連對象都不建立。

2. 對象的通路定位

Java 類型分為基礎資料類型(int 等)和引用類型(Reference),雖然兩者都是數值,但卻有本質的差別:基礎資料類型本身就代表資料,而引用本身隻是一個位址,并不代表對象資料。那麼,虛拟機是如何通過引用定位到實際的對象資料呢?具體通路定位方式取決于虛拟機實作,目前有 2 種主流方式:

  • 1、直接指針通路: 引用内部持有一個指向對象資料的直接指針,通過該指針就可以直接通路到對象資料。采用這種方式的話,就需要在對象資料中額外使用一個指針來指向對象類型資料;
  • 2、句柄通路: 引用内部持有一個句柄,而句柄内部持有指向對象資料和類型資料的指針(句柄位于 Java 堆中句柄池)。使用這種方式的話,就不需要在對象資料中記錄對象類型資料的指針。

使用句柄的優點是當對象在垃圾收集過程中移動存儲區域時,虛拟機隻需要改變句柄中的指針,而引用保持穩定。而使用直接指針的優點是隻需要一次指針跳轉就可以通路對象資料,通路速度相對更快。以 Sun HotSpot 虛拟機而言,采用的是直接指針方式,而 Android ART 虛拟機采用的是句柄方式。

handle.h

// Android ART 虛拟機源碼展現:
// Handles are memory locations that contain GC roots. As the mirror::Object*s within a handle are
// GC visible then the GC may move the references within them, something that couldn't be done with
// a wrap pointer. Handles are generally allocated within HandleScopes. Handle is a super-class
// of MutableHandle and doesn't support assignment operations.
template<class T>
class Handle : public ValueObject {
	...
}
           

直接指針通路:

句柄通路:

關于 Java 引用類型的深入分析,見 引用類型

3. 使用 JOL 分析對象記憶體布局

這一節我們示範使用 JOL(Java Object Layout) 來分析 Java 對象的記憶體布局。JOL 是 OpenJDK 提供的對象記憶體布局分析工具,不過它隻支援 HotSpot / OpenJDK 虛拟機,在其他虛拟機上使用會報錯:

錯誤日志

java.lang.IllegalStateException: Only HotSpot/OpenJDK VMs are supported
           

3.1 使用步驟

現在,我們使用 JOL 分析 new Object() 在 HotSpot 虛拟機上的記憶體布局,模闆程式如下:

示例程式

// 步驟一:添加依賴
implementation 'org.openjdk.jol:jol-core:0.11'
// 步驟二:建立對象
Object obj = new Object();
// 步驟三:列印對象記憶體布局
// 1. 輸出虛拟機與對象記憶體布局相關的資訊
System.out.println(VM.current().details());
// 2. 輸出對象記憶體布局資訊
System.out.println(ClassLayout.parseInstance(obj).toPrintable());
           

輸出日志

# Running 64-bit HotSpot VM.
# Using compressed oop with 3-bit shift.
# Using compressed klass with 3-bit shift.
# Objects are 8 bytes aligned.
# Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]
# Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

java.lang.Object object internals:
 OFFSET  SIZE   TYPE DESCRIPTION        VALUE
      0     4        (object header)    01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)    00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)    e5 01 00 f8 (11100101 00000001 00000000 11111000) (-134217243)
     12     4        (loss due to the next object alignment)
Instance size: 16 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
           

其中關于虛拟機的資訊:

  • Running 64-bit HotSpot VM.

    表示運作在 64 位的 HotSpot 虛拟機;
  • Using compressed oop with 3-bit shift.

    指針壓縮(後文解釋);
  • Using compressed klass with 3-bit shift.

    指針壓縮(後文解釋);
  • Objects are 8 bytes aligned.

    表示對象按 8 位元組對齊(後文解釋);
  • Field sizes by type: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

    :依次表示引用、boolean、byte、char、short、int、float、long、double 類型占用的長度;
  • Array element sizes: 4, 1, 1, 2, 2, 4, 4, 8, 8 [bytes]

    :依次表示數組元素長度。

我将 Java 對象的記憶體布局總結為以下基本模型:

3.2 對象記憶體布局的基本模型

在 Java 虛拟機中,對象的記憶體布局主要由 3 部分組成:

  • 1、對象頭(Header): 包括對象的運作時狀态資訊 Mark Work 和類型指針(直接指針通路方式),資料對象還會記錄數組元素個數;
  • 2、執行個體資料(Instance Data): 普通對象的執行個體資料包括目前類聲明的執行個體字段以及父類聲明的執行個體字段,而 Class 對象的執行個體資料包括目前類聲明的靜态字段和方法表等;
  • 3、對齊填充(Padding): HotSpot 虛拟機對象的大小必須按 8 位元組對齊,如果對象實際占用空間不足 8 位元組的倍數,則會在對象末尾增加對齊填充。

關于方法表的作用,見 重載與重寫。

4. 對象記憶體布局詳解

這一節開始,我們詳細解釋對象記憶體布局的模型。

4.1 對象頭(Header)**

  • Mark Work: Mark Work 是對象的運作時狀态資訊,包括哈希碼、分代年齡、鎖狀态、偏向鎖資訊等。由于 Mark Work 是與對象執行個體資料無關的額外存儲成本,是以虛拟機選擇将其設計為帶狀态的資料結構,會根據對象目前的不同狀态而定義不同的含義;
  • 類型指針(Class Pointer): 指向對象類型資料的指針,隻有虛拟機采用直接指針的對象通路定位方式才需要在對象上記錄類型指針,而采用句柄的對象通路定位方式不需要此指針;
  • 數組長度: 數組類型的元素長度是不能提前确定的,但在建立對象後又是固定的,是以數組對象的對象頭中會記錄數組對象中實際元素的個數。

以下示範檢視數組對象的對象頭中的數組長度字段:

示例程式

char [] str = new char[2];
System.out.println(ClassLayout.parseInstance(str).toPrintable());
           

輸出日志

[C object internals:
 OFFSET  SIZE   TYPE DESCRIPTION        VALUE
      0     4        (object header)    01 00 00 00 (00000001 00000000 00000000 00000000) (1)
      4     4        (object header)    00 00 00 00 (00000000 00000000 00000000 00000000) (0)
      8     4        (object header)    41 00 00 f8 (01000001 00000000 00000000 11111000) (-134217663)
     12     4        (object header)    【數組長度:2】02 00 00 00 (00000010 00000000 00000000 00000000) (2)
     16     4   char [C.<elements>     N/A
     20     4        (loss due to the next object alignment)
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total
           

可以看到,對象頭中有一塊 4 位元組的區域,顯示該數組長度為 2。

4.2 執行個體資料(Instance Data)

普通對象和 Class 對象的執行個體資料區域是不同的,需要分開讨論:

  • 1、普通對象: 包括目前類聲明的執行個體字段以及父類聲明的執行個體字段,不包括類的靜态字段;
  • 2、Class 對象: 包括目前類聲明的靜态字段和方法表等

其中,父類聲明的執行個體字段會放在子類執行個體字段之前,而字段間的并不是按照源碼中的聲明順序排列的,而是相同寬度的字段會配置設定在一起:引用類型 > long/double > int/float > short/char > byte/boolean。如果虛拟機開啟 CompactFields 政策,那麼子類較窄的字段有可能插入到父類變量的空隙中。

4.3 對齊填充(Padding)

HotSpot 虛拟機對象的大小必須按 8 位元組對齊,如果對象實際占用空間不足 8 位元組的倍數,則會在對象末尾增加對齊填充。 對齊填充不僅能夠保證對象的起始位置是規整的,同時也是實作指針壓縮的一個前提。

5. 什麼是指針壓縮?

我們都知道 CPU 有 32 位和 64 位的差別,這裡的位數決定了 CPU 在記憶體中的尋址能力,32 位的指針可以表示 4G 的記憶體空間,而 64 位的指針可以表示一個非常大的天文數字。但是,目前市場上計算機的記憶體中不可能有這麼大的空間,是以 64 位指針中很多高位比特其實是被浪費掉的。 為了提高記憶體利用效率,Java 虛拟機會采用指針壓縮的方式,讓 32 位指針不僅可以表示 4G 記憶體空間,還可以表示略大于 4G (不超過 32 G)的記憶體空間。這樣就可以在使用較大堆記憶體的情況下繼續使用 32 位的指針變量,進而減少程式記憶體占用。 但是,32 位指針怎麼可能表示超過 4G 記憶體空間?我們把 64 位指針的高 32 位截斷之後,剩下的 32 位指針也最多隻能表示 4G 空間呀?

在解釋這個問題之前,我先解釋下為什麼 32 位指針可以表示 4G 記憶體空間呢? 細心的同學會發現,你用 $2^{32}$ 計算也隻是得到 512M 而已,那麼 4G 是怎麼計算出來的呢?其實啊,作業系統中最小的記憶體配置設定機關是位元組,而不是比特位,作業系統無法按位通路記憶體,隻能按位元組通路記憶體。是以,32 位指針其實是表示 $2^{32}bytes$ ,而不是 $2^{32}bits$,算起來就是 4G 記憶體空間。

了解了 4G 的計算問題後,再解釋 32 位指針如何表示 32G 記憶體空間就很簡單了。 這就拐回到上一節提到的對象 8 位元組對齊了。作業系統将 8 個比特位組合成 1 個位元組,等于說隻需要标記每 8 個位的編号,而 Java 虛拟機在保證對象按 8 位元組對齊後,也可以隻需要标記每 8 個位元組的編号,而不需要标記每個位元組的編号。是以,32 位指針其實是表示 $2^{32}*8bytes$,算起來就是 32G 記憶體空間了。如下圖所示:

提示: 在上文使用 JOL 分析對象記憶體布局時,輸入日志

Using compressed oop with 3-bit shift.

就表示對象是按 8 位元組對齊,指針按 3 位位移。

那對象對齊填充繼續放大的話,32 位指針是不是可以表示更大的記憶體空間了?對。 同理,對齊填充放大到 16 位對齊,則可以表示 64G 空間,放大到 32 位對齊,則可以表示 128G 空間。但是,放大對齊填充等于放大了每個對象的平大小,對齊越大填充的空間會越快抵消指針壓縮所減少的空間,得不償失。是以,Java 虛拟機的選擇是在記憶體空間超過 32G 時,放棄指針壓縮政策,而不是一味增大對齊填充。

6. 總結

到這裡,對象的記憶體布局就将完了。我們講到了對象的配置設定區域、對象資料的通路定位方式以及對象内部的布局形式。下一篇,我們繼續深入挖掘 Java 引用類型的實作原理。關注我,帶你建立核心競争力,我們下次見。

參考資料

  • 深入了解 Java 虛拟機(第 3 版)(第 1、3、13 章) —— 周志明 著
  • 深入了解 Android:Java 虛拟機 ART(第 8.7 章 · 類的加載、連結和初始化) —— 鄧凡平 著
  • Java 并發程式設計的藝術(第 2 章 · Java 并發機制的底層實作原理)—— 方騰飛、魏鵬、程曉明 著
  • JVM Anatomy Quark #23: Compressed References —— Aleksey Shipilёv 著
  • JVM Anatomy Quark #24: Object Alignment —— Aleksey Shipilёv 著
你的點贊對我意義重大!微信搜尋公衆号 [彭旭銳],希望大家可以一起讨論技術,找到志同道合的朋友,我們下次見!

享受陽光。