[Java虛擬機器]Java記憶體模型與執行緒

陶程發表於2016-03-21

深入理解Java虛擬機器讀書筆記


第12章

主記憶體和工作記憶體

java記憶體模型的主要目標是定義程式中各個變數的訪問規則,即在虛擬機器中將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節。為了獲得較好的執行效能,Java記憶體模型咩有並沒有限制執行引擎使用處理器的特定暫存器或快取來和主記憶體進行互動,也沒有限制即時編譯器調整程式碼執行順序這類權利。

Java記憶體模型規定了所有的變數都儲存在主記憶體中(Main Memory)中(此處的主記憶體和介紹物理硬體時的主記憶體名字一樣,兩者也可以互相類比,但此處僅是虛擬機器記憶體的一部分)。每條執行緒還有自己的工作記憶體(Working Memory,可與前面所講的處理器快取記憶體類比),執行緒的工作記憶體中儲存了該執行緒使用到的變數的主記憶體副本拷貝,執行緒對變數的所有操作(讀取,賦值等)都必須在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。不同的執行緒也無法直接訪問對方工作記憶體中的變數,執行緒間變數的傳遞需要通過主記憶體來完成,執行緒、主記憶體、工作記憶體的互動關係如圖:

這裡寫圖片描述

記憶體間互動操作

關於主記憶體和工作記憶體之間具體的互動協議,即一個遍歷那個如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步回主記憶體之類的實現細節,Java記憶體模型中定義了一下八種操作來完成:

  • lock(鎖定):作用於主記憶體的變數,它把一個變數標識為一條執行緒獨佔的狀態。
  • unlock(解鎖):作用於主記憶體的變數,它把一個處於鎖定狀態的變數釋放出來,釋放後的變數才可以被其他執行緒鎖定。
  • read(讀取):作用於主記憶體的變數,它把一個變數的值從主記憶體傳輸到執行緒的工作記憶體中,以便隨後的load動作使用。
  • load(載入):作用於工作記憶體的變數,它把read操作從主記憶體中得到的變數值放入工作記憶體的變數副本中。
  • use(使用):作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳遞給執行引擎,每當虛擬機器遇到一個需要使用到變數的值的位元組碼指令時將會執行這個操作。
  • assign(賦值):作用於工作記憶體的變數,它把一個從執行引擎收到的值賦值給工作記憶體的變數,每當虛擬機器遇到一個給變數賦值的位元組碼時執行這個指令。
  • store(儲存):作用於工作記憶體的變數,它把工作記憶體中一個變數的值傳送到主記憶體中,以便隨後的write操作使用。
  • write(寫入):作用於主記憶體的變數,它把store操作從工作記憶體中得到的變數的值放入主記憶體的變數中。

如果要把一個變數從主記憶體複製到工作記憶體,那就要按順序執行read和load操作,如果要把變數從工作記憶體同步回主記憶體,就要按照順序地執行store和write操作。注意,Java記憶體模型只要求上述兩個操作必須按順序執行,而沒有保證必須是連續執行。Java記憶體模型規定了在執行上述八種基本操作時必須滿足時必須滿足如下規則:

  • 不允許read和load、store和write操作之一單獨出現
  • 不允許一個執行緒丟棄它的最近的assign操作
  • 不允許一個執行緒無原因的(沒有發生過任何assign操作)把資料從執行緒的工作記憶體同步到主記憶體中
  • 一個新的變數只能在主記憶體中誕生,不允許在工作記憶體中直接使用一個未被初始化(load或assign)的變數
  • 一個變數在同一時刻只允許一條執行緒對其進行lock操作,但lock操作可以被同一條執行緒重複執行多次,多次執行lock後,只有執行相同次數的unlock操作,變數才會被解鎖
  • 如果對一個變數執行lock操作,將會清空工作記憶體中此變數的值,在執行引擎使用這個變數前,需要重新執行load或assign操作初始化變數的值。
  • 如果一個變數事先沒有被lock鎖定,則不允許對它執行unlock操作;也不允許去unlock一個被其他執行緒鎖定住的變數。
  • 對一個變數執行unlock操作之前,必須先把此變數同步回主記憶體中(執行store和write)

對於volatile型變數的特殊規則

當一個變數被定義成volatile之後,它將具備兩種特性,第一是保證此變數對所有執行緒的可見性,這裡的可見性是指當一條執行緒修改了這個變數的值,新值對於其他的執行緒是可以立即得知的。

由於volatile變數只能保證可見性,在不符合以下兩條規則的運算場景中,我們仍然要通過加鎖(使用synchronized或java.util.concurrent中的原子類)來保證原子性

  • 運算結果並不依賴於變數的當前值,或者能夠確保只有單一的執行緒修改變數的值
  • 變數不需要與其他的狀態變數共同參與不變約束

volatile變數讀操作的效能消耗與普通變數幾乎沒有什麼差別,但是寫操作則可能會慢上一些,因為它需要在原生程式碼中插入許多記憶體屏障(Memory Barrier或Memory Fence)指令來保證處理器不發生亂序執行。不過即便如此,大多數場景volatile的總開銷仍然要比鎖來的低。

對於long和double型變數的特殊規則

Java記憶體模型要求對於lock、unlock、read、load、assign、use、store和write這八個操作都具有原子性,但是對於64位的資料型別(long和double),允許虛擬機器將沒有被volatile修飾的64位資料的讀寫操作劃分為兩次32位的操作來進行,即允許虛擬機器實現選擇可以不保證64位資料型別的load、store、read和write這四個操作的原子性。

原子性、可見性與有序性

