1. 程式人生 > 實用技巧 >深入理解java虛擬機器筆記Chapter12

深入理解java虛擬機器筆記Chapter12

(本節筆記的執行緒收錄在執行緒/併發相關的筆記中,未在此處提及)

Java記憶體模型

Java 記憶體模型主要由以下三部分構成:1 個主記憶體、n 個執行緒、n 個工作記憶體(與執行緒一一對應)

主記憶體與工作記憶體

Java記憶體模型的主要目標是定義程式中各個變數的訪問規則 – 虛擬機器中將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節。此處的變數(Variables)包括了例項欄位、靜態欄位和構成陣列物件的元素,但不包括區域性變數與方法引數,因為後者是執行緒私有的,不會被共享,自然就不會存在競爭問題。

  • Java記憶體模型規定了所有的變數都儲存在主記憶體(Main Memory,類比實體記憶體)。
    • 執行緒間變數值的傳遞均需要通過主記憶體來完成
  • 每條執行緒還有自己的工作記憶體(Working Memory,類比處理器快取記憶體)
    • 執行緒的工作記憶體中儲存了被該執行緒使用到的變數的主記憶體副本拷貝
    • 執行緒對變數的所有操作(讀取、賦值等)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數
    • 不同的執行緒之間也無法直接訪問對方工作記憶體中的變數

粗略來看,主記憶體主要對應於Java堆中的物件例項資料部分,而工作記憶體則對應於虛擬機器棧中的部分割槽域。 從更低層次上說,主記憶體就直接對應於物理硬體的記憶體,而為了獲取更好的執行速度,虛擬機器(甚至是硬體系統本身的優化措施)可能會讓工作記憶體優先儲存於暫存器和快取記憶體中,因為程式執行時主要訪問讀寫的是工作記憶體。

一個變數從主記憶體拷貝到工作記憶體,再從工作記憶體同步回主記憶體的流程為:

|主記憶體| -> read -> load -> |工作記憶體| -> use -> |Java執行緒| -> assign -> |工作記憶體| -> store -> write -> |主記憶體|

Java 記憶體模型中的 8 個原子操作

  • lock:作用於主記憶體,把一個變數標識為一個執行緒獨佔狀態。
  • unlock:作用於主記憶體,釋放一個處於鎖定狀態的變數。
  • read:作用於主記憶體,把一個變數的值從主記憶體傳輸到執行緒工作記憶體中,供之後的 load 操作使用。
  • load:作用於工作記憶體,把 read 操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。
  • use:作用於工作記憶體,把工作記憶體中的一個變數傳遞給執行引擎,虛擬機器遇到使用變數值的位元組碼指令時會執行。
  • assign:作用於工作記憶體,把一個從執行引擎得到的值賦給工作記憶體的變數,虛擬機器遇到給變數賦值的位元組碼指令時會執行。
  • store:作用於工作記憶體,把工作記憶體中的一個變數傳送到主記憶體中,供之後的 write 操作使用。
  • write:作用於主記憶體,把 store 操作從工作記憶體中得到的變數值存入主記憶體的變數中。

8 個原子操作的執行規則

有關變數拷貝過程的規則

  • 不允許 read 和 load,store 和 write 單獨出現
  • 不允許執行緒丟棄它最近的 assign 操作,即工作記憶體變化之後必須把該變化同步回主記憶體中
  • 不允許一個執行緒在沒有 assign 的情況下將工作記憶體同步回主記憶體中,也就是說,只有虛擬機器遇到變數賦值的位元組碼時才會將工作記憶體同步回主記憶體
  • 新的變數只能從主記憶體中誕生,即不能在工作記憶體中使用未被 load 和 assign 的變數,一個變數在 use 和 store 前一定先經過了 load 和 assign

有關加鎖的規則

  • 一個變數在同一時刻只允許一個執行緒對其進行 lock 操作,但是可以被一個執行緒多次 lock(鎖的可重入)
  • 對一個變數進行 lock 操作會清空這個變數在工作記憶體中的值,然後在執行引擎使用這個變數時,需要通過 assign 或 load 重新對這個變數進行初始化
  • 對一個變數執行 unlock 前,必須將該變數同步回主記憶體中,即執行 store 和 write 操作
  • 一個變數沒有被 lock,就不能被 unlock,也不能去 unlock一個被其他執行緒 lock 的變數

對於volatile型變數的特殊規則(預)

關鍵字volatile可以說是Java虛擬機器提供的最輕量級的同步機制;Java記憶體模型對volatile專門定義了一些特殊的訪問規則,一個變數定義為volatile之後,它將具備兩種特性:可見性,禁止指令重排序優化。

接下來先介紹先行發生原則(Happens-Before 規則),在返回來學習volatile

Happens-Before 規則

此處,先提一下可見性問題有序性問題

通過上圖可以發現,Java 執行緒只能操作自己的工作記憶體,其對變數的所有操作(讀取、賦值等)都必須在工作記憶體中進行,不能直接讀寫主記憶體中的變數。這就有可能會導致可見性問題:

  • 因為對於主記憶體中的變數 A,其在不同的執行緒的工作記憶體中可能存在不同的副本 A1、A2、A3。
  • 不同執行緒的 read 和 load、store 和 write 不一定是連續執行的,中間可以插入其他命令。Java 只能保證 read 和 load、store 和 write 的執行對於一個執行緒而言是連續的,但是並不保證不同執行緒的 read 和 load、store 和 write 的執行是連續的,如下圖:

