1. 程式人生 > >十二、JAVA多執行緒:機器硬體CPU、Java記憶體模型

十二、JAVA多執行緒:機器硬體CPU、Java記憶體模型

  大家都知道,計算機在執行程式時,每條指令都是在CPU中執行的,而執行指令過程中,勢必涉及到資料的讀取和寫入。由於程式執行過程中的臨時資料是存放在主存(實體記憶體)當中的,這時就存在一個問題,由於CPU執行速度很快,而從記憶體讀取資料和向記憶體寫入資料的過程跟CPU執行指令的速度比起來要慢的多,因此如果任何時候對資料的操作都要通過和記憶體的互動來進行,會大大降低指令執行的速度。因此在CPU裡面就有了快取記憶體。

  也就是,當程式在執行過程中,會將運算需要的資料從主存複製一份到CPU的快取記憶體當中,那麼CPU進行計算時就可以直接從它的快取記憶體讀取資料和向其中寫入資料,當運算結束之後,再將快取記憶體中的資料重新整理到主存當中。舉個簡單的例子,比如下面的這段程式碼:

 

i = i + 1;

 

 

當執行緒執行這個語句時,會先從主存當中讀取i的值,然後複製一份到快取記憶體當中,然後CPU執行指令對i進行加1操作,然後將資料寫入快取記憶體,最後將快取記憶體中i最新的值重新整理到主存當中。

  這個程式碼在單執行緒中執行是沒有任何問題的,但是在多執行緒中執行就會有問題了。在多核CPU中,每條執行緒可能運行於不同的CPU中,因此每個執行緒執行時有自己的快取記憶體(對單核CPU來說,其實也會出現這種問題,只不過是以執行緒排程的形式來分別執行的)。本文我們以多核CPU為例。

  比如同時有2個執行緒執行這段程式碼,假如初始時i的值為0,那麼我們希望兩個執行緒執行完之後i的值變為2。但是事實會是這樣嗎?

  可能存在下面一種情況:初始時,兩個執行緒分別讀取i的值存入各自所在的CPU的快取記憶體當中,然後執行緒1進行加1操作,然後把i的最新值1寫入到記憶體。此時執行緒2的快取記憶體當中i的值還是0,進行加1操作之後,i的值為1,然後執行緒2把i的值寫入記憶體。

  最終結果i的值是1,而不是2。這就是著名的快取一致性問題。通常稱這種被多個執行緒訪問的變數為共享變數。

  也就是說,如果一個變數在多個CPU中都存在快取(一般在多執行緒程式設計時才會出現),那麼就可能存在快取不一致的問題。

  為了解決快取不一致性問題,通常來說有以下2種解決方法:

  1)通過在匯流排加LOCK#鎖的方式

  2)通過快取一致性協議

  這2種方式都是硬體層面上提供的方式。

  在早期的CPU當中,是通過在總線上加LOCK#鎖的形式來解決快取不一致的問題。因為CPU和其他部件進行通訊都是通過匯流排來進行的,如果對匯流排加LOCK#鎖的話,也就是說阻塞了其他CPU對其他部件訪問(如記憶體),從而使得只能有一個CPU能使用這個變數的記憶體。比如上面例子中 如果一個執行緒在執行 i = i +1,如果在執行這段程式碼的過程中,在總線上發出了LCOK#鎖的訊號,那麼只有等待這段程式碼完全執行完畢之後,其他CPU才能從變數i所在的記憶體讀取變數,然後進行相應的操作。這樣就解決了快取不一致的問題。

  但是上面的方式會有一個問題,由於在鎖住匯流排期間,其他CPU無法訪問記憶體,導致效率低下。

  所以就出現了快取一致性協議。最出名的就是Intel 的MESI協議,MESI協議保證了每個快取中使用的共享變數的副本是一致的。它核心的思想是:當CPU寫資料時,如果發現操作的變數是共享變數,即在其他CPU中也存在該變數的副本,會發出訊號通知其他CPU將該變數的快取行置為無效狀態,因此當其他CPU需要讀取這個變數時,發現自己快取中快取該變數的快取行是無效的,那麼它就會從記憶體重新讀取。

 

CPU Cahche模型:

 

 

JAVA記憶體模型

1、Java記憶體模型(JMM)

