Java 執行時資料區和記憶體模型

C_nullptr發表於2021-03-11

執行時資料區是指對 JVM 執行過程中涉及到的記憶體根據功能、目的進行的劃分,而記憶體模型可以理解為對記憶體進行存取操作的過程定義。總是有人望文生義的將前者描述為 “Java 記憶體模型”,最近在閱讀《深入理解 Java 虛擬機器》之後對二者加深了部分理解,於是寫一篇相關內容的學習總結。

執行時資料區

《Java 虛擬機器規範》定義中,由 JVM 管理的記憶體區域分為以下幾個執行時資料區域:

flowchart LR subgraph 執行時資料區 subgraph 執行緒私有 虛擬機器棧 本地方法棧 程式計數器 end subgraph 執行緒共享 方法區 javaHeap[Java 堆] end end

程式計數器

程式計數器(Program Counter Register)的生命週期和 Java 執行緒一致,僅能被相關執行緒訪問。用於記錄當前執行緒所執行的位元組碼的行號,程式碼中的分支、迴圈、跳轉、異常處理和多執行緒發生切換時的執行緒恢復等基礎功能都需要依賴程式計數器完成。當執行 Java 程式碼時,程式計數器儲存正在執行的虛擬機器位元組碼指令地址,而執行本地方法時,程式計數器應該為空。

《Java 虛擬機器規範》中規定程式計數器不會發生 OutOfMemoryError 異常情況。

虛擬機器棧和本地方法棧

虛擬機器棧(Java Virtual Machine Stack)的生命週期和 Java 執行緒一致,僅能被相關執行緒訪問。用於描述 Java 方法執行的執行緒記憶體模型:每次進入一個新的方法時,JVM 都會同步建立一個棧幀,棧幀中儲存了區域性變數表、運算元棧、動態連線和方法出口等資訊。每次執行結束一個方法時,對應棧幀就會出棧。

虛擬機器棧可能發生兩類異常情況:

  • StackOverflowError:執行緒請求的棧深度大於虛擬機器允許的深度
  • OutOfMemoryError:如果 JVM 棧容量可以動態擴充,當需要擴充時 JVM 無法申請到足夠的記憶體就會丟擲該異常。而在 HotSpot 這種不允許動態擴充的虛擬機器中,如果建立時就失敗依然也會丟擲該異常。

本地方法棧(Native Method Stack)與虛擬機器棧基本類似,當執行本地方法時使用本地方法棧,同樣會丟擲 StackOverflowErrorOutOfMemoryError

《Java 虛擬機器規範》對本地方法棧的實現方式沒有任何強制規範,故 HotSpot 虛擬機器中虛擬機器棧和本地方法棧直接合二為一。

虛擬機器棧的大小通過 java 命令的引數 -Xss 設定。

Java 堆

Java 堆(Java Heap)在虛擬機器啟動時就建立,虛擬機器關閉時銷燬,被所有 Java 執行緒共享。用於存放所有的物件例項。

Java 堆受垃圾回收器管理,由於現代主流的垃圾回收器都是基於分代收集理論設計,Java 堆中經常會出現“新生代”、“老年代”、“永久代”、“Eden空間”、“From Survivor空間”、“To Survivor空間” 等名詞。這些名詞對於 Java 堆的劃分,是指一部分垃圾回收器的設計風格,而不是 JVM 具體實現的固有記憶體佈局,更不是 《Java 虛擬機器規範》裡對 Java 堆的進一步細緻劃分。並且近年來新出現的垃圾回收器也有不採用分代設計的,再用這些名詞劃分 Java 堆空間也已經不正確了。

Java 堆可能發生 OutOfMemoryError異常:分配記憶體給新的物件例項失敗、且堆無法再擴充時丟擲該異常。

Java 堆的大小通過 java 命令的引數 -Xmx-Xms 設定。

方法區

方法區(Method Area)和 Java 堆一樣在虛擬機器啟動時就建立,虛擬機器關閉時銷燬,被所有 Java 執行緒共享。用於儲存已被虛擬機器載入的型別資訊、常量、靜態變數和即時編譯器編譯後的程式碼快取。

在 HotSpot 虛擬機器中,方法區的實現經歷過兩次大規模改動:

  1. JDK 6 及以前:為了方便使用分代垃圾收集器管理方法區記憶體,使用永久代(Permanent Generation)實現方法區。
  2. JDK 7 時期:字串常量池和靜態變數轉移到本地記憶體中(Native Memory),其他資料依然存放在由永久代實現的方法區中。
  3. JDK 8 及此後:方法區中的全部資料都存放在被稱為元空間(Meta-space)的本地記憶體中。

如果方法區無法滿足新的分配記憶體需求時會丟擲 OutOfMemoryError 異常。

記憶體模型

記憶體模型:在特定的快取一致性協議約束下,對特定的記憶體快取記憶體進行讀寫訪問的過程抽象。記憶體模型主要用於在共享記憶體的多核系統中解決快取一致性的問題。不同架構的物理機通常具有不一樣的記憶體模型,而Java虛擬機器也有自己的記憶體模型。