假設有兩個執行緒 A 和 B,其中執行緒 A 在寫入共享變數,執行緒 B 要讀取共享變數,我們想讓執行緒 A 先完成寫入,執行緒 B 再完成讀取。此時即便我們是按照 “執行緒 A 寫入 -> 執行緒 B 讀取” 的順序開始執行的,真實的執行順序也可能是這樣的:storeA -> readB -> writeA -> loadB,這將導致執行緒 B 讀取的是變數的舊值,而非執行緒 A 修改過的新值。也就是說,執行緒 A 修改變數的執行先於執行緒 B 操作了,但這個操作對於執行緒 B 而言依舊是不可見的。

那麼如何解決這個問題呢?通過上述的分析可以發現,可見性問題的本身,也是由於不同執行緒之間的執行順序得不到保證導致的,因此我們也可以將它的解決和有序性合併,即對 Java 一些指令的操作順序進行限制,這樣既保證了有序性,有解決了可見性。

於是乎,Java 給出了一些命令執行的順序規範,也就是大名鼎鼎 Happens-Before 規則。

根據語義,Happens-Before,就是即便是對於不同的執行緒,前面的操作也應該發生在後面操作的前面,也就是說,Happens-Before 規則保證:前面的操作的結果對後面的操作一定是可見的。

Happens-Before 規則本質上是一種順序約束規範,用來約束編譯器的優化行為。就是說,為了執行效率,我們允許編譯器的優化行為,但是為了保證程式執行的正確性,我們要求編譯器優化後需要滿足 Happens-Before 規則。

根據類別,我們將 Happens-Before 規則分為了以下 4 類:

  • 操作的順序:
    • 程式順序規則: 如果程式碼中操作 A 在操作 B 之前,那麼同一個執行緒中 A 操作一定在 B 操作前執行,即在本執行緒內觀察,所有操作都是有序的。
    • 傳遞性: 在同一個執行緒中,如果 A 先於 B ,B 先於 C 那麼 A 必然先於 C。
  • 鎖和 volatile:
    • 監視器鎖規則: 監視器鎖的解鎖操作必須在同一個監視器鎖的加鎖操作前執行。
    • volatile 變數規則: 對 volatile 變數的寫操作必須在對該變數的讀操作前執行,保證時刻讀取到這個變數的最新值。
  • 執行緒和中斷:
    • 執行緒啟動規則: Thread#start() 方法一定先於該執行緒中執行的操作。
    • 執行緒結束規則: 執行緒的所有操作先於執行緒的終結。
    • 中斷規則: 假設有執行緒 A,其他執行緒 interrupt A 的操作先於檢測 A 執行緒是否中斷的操作,即對一個執行緒的 interrupt() 操作和 interrupted() 等檢測中斷的操作同時發生,那麼 interrupt() 先執行。
  • 物件生命週期相關:
    • 終結器規則: 物件的建構函式執行先於 finalize() 方法。

volatile 的實現原理

Happens-Before 規則中要求,對 volatile 變數的寫操作必須在對該變數的讀操作前執行,這個規則聽起來很容易,那實際上是如何實現的呢?解決方法分兩步:

  1. 保證動作發生;
  2. 保證動作按正確的順序發生。

保證動作發生

首先,在對 volatile 變數進行讀取和寫入操作,必須去主記憶體拉取最新值,或是將最新值更新進主記憶體,不能只更新進工作記憶體而不將操作同步進主記憶體,即在執行 read、load、use、assign、store、write 操作時:

  • use 操作必須與 load、read 操作同時出現,不能只 use,不 load、read。
    • use <- load <- read
  • assign 操作必須與 store、write 操作同時出現,不能只 assign,不 store、write。
    • assign -> store -> write

此時,我們已經保證了將變數的最新值時刻同步進主記憶體的動作發生了,接下來,我們需要保證這個動作,對於不同的執行緒,滿足 volatile 變數的 Happens-Before 規則:對變數的寫操作必須在對該變數的讀操作前執行。

保證動作按正確的順序發生

其實,導致這個執行順序問題的主要原因在於,這個讀寫 volatile 變數的操作不是一氣呵成的,它不是原子的!無論是讀還是寫,它都分成了 3 個命令(use <- load <- read 或 assign -> store -> write),這就導致了,你能保證 assignA 發生在 useB 之前,但你根本不能保證 writeA 也發生在 useB 之前,而如果 writeA 不發生在 useB 之前,主記憶體中的資料就是舊的,執行緒 B 就讀不到最新值!

所以,我覺得這句話應當換一個理解方式:假設我是一個寫操作,你發生在我之前的讀操作可以隨便執行,各個分解命令先於我還是後於我都無所謂。但是,你發生在我之後的讀操作,必須等我把 3 個命令都執行完,才能執行!不許偷偷把一些指令排到我的最後一個指令的前面去。 這才是 “對變數的寫操作必須在對該變數的讀操作前執行” 的本質。

volatile 的真實實現

那麼 Java 是如何利用現有的工具,實現了上述的兩個效果的呢?

答案是:它巧妙的利用了 lock 操作的特點,通過 觀察對 volatile 變數的賦值操作的反編譯程式碼,我們發現,在執行了變數賦值操作之後,額外加了一行:

lock addl $0x0,(%esp)

這一句的意思是:給 ESP 暫存器 +0,這是一個無意義的空操作,重點在 lock 上:

  • 保證動作發生:
    • lock 指令會將當前 CPU 的 Cache 寫入記憶體,並無效化其他 CPU 的 Cache,相當於在執行了 assign 後,又進行了 store -> write;
    • 這使得其他 CPU 可以立即看見 volatile 變數的修改,因為其他 CPU 在讀取 volatile 變數時,會發現自己的快取過期了,於是會去主記憶體中拉取最新的 volatile 變數值,也就被迫在 use 前進行一次 read -> load。
  • 保證動作順序:
    • lock 的存在相當於一個記憶體屏障,使得在重排序時,不能把後面的指令排在記憶體屏障之前。