1. 程式人生 > >Java 記憶體模型和 JVM 記憶體結構真不是一回事

Java 記憶體模型和 JVM 記憶體結構真不是一回事

這兩個概念估計有不少人會混淆,它們都可以說是 JVM 規範的一部分,但真不是一回事!它們描述和解決的是不同問題,簡單來說,

  • Java 記憶體模型,描述的是多執行緒允許的行為
  • JVM 記憶體結構,描述的是執行緒執行所設計的記憶體空間

JVM 是什麼呢?它遮蔽了底層架構的差異性,是 Java 跨平臺的依據,也是每個 Java 程式設計師必須瞭解的一部分。

JVM 體系結構

Java Virtual Machine(JVM) 是一種抽象的計算機,基於堆疊架構,它有自己的指令集和記憶體管理。它載入 class 檔案,分析、解釋並執行位元組碼。基本結構如下:

如上圖所示,JVM 主要分為三個子系統:類載入器、執行時資料區和執行引擎。

類載入器子系統

它主要功能是處理類的動態載入,還有連結,並且在第一次引用類時進行初始化。

Loading - 載入,顧名思義,用於載入類,它有三種類載入器,根據雙親委託模型,從不同路徑進行載入:

  • Bootstrap ClassLoader - 載入 rt.jar 核心類庫,是優先順序最高的載入器
  • Extension ClassLoader - 負責載入 jre\lib\ext 資料夾中的類
  • Application ClassLoader -負責載入 CLASSPATH 指定的類庫

Linking - 連結,動態連結到執行時所需的資源,分為三步:

  • Verify - 驗證:驗證生成的位元組碼是否正確
  • Prepare - 準備:為所有靜態變數,分配記憶體並賦予預設值
  • Resolve - 解析:將 class 檔案常量池中所有對記憶體的符號引用,替換成到方法區的直接引用

Initialization - 類初始化,類載入的最後階段,這裡對靜態變數進行賦值,並執行靜態塊。(注意區分物件初始化)

執行時資料區

它約定了在執行時程式程式碼的資料比如變數、引數等等的儲存位置,主要包含以下幾部分:

  • PC 暫存器(程式計數器):儲存正在執行的位元組碼指令的地址
  • 棧:在方法呼叫時,建立一個叫棧幀的資料結構,用於儲存區域性變數和部分過程的結果,棧幀由以下幾部分組成:
    • 區域性變量表:儲存方法呼叫時傳遞的引數,從0開始儲存this、方法引數、區域性變數
    • 運算元棧:執行中間操作,儲存從區域性變量表或物件例項欄位複製的常量或變數值,以及操作結果,另外,還用來準備被呼叫方法的引數和接受方法呼叫的返回結果
    • 動態連結:一個指向執行時常量池的引用,將 class 檔案中的符號引用(描述一個方法呼叫了其他方法或訪問成員變數)轉為直接引用
    • 方法返回地址:方法正常退出或丟擲異常退出,返回方法被呼叫的位置
  • 堆:儲存類例項物件和陣列物件,垃圾回收的主要區域
  • 方法區:也被稱為元空間,還有個別名 non-heap(非堆),使用本地記憶體儲存 class meta-data 元資料(執行時常量池,欄位和方法的資料,建構函式和方法的位元組碼等),在 JDK 8 中,把 interned String 和類靜態變數移動到了 Java 堆
  • 執行時常量池:儲存類或介面中的數值字面量,字串字面量以及所有方法或欄位的引用,基本上涉及到方法或欄位,JVM 就會在執行時常量池中搜索其具體的記憶體地址
  • 本地方法棧:與 JVM 棧類似,只不過服務於 Native 方法

執行引擎

執行時資料區儲存著要執行的位元組碼,執行引擎將會讀取並逐個執行。

Interpreter - 直譯器,它對位元組碼的解釋很快,但執行慢,有個缺點是,當方法被多次呼叫時,每次都需要重新解釋。

JIT Compiler- JIT編譯器, 解決了直譯器的缺點,仍使用直譯器來轉換位元組程式碼,但發現有程式碼重複執行時,會使用 JIT 編譯器,將整個位元組碼編譯成原生代碼,將原生代碼用於重複呼叫,從而提高系統的效能,有以下幾部分組成:

  • 中間程式碼生成器 - 生成中間程式碼
  • 程式碼優化器 - 負責優化上面生成的中間程式碼
  • 目的碼生成器 - 負責生成機器程式碼或原生代碼
  • Profiler - 一個特殊元件,負責查詢熱點,判斷該方法是否被多次呼叫

Garbage Collector- 垃圾收集器,收集和刪除未引用的物件。