在硬體層面上,記憶體模型針對快取記憶體。如圖所示,處理器運算速度和主記憶體的存取速度天差地別,必須加入快取記憶體來作為記憶體和處理器之間的緩衝。如果兩個處理器執行的指令都涉及到主記憶體同一區域,就會發生各自的快取資料不一致。這種情況下需要通過快取一致性協議規定處理器的讀寫行為。

flowchart LR CpuOne(處理器一) <--> CacheOne(快取記憶體一) CacheOne <--> Protocol[快取一致性協議] CpuTwo(處理器二) <--> CacheTwo(快取記憶體二) CacheTwo <--> Protocol Protocol <--> Memory((主記憶體))

在 JVM 中,記憶體模型針對各執行緒的工作記憶體。如圖所示,其中關鍵名詞解釋如下:

  • 主記憶體:與上方硬體層面的主記憶體概念一致,實際是作業系統為 JVM 分配的實體記憶體空間。
  • 工作記憶體:每個執行緒各自的工作記憶體,可與上方硬體層面的快取記憶體類比。

工作記憶體中儲存了該執行緒使用到的變數的主記憶體副本(並不是百分之百的完全副本,例如兩條執行緒同時訪問一個 10MB 的物件,並不會在各自的工作記憶體中都建立一個完全相同的物件),對每個變數的讀、寫操作都必須在工作記憶體中進行,不能直接訪問主記憶體(volatile 變數通過讀寫屏障實現,也受這條規則約束,並不是直接訪問主記憶體)。

主記憶體、工作記憶體的劃分方式與前一章中闡述的堆、棧、方法區等劃分方式是截然不同的概念,不能類比或對應。

flowchart LR ThreadOne(執行緒一) <--> WorkingMemoryOne(工作記憶體一) WorkingMemoryOne <--> SaveAndLoad[Save和Load操作] ThreadTwo(執行緒二) <--> WorkingMemoryTwo(工作記憶體二) WorkingMemoryTwo <--> SaveAndLoad SaveAndLoad <--> Memory((主記憶體))

volatile 變數

volatile 變數有可見性和禁止指令重排序優化兩個特點。

可見性

一個執行緒修改了 volatile 變數,其他執行緒立即可知。在 JVM 中的實現方式為,執行緒對 volatile 變數的讀取時,需要先將主記憶體中的當前值拷貝到工作記憶體中;執行緒對 volatile 變數寫入時,寫入工作記憶體後立即同步到主記憶體中。注意可見性和原子性是不同的概念,volatile 關鍵字無法保證對變數操作的原子性。

禁止指令重排序優化

指令重排序優化是指處理器為了提高運算單元的利用率,會對指令進行亂序執行優化,處理器能夠保證經過亂序的指令和原始順序的指令執行結果一致。禁止指令重排序的方式是新增記憶體屏障,重排序時不能把後面的指令重排序到記憶體屏障之前的位置。

如果在多執行緒環境中,指令重排序優化可能導致訪問共享資源出錯,一個常見的例子就是單例模式的雙鎖檢查式實現方式需要將例項引用新增 volatile 修飾。

public class Singleton {
    //如果不新增 volatile 修飾可能發生異常
    private volatile static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) {
            synchronized (Singleton.class) {
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

    private Singleton() {};
}

其中 instance = new Singleton(); 一句對應的位元組碼指令為:

NEW Singleton
DUP
INVOKESPECIAL Singleton.<init> ()V
PUTSTATIC Singleton.instance : LSingleton;

總共分為四個步驟:

  1. NEW 指令建立物件(此時還沒有執行建構函式,物件的所有成員變數都是預設的“零值”),將物件引用壓入運算元棧。
  2. DUP 指令將當前運算元棧頂的值拷貝一份(此時運算元棧頂的兩個元素是兩個相同的、值為前一步建立的物件的引用)。
  3. INVOKESPECIAL 指令會呼叫當前運算元棧頂的物件的 <init> 方法,該方法是根據空參的建構函式生成的。
  4. PUTSTATIC 指令將運算元棧頂的物件引用賦值給 Singleton 類的 instance 引用。

根據指令重排序的原則,三、四兩步之間亂序執行不會影響結果,那麼如果發生了指令重排、且兩個執行緒恰好按照如下步驟執行就會發生異常情況(篇幅所限不給出能發生指令重排序的程式碼,其實就是上方程式碼刪去 volatile ):

sequenceDiagram threadOne ->> threadOne : NEW Singleton threadOne ->> threadOne : DUP threadOne ->> Singleton.class : PUTSTATIC Singleton.instance : LSingleton; threadTwo ->> Singleton.class : 呼叫 getInstance() 函式 Singleton.class ->> threadTwo : Singleton.instance 引用不為 null,返回引用值 threadTwo ->> threadTwo : 使用單例物件 Note over threadTwo : 此時單例物件還未完全初始化,發生異常 threadOne ->> threadOne : INVOKESPECIAL

而如果 instance 使用 volatile 修飾,由於記憶體屏障的存在指令 INVOKESPECIALPUTSTATIC 之間就不會發生指令重排,確保了上述問題不會發生。另外請注意,Java 中雙鎖檢查式方式實現的單例在 Java 5 之後才能完全保證可用,此前版本依然會出現問題。

相關文章