Java記憶體模型的主要目標:定義在虛擬機器中將變數儲存到記憶體和從記憶體中取出變數這樣的底層細節。

注意:上邊的變數指的是共享變數(例項欄位、靜態欄位、陣列物件元素),不包括執行緒私有變數(區域性變數、方法引數),因為私有變數不會存在競爭關係。

1.1、記憶體模型就是一張圖:

說明:

  • 所有共享變數存於主記憶體
  • 每一條執行緒都有自己的工作記憶體(就是上圖所說的本地記憶體)
  • 工作記憶體中儲存了被該執行緒使用到的變數的主記憶體副本

注意:

  • 執行緒對變數的操作都要在工作記憶體中進行,不能直接操作主記憶體
  • 不同的執行緒之間無法直接訪問對方的工作記憶體中的變數
  • 不同執行緒之間的變數的傳遞必須通過主記憶體

類比:(注意:主記憶體與工作記憶體只是一個概念,與堆疊記憶體沒有關係,下邊的類比只是幫助理解)

  • 主記憶體:對應於Java堆中的物件例項資料部分(注意:堆中還儲存了物件的其他資訊,eg.Mark Word、Klass Point和用於位元組對其補白的填充資料)
  • 工作記憶體:對應於棧中的部分割槽域

1.2、8條記憶體屏障指令:

下面只列出6條與之後內容相關的,其餘的檢視《深入理解Java虛擬機器》

  • lock:作用於主記憶體,把一個變數標識為一條執行緒獨佔的狀態
  • unlock:作用於主記憶體,把一個處於鎖定的變數解鎖

下邊四條是與volatile實現記憶體可見性直接相關的四條(store、write、read、load)

  • store:把工作記憶體中的變數的值傳送到主記憶體中
  • write:把store操作從工作記憶體中得到的變數值放入到主記憶體的變數中
  • read:把一個變數的值從主記憶體中傳輸到執行緒的工作記憶體
  • load:把read操作從主記憶體中獲取到的變數值放入工作記憶體的變數中去

注意:

  • 一個變數在同一時刻只允許一條執行緒對其進行lock操作
  • lock操作會將該變數在所有執行緒工作記憶體中的變數副本清空,否則就起不到鎖的作用了
  • lock操作可被同一條執行緒多次進行,lock幾次,就要unlock幾次(可重入鎖)
  • unlock之前必須先執行store-write
  • store-write必須成對出現(工作記憶體-->主記憶體)
  • read-load必須成對出現(主記憶體-->工作記憶體)

 

2、變數對所有執行緒的可見性

可見性:執行緒1對共享變數的修改能及時被執行緒2看到

2.1、共享變數不可見的原因

  • 共享變數更新後的值沒有在工作記憶體和主記憶體之間及時更新
  • 執行緒交錯執行
  • 指令重排序結合線程交錯執行

2.2、實現共享變數及時更新的措施

執行緒1修改過共享變數後,將共享變數刷到主記憶體,然後,執行緒2從主記憶體讀取該共享變數,將該共享變數載入到工作記憶體中

注意:在短時間內的高併發情況下,如果發生下列三種情況,則執行緒2就讀不到執行緒1修改過的最新的值了,

  • 可能執行緒1根本來不及將修改過後的共享變數刷到主記憶體(這個時間非常短,但是還是有)的時候,執行緒2就已經讀取了原有的主記憶體變數到其工作記憶體中。
  • 可能執行緒1雖然將修改過後的值刷到了主記憶體中,但是執行緒2的工作記憶體中的變數副本還沒來得及從CPU重新整理回來,所以執行緒2讀取到的還是原來的工作記憶體中的變數副本
  • 可能執行緒1根本來不及將修改過後的共享變數刷到主記憶體的時候,同時,執行緒2的工作記憶體中的變數副本還沒來得及從CPU重新整理回來

注意:工作記憶體中的變數副本在使用之後,不會立刻消失掉,會一直存在,這樣其值也一直不變,直到對其進行寫操作或資料從CPU中重新整理回來(類比volatile-read的作用)。

2.3、指令重排序:程式碼書寫順序與實際執行順序不同(編譯器或處理器為提高程式效能做的優化)

 


 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

參考連線:

https://www.cnblogs.com/java-zhao/p/5124725.html

《Java高併發程式設計詳解:多執行緒與架構設計》 --汪文君