另外,還包括執行引擎所需的本地庫(Native Method Libraries)和與其互動的 JNI 介面(Java Native Interface)

現在來看下 Java 記憶體模型和 JVM 記憶體結構有何不同。

JVM 記憶體結構

常說的 JVM 記憶體結構指的就是上文提交到執行時資料區,其中方法區被執行緒共享,程式計數器執行時常量池被執行緒獨享。

它描述的是,在執行時,位元組碼和程式碼資料儲存的位置。

記憶體模型

先拋開 Java 不說,先來看下記憶體模型是什麼?維基百科中的定義:

In computing, a memory model describes the interactions of threads through memory and their shared use of the data.

意思就是,在計算中,記憶體模型描述了多執行緒如何正確的通過記憶體進行互動和使用共享資料。換句話說,記憶體模型約束了處理器對記憶體的讀寫。

CPU 和記憶體之間通常會存在一層或多層快取記憶體,這對單處理器可能沒問題,但在多處理器系統中,可能就會出現快取一致性問題,也就是當兩個處理器(執行緒)同時讀取相同記憶體位置會發生什麼?什麼情況下會看到相同的值?

快取一致性問題,在併發程式設計中,又被稱作可見性問題。記憶體模型在處理器級別,為處理器彼此之間對記憶體寫入結果的可見性,定義了充分必要條件:

  • 強記憶體模型,一般說的是順序一致性,所有記憶體操作存在一個全序關係,每個操作都是原子的且立即對所有處理器可見
  • 弱記憶體模型,不限制處理器的記憶體操作順序,而使用特殊指令重新整理或者使本地快取失效,以便看到其他處理器的寫入,或使此處理器的寫入對其他處理器可見,這些特殊指令被稱為記憶體屏障

大多數處理器不會限制記憶體操作的順序,多執行緒在執行時可能會出現讓人困惑和違背直覺的結果。這是因為 CPU 為了充分利用不同型別儲存器(暫存器、快取記憶體、主存)的匯流排頻寬,會將記憶體操作重新排序,以無序執行,這個動作稱為記憶體排序或指令重排序。

重排序,也被稱為編譯器優化和處理器優化,因為它既可以發生在編譯期間,也可以發生在 CPU 執行時。為了保證多執行緒的有序性,需要使用記憶體屏障禁止重排序。

所以說,記憶體模型就是在硬體層面描述了使用記憶體屏障(重新整理快取或禁用指令重排序)解決多執行緒程式設計中的可見性和有序性的問題。

Java 記憶體模型

Java 記憶體模型(下文簡稱 JMM)就是在底層處理器記憶體模型的基礎上,定義自己的多執行緒語義。它明確指定了一組排序規則,來保證執行緒間的可見性。

這一組規則被稱為 Happens-Before, JMM 規定,要想保證 B 操作能夠看到 A 操作的結果(無論它們是否在同一個執行緒),那麼 A 和 B 之間必須滿足 Happens-Before 關係:

  • 單執行緒規則:一個執行緒中的每個動作都 happens-before 該執行緒中後續的每個動作
  • 監視器鎖定規則:監聽器的解鎖動作 happens-before 後續對這個監聽器的鎖定動作
  • volatile 變數規則:對 volatile 欄位的寫入動作 happens-before 後續對這個欄位的每個讀取動作
  • 執行緒 start 規則:執行緒 start() 方法的執行 happens-before 一個啟動執行緒內的任意動作
  • 執行緒 join 規則:一個執行緒內的所有動作 happens-before 任意其他執行緒在該執行緒 join() 成功返回之前
  • 傳遞性:如果 A happens-before B, 且 B happens-before C, 那麼 A happens-before C

Java 提供了幾種語言結構,包括 volatile, finalsynchronized, 它們旨在幫助程式設計師向編譯器描述程式的併發要求,其中:

  • volatile - 保證可見性和有序性
  • synchronized - 保證可見性和有序性; 通過管程(Monitor)保證一組動作的原子性
  • final - 通過禁止在建構函式初始化和給 final 欄位賦值這兩個動作的重排序,保證可見性(如果 this 引用逃逸就不好說可見性了)

編譯器在遇到這些關鍵字時,會插入相應的記憶體屏障,保證語義的正確性。

所以說,Java 記憶體模型描述的是多執行緒對共享記憶體修改後彼此之間的可見性,另外,還確保正確同步的 Java 程式碼可以在不同體系結構的處理器上正確執行。

小結

它們之間的關係可以這樣來個總結,實現一個 JVM 要滿足記憶體結構描述的組成部分,設計如何執行多個執行緒的時候,要滿足Java 記憶體模型約定的多執行緒語義