Java 多執行緒(六)之Java記憶體模型
1. 併發程式設計的兩個問題
在併發程式設計中, 需要處理兩個關鍵問題: 執行緒之間如何通訊及執行緒之間如何同步
通訊指的是執行緒之間是以何種機制來交換資訊, 在指令式程式設計中, 執行緒之間的通訊機制有兩種:共享記憶體和訊息傳遞。在共享記憶體的模型中, 執行緒之間共享程式的公共狀態, 通過讀寫記憶體中的公共狀態進行隱式通訊。在訊息傳遞的併發模型中, 執行緒之間沒有公共狀態, 執行緒之間必須通過傳送訊息顯示的進行通訊。
同步指的是程式中用於控制不同執行緒之間操作發生相對順序的機制。在共享記憶體的併發模型裡, 同步是顯示進行的。 程式設計師必須顯示的指定某個方法或某段程式碼需要線上程之間互斥。
Java 採用的是共享記憶體模型, Java執行緒之間的通訊總是隱式的進行, 整個通訊過程對程式設計師完全透明。
2 CPU 快取模型
2.1 CPU 和 主存
在計算機中, 所有的計算操作都是由 CPU 的暫存器來完成的。 CPU 指令的執行過程需要涉及資料的讀取和寫入操作。 CPU 通常能訪問的都是計算機的主記憶體(通常是 RAM)。
隨著製造工藝等的飛速發展, CPU 不斷的發展。 但主存的發展卻沒有多大的突破, 因此, 差距就越來越大。
因此, 一種新型別的更快的記憶體-快取,就出現了(速度越快越貴),用來彌補兩者之間的差距。
2.2 CPU Cache
目前, CPU快取模型如下所示
越靠近CPU, 速度越快。 其速度差異如下
CPU Cache 由多個 CPU Line 構成, CPU Line 被認為是最小的快取單位。
2.3 CPU如何通過 Cache 與 主記憶體互動
既然有了 CPU Cache, CPU 就不直接跟記憶體進行互動了。 在程式執行的過程中, 會將運算所需要的資料從主記憶體複製到 CPU Cache 中, 這樣就可以直接對 CPU Cache 進行讀取和寫入, 當運算結束之後, 在將結果重新整理到主記憶體中。
通過以上的方式, CPU的吞吐能力得到極大的提高。有了 CPU Cache 之後, 整體的 CPU 和 主記憶體的交換架構大致如下
在該架構中, 每個CPU的 CPU Cache 是自己本地的, 別的CPU無法訪問。
2.4 CPU 快取一致性問題
就如同我們在自己的程式中使用快取時一樣, CPU 引入了快取, 提高了訪問速度, 但也帶來了快取一致性的問題。
舉例
對於 i++ 這個操作, 需要以下幾個步驟
- 讀取主記憶體值 i 到 CPU Cache 中
- 對 i 進行自增操作
- 將結果寫回 CPU Cache 中
- 將資料重新整理到快取中
在單執行緒的情況下, 該操作是沒有任何問題的。 但是在多執行緒的情況下, 變數 i 會在多個執行緒的本地記憶體中都存在副本, 如果兩個執行緒都執行以上操作, 讀取到的值剛開始都為 0, 那麼在進行兩次自增操作之後, 主存中的值仍然為 1。 這就是快取一致性問題。
為了解決該問題, 聰明的前人發明了兩種方法
- 通過匯流排加鎖的方式
- 通過快取一致性協議
匯流排加鎖效率太低, 現在都使用的是快取一致性協議。
最出名的就是傳說中的 MESI(Modify, Exclusive, Shared, Invalid) 協議。
- Modify:當前CPU cache擁有最新資料(最新的cache line),其他CPU擁有失效資料(cache line的狀態是invalid),雖然當前CPU中的資料和主存是不一致的,但是以當前CPU的資料為準;
- Exclusive:只有當前CPU中有資料,其他CPU中沒有改資料,當前CPU的資料和主存中的資料是一致的;
- Shared:當前CPU和其他CPU中都有共同資料,並且和主存中的資料一致;
- Invalid:當前CPU中的資料失效,資料應該從主存中獲取,其他CPU中可能有資料也可能無資料,當前CPU中的資料和主存被認為是不一致的;
MESI 協議為每個 CPU Line 提供狀態, 並根據不同狀態的操作做出不同的響應。
在 MESI 協議中, 有如下操作
- Local Read(LR):讀本地cache中的資料
- Local Write(LW):將資料寫到本地cache
- Remote Read(RR):其他核心發生read
- Remote Write(RW):其他核心發生write
3 Java記憶體模型(JMM)
3.1 Java記憶體模型(JMM)
Java 虛擬機器規範提供了一種Java 記憶體模型來遮蔽掉各種硬體和作業系統的記憶體訪問差異, 以實現讓 Java 程式在各種平臺下都能達到一致性的記憶體訪問效果。
從架構上看, 跟之前提到的物理硬體記憶體模型有很大的相似度, 但是差別挺大。
- 主記憶體: 所有的變數都儲存在主記憶體中(類似於物理硬體的主記憶體, 不過該記憶體只是虛擬機器記憶體的一部分)
- 工作記憶體: 工作記憶體中儲存了被該執行緒用到的變數的主記憶體副本拷貝(取決於虛擬機器的實現, 可能複製的只是物件的引用, 物件的某個欄位等), 執行緒對變數的操作(讀寫等)都必須在工作記憶體中執行, 而不能直接讀寫主記憶體中的變數
不同的執行緒之間無法訪問對方工作記憶體中的變數, 執行緒之間變數的傳遞必須通過主記憶體進行。
3.2 記憶體間互動操作
變數如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步回主記憶體, 由以下8種原子操作來完成。
- lock: 作用於主記憶體變數, 它把一個變數標識為一條執行緒獨佔的狀態
- unlock: 作用於主記憶體的變數, 它把一個處於加鎖的變數釋放出來, 釋放後的變數才可以被其他執行緒鎖定
- read: 作用於主記憶體變數, 它把一個變數的值從主記憶體傳輸到執行緒的工作記憶體, 一般隨後的 load 操作
- load: 作用於工作記憶體的變數, 它把 read 操作從主記憶體巨集得到的值寫入工作記憶體的變數副本中
- use: 作用於工作記憶體的變數, 把工作記憶體的變數傳遞給執行引擎, 每當虛擬機器遇到一個需要使用到變數值的位元組碼指令時就會執行該操作
- assign: 作用於工作區記憶體的變數, 它把執行引擎接收到的值賦值給工作記憶體的變數, 當虛擬機器遇到給一個給變數賦值的指令時就會執行這個操作
- store: 作用於工作記憶體變數, 把工作記憶體中變數的值傳送到主記憶體中, 以便隨後的 write 操作
- write: 作用於主記憶體變數, 它把 store 操作從工作記憶體中得到的變數值放入主記憶體變數中
Java模型還對這些操作進行了更加細緻的限定, 加上 volatile 的一些特殊規定, 就可以確定 Java 程式中哪些記憶體訪問操作在併發下是安全的。
3.3 重排序
重排序是編譯器和處理器為了優化程式效能而對指令序列進行重重排序的一種手段。重排序的目的是在不改變程式執行結果的情況下, 儘可能提高並行度。 有以下幾種重排序:
- 編譯器優化的重排序。 在不改變單執行緒程式語義的前提下,可以重新安排語句的執行順序。
- 指令級並行的重排序。現在處理器採用了指令級並行技術(ILP)來將多條指令重疊執行。 如果不存在資料依賴性, 處理器可以改變語句對應機器指令的執行順序。
- 記憶體系統的重排序。 由於處理器使用快取和讀寫緩衝區, 這使得記載和儲存操作看上去可能是亂序執行的。
從原始碼到最終實際執行的指令序列, 經歷的3種重排序
1屬於編譯器重排序, 2和3屬於處理器重排序。
3.3.1 資料依賴性
如果兩個操作訪問同一個變數, 且這兩個操作中有一個為寫操作, 此時這兩個操作之間就存在資料依賴性
名稱 | 程式碼示例 | 說明 |
---|---|---|
寫後讀 | a=1; b=a; |
寫一個變數之後, 在讀這個位置 |
寫後寫 | a=1; a=2; |
寫一個變數之後, 再寫一個變數 |
讀後寫 | a=b; b=1; |
讀一個變數之後, 再寫這個變數 |
如果對以上的操作並行重排序, 則會改變程式執行的結果。因此, 編譯器和處理器在重排序時, 會遵循資料依賴性, 編譯器和處理器不會改變存在資料依賴性的兩個操作的執行順序。
此處說的僅僅是單執行緒的資料依賴性, 多執行緒的不考慮。
3.3.2 as-if-serial
即不管程式怎麼重排序, (單執行緒)程式的執行結果不能被改變。 編譯器、runtime和處理器必須遵循 as-if-serial 語義。
double pi=3.14; // A
double r=1.0; // B
double area = pi*r*r; // C
在此程式碼中, A和B都跟C存在資料依賴性, 但是 A 和 B 之間沒有依賴性。 因此, C 不能被排到 A或B 之前。 但對 A 和 B, 這兩者可以隨意排序。
3.3.3 程式順序規則
在以上圓形面積的計算中, 有如下三個 happens-before 關係
1) A happens-before B
2) B happens-before C
3) A happens-before C
其中第三條是根據前面兩條傳遞性推倒出來的。
A happens-before B 並不是要求 A 一定要在 B 之前執行, 而是要求A的執行結果對B可見。 但這裡的A的執行結果不需要對B可見, 在這種情況下, JMM 會認為這種重排序是合法的, JMM 允許此類重排序。
3.4 happens-before原則
happens-before 是用來闡述操作之間的可見性。 即在JMM中, 如果一個操作執行的結果需要對另一個操作可見, 則這兩個操作之間必須存在 happens-before 關係。
happens-before 規則
- 程式順序規則(單執行緒): 一個執行緒中的每個操作, happens-before 於該執行緒中的後續操作。
- 監視器規則: 對一個鎖的解鎖, happens-before 於對該鎖的加鎖
- volatile規則:對一個 volatile 域的寫, happens-before 於隨後對這個域的讀
- 傳遞性: 如果 A happens-before B, 且 B happens-before C, 則 A happens-before C。
- 執行緒啟動規則: 如果執行緒A執行操作ThreadB.start()(執行緒B啟動), 那麼A執行緒的 Thread.start() 操作 happens-before 於執行緒B的任意操作。
- 執行緒終止規則: 如果執行緒 A 執行操作 ThreadB.join() 併成功返回, 那麼程式設計B中的任意操作 happens-before 於執行緒A從ThreadB.join()操作成功返回。
- 程式中斷規則: 對執行緒interrupt()的方法的呼叫 happens-before 於被中斷執行緒程式碼檢測到中斷事件的發生。
- 物件終結規則: 一個物件的初始化完成, happens-before 於發生它的 finalize() 方法的開始。
3.4 原子性、可見性和有序性
JMM 是圍繞著在併發過程中如何處理原子性、可見性和有序性這個三個特徵來建立的。
3.4.1 原子性
Java 中對以上的八種操作是原子性的。 對應起來就是對基本資料型別的讀取/賦值操作都是原子性的, 引用型別的讀取和賦值也是如此。
舉幾個例子
賦值操作
a=10
該操作需要使用 assign 操作, 可能需要 store 和 write 操作。 這些過程都是原子操作。
可有通過
- synchronized關鍵字
- JUC所提供的顯式鎖Lock
來實現原子性
3.4.1 可見性
指的是一個執行緒中修改了共享變數, 其他的執行緒就能夠立即知道這個修改。 JMM 可以通過以下三種方式來保證可見性
- volatile關鍵字
- synchronized關鍵字
- JUC所提供的顯式鎖Lock
3.4.2 有序性
Java 中天然的有序性可以概括總結為一句話:如果本執行緒內觀察, 所有的操作都是有序的; 如果在一個執行緒內觀察另一個執行緒, 所有的操作都是無序的。 前半句指的是 as-if-serial 語義, 後半句指的是“指令重排”和“執行緒記憶體與主記憶體同步延遲”的執行緒。
有序性的保證:
- volatile: 禁止指令重排
- synchronized: 一個變數再同一時刻, 只允許一條執行緒對其進行 lock 操作。
- Lock: 同 synchronized