這兩個概念估計有不少人會混淆,它們都可以說是 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
怎麼理解 happens-before 呢?如果按字面意思,比如第二個規則,線程(不管是不是同一個)的解鎖動作發生在鎖定之前?這明顯不對。happens-before 也是爲了保證可見性,比如那個解鎖和加鎖的動作,可以這樣理解,線程1釋放鎖退出同步塊,線程2加鎖進入同步塊,那麼線程2就能看見線程1對共享對象修改的結果。
Java 提供了幾種語言結構,包括 volatile, final 和 synchronized, 它們旨在幫助程序員向編譯器描述程序的併發要求,其中:
- volatile - 保證可見性和有序性
- synchronized - 保證可見性和有序性; 通過管程(Monitor)保證一組動作的原子性
- final - 通過禁止在構造函數初始化和給 final 字段賦值這兩個動作的重排序,保證可見性(如果 this 引用逃逸就不好說可見性了)
編譯器在遇到這些關鍵字時,會插入相應的內存屏障,保證語義的正確性。
有一點需要注意的是,synchronized 不保證同步塊內的代碼禁止重排序,因爲它通過鎖保證同一時刻只有一個線程訪問同步塊(或臨界區),也就是說同步塊的代碼只需滿足 as-if-serial 語義 - 只要單線程的執行結果不改變,可以進行重排序。
所以說,Java 內存模型描述的是多線程對共享內存修改後彼此之間的可見性,另外,還確保正確同步的 Java 代碼可以在不同體系結構的處理器上正確運行。
小結
它們之間的關係可以這樣來個總結,實現一個 JVM 要滿足內存結構描述的組成部分,設計如何執行多個線程的時候,要滿足Java 內存模型約定的多線程語義。