Java語言提供了volatile和synchronized兩個關鍵字來保證執行緒之間操作的有序性,volatile關鍵字本生就包含了禁止指令重排序的語義,而synchronized則是由“一個變數在同一時刻只允許一條執行緒對其進行lock操作”這條規則獲得的,這個規則決定了持有同一個鎖的兩個同步程式碼塊只能序列的進入。

先行發生原則

先行發生是Java記憶體模型中定義的兩項操作之間的偏序關係,如果說操作A先行發生於操作B,其實就是說在發生操作B之前,操作A產生的影響能被操作B觀察到,“影響”包括修改了記憶體中共享變數的值、傳送了訊息、呼叫了方法等。

以下是Java記憶體模型下一些天然的先行發生關係,這些關係無需任何同步器協助就已經存在。

  • 程式次序規則(Program Order Rule):在一個執行緒內,按照程式程式碼順序,書寫在前面的操作先行發生於書寫在後面的操作。準確的說是控制流順序而不是程式程式碼順序,因為要考慮分支、迴圈等結構。
  • 管程鎖定規則(Monitor Lock Rule):一個unlock操作先行發生於後面對同一個鎖的lock操作。這裡必須強調的是同一個鎖,而後面是指事件上的先後順序。
  • volatile變數規則(Volatile Variable Rule):對一個volatile變數的寫操作先行發生於後面對這個變數的讀操作,這裡的後面同樣是指時間上的先後順序。
  • 執行緒啟動規則(Thread Start Rule):Thread物件的start()方法先行發生於此執行緒的每一個動作。
  • 執行緒終止規則(Thread Termination Rule):對執行緒所有操作都先行發生於對此執行緒的終止檢測,我們可以通過Thread.join() 方法結束、Thread.isAlive()的返回值等手段檢測到執行緒已經終止執行。
  • 執行緒中斷規則(Thread Interruption Rule):對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中斷事件的發生,可以通過Thread.interrupted()方法檢測到是否有中斷髮生。
  • 物件終結規則(Finalizer Rule):一個物件的初始化完成(建構函式執行結束)先行發生於它的finalize()方法的開始。
  • 傳遞性(Transitivity):如果操作A先行發生於操作B,操作B先行發生於操作C,那就可以得出操作A先行發生於操作C的結論。

執行緒的實現

執行緒是比程式更輕量級的排程執行單位,執行緒的引入,可以把一個程式的資源分配和執行排程分開,各個執行緒既可以共享程式資源(記憶體地址、檔案I/O等),又可以獨立排程(執行緒是CPU排程的最基本單位)。

實現執行緒主要有三種方式:

  1. 使用核心執行緒實現
  2. 使用使用者執行緒實現
  3. 混合實現
  4. Java執行緒的實現

Java執行緒排程

執行緒排程是指系統為執行緒分配處理器使用權的過程,主要排程方式有兩種,分別是協同式(Cooperative Threads-Scheduling)執行緒排程和搶佔式(Preemptive Threads-Scheduling)執行緒排程。

使用協同式排程的多執行緒系統,執行緒的執行時間由執行緒本身來控制,執行緒把自己的工作執行完了之後,要主動通知系統切換到另外一個執行緒上去。協同式多執行緒的最大好處是實現簡單,而且由於執行緒要把自己的事情幹完後才會進行執行緒切換,切換操作對自己是可知的,所以沒有什麼執行緒同步的問題。壞處是執行緒執行時間不可控制,甚至如果一個執行緒編寫有問題,一直不告知系統進行執行緒切換,那麼程式就會一直阻塞在那裡。

搶佔式排程的多執行緒系統,那麼每個執行緒將由系統來分配執行時間,執行緒的切換不由執行緒本身來決定(在Java中,Thread.yield()可以讓出執行時間,但是要獲取執行時間的話,執行緒本身是沒有什麼辦法的)。在這種實現執行緒排程的方式下,執行緒的執行時間是系統可控的,也不會有一個執行緒導致整個程式阻塞的問題,Java使用的執行緒排程方式就是搶佔式排程。

狀態轉換

Java定義了5種程式狀態

  • 新建(New):建立後尚未啟動的執行緒處於這種狀態。
  • 執行(Runnable):Runnable包括了作業系統狀態中的Running和Ready,也就是處於此狀態的執行緒有可能正在執行,也有可能正在等待CPU為它分配執行時間。
  • 無限期等待(Waiting):處於這種狀態的程式不會被分配CPU執行時間,它們要等待被其他執行緒顯示的喚醒。以下方法會讓執行緒陷入無限期的等待狀態:
    • 沒有設定Timeout引數的Object.wait()方法
    • 沒有設定Timeout引數的Thread.join()方法
    • LockSupport.park()方法
  • 限期等待(Timed Waiting):處於這種狀態的程式不會被分配CPU執行時間,不過無需等待被其他執行緒顯示的喚醒,在一定時間之後它們會由系統自動喚醒。以下方法會讓執行緒進入限期等待狀態:
    • Thread.sleep()方法
    • 設定了Timeout引數的Object.wait()方法
    • 設定了Timeout引數的Thread.join()方法
    • LockSupport.parkNanos()方法
    • LockSupport.parkUnitil()方法
  • 阻塞(Blocked):程式被阻塞了,阻塞狀態和等待狀態的區別是:阻塞狀態在等待著獲取到一個排它鎖,這個事件將在另一個執行緒放棄這個鎖的時候發生;而等待狀態則是在等待一段時間,或者喚醒動作的發生。在程式等待進入同步區域的時候,執行緒將進入這種狀態。
  • 結束(Terminated):已終止執行緒的執行緒狀態,執行緒已經結束執行。

相關文章