@
目錄
- 1. 并發程式設計的兩個問題
- 2 CPU 緩存模型
- 2.1 CPU 和 主存
- 2.2 CPU Cache
- 2.3 CPU如何通過 Cache 與 主記憶體互動
- 2.4 CPU 緩存一緻性問題
- 3 Java記憶體模型(JMM)
- 3.1 Java記憶體模型(JMM)
- 3.2 記憶體間互動操作
- 3.3 重排序
- 3.3.1 資料依賴性
- 3.3.2 as-if-serial
- 3.3.3 程式順序規則
- 3.4 happens-before原則
- 3.4 原子性、可見性和有序性
- 3.4.1 原子性
- 3.4.1 可見性
- 3.4.2 有序性
在并發程式設計中, 需要處理兩個關鍵問題: 線程之間如何通信及線程之間如何同步
通信指的是線程之間是以何種機制來交換資訊, 在指令式程式設計中, 線程之間的通信機制有兩種:共享記憶體和消息傳遞。在共享記憶體的模型中, 線程之間共享程式的公共狀态, 通過讀寫記憶體中的公共狀态進行隐式通信。在消息傳遞的并發模型中, 線程之間沒有公共狀态, 線程之間必須通過發送消息顯示的進行通信。
同步指的是程式中用于控制不同線程之間操作發生相對順序的機制。在共享記憶體的并發模型裡, 同步是顯示進行的。 程式員必須顯示的指定某個方法或某段代碼需要線上程之間互斥。
Java 采用的是共享記憶體模型, Java線程之間的通信總是隐式的進行, 整個通信過程對程式員完全透明。
在計算機中, 所有的計算操作都是由 CPU 的寄存器來完成的。 CPU 指令的執行過程需要涉及資料的讀取和寫入操作。 CPU 通常能通路的都是計算機的主記憶體(通常是 RAM)。
随着制造技術等的飛速發展, CPU 不斷的發展。 但主存的發展卻沒有多大的突破, 是以, 差距就越來越大。

是以, 一種新類型的更快的記憶體-緩存,就出現了(速度越快越貴),用來彌補兩者之間的差距。
目前, CPU緩存模型如下所示
越靠近CPU, 速度越快。 其速度差異如下
CPU Cache 由多個 CPU Line 構成, CPU Line 被認為是最小的緩存機關。
既然有了 CPU Cache, CPU 就不直接跟記憶體進行互動了。 在程式運作的過程中, 會将運算所需要的資料從主記憶體複制到 CPU Cache 中, 這樣就可以直接對 CPU Cache 進行讀取和寫入, 當運算結束之後, 在将結果重新整理到主記憶體中。
通過以上的方式, CPU的吞吐能力得到極大的提高。有了 CPU Cache 之後, 整體的 CPU 和 主記憶體的交換架構大緻如下
在該架構中, 每個CPU的 CPU Cache 是自己本地的, 别的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
Java 多線程(六)之Java記憶體模型
Java 虛拟機規範提供了一種Java 記憶體模型來屏蔽掉各種硬體和作業系統的記憶體通路差異, 以實作讓 Java 程式在各種平台下都能達到一緻性的記憶體通路效果。
從架構上看, 跟之前提到的實體硬體記憶體模型有很大的相似度, 但是差别挺大。
- 主記憶體: 所有的變量都存儲在主記憶體中(類似于實體硬體的主記憶體, 不過該記憶體隻是虛拟機記憶體的一部分)
- 工作記憶體: 工作記憶體中儲存了被該線程用到的變量的主記憶體副本拷貝(取決于虛拟機的實作, 可能複制的隻是對象的引用, 對象的某個字段等), 線程對變量的操作(讀寫等)都必須在工作記憶體中運作, 而不能直接讀寫主記憶體中的變量
不同的線程之間無法通路對方工作記憶體中的變量, 線程之間變量的傳遞必須通過主記憶體進行。
變量如何從主記憶體拷貝到工作記憶體、如何從工作記憶體同步回主記憶體, 由以下8種原子操作來完成。
- lock: 作用于主記憶體變量, 它把一個變量辨別為一條線程獨占的狀态
- unlock: 作用于主記憶體的變量, 它把一個處于加鎖的變量釋放出來, 釋放後的變量才可以被其他線程鎖定
- read: 作用于主記憶體變量, 它把一個變量的值從主記憶體傳輸到線程的工作記憶體, 一般随後的 load 操作
- load: 作用于工作記憶體的變量, 它把 read 操作從主記憶體宏得到的值寫入工作記憶體的變量副本中
- use: 作用于工作記憶體的變量, 把工作記憶體的變量傳遞給執行引擎, 每當虛拟機遇到一個需要使用到變量值的位元組碼指令時就會執行該操作
- assign: 作用于工作區記憶體的變量, 它把執行引擎接收到的值指派給工作記憶體的變量, 當虛拟機遇到給一個給變量指派的指令時就會執行這個操作
- store: 作用于工作記憶體變量, 把工作記憶體中變量的值傳送到主記憶體中, 以便随後的 write 操作
- write: 作用于主記憶體變量, 它把 store 操作從工作記憶體中得到的變量值放入主記憶體變量中
Java模型還對這些操作進行了更加細緻的限定, 加上 volatile 的一些特殊規定, 就可以确定 Java 程式中哪些記憶體通路操作在并發下是安全的。
重排序是編譯器和處理器為了優化程式性能而對指令序列進行重重排序的一種手段。重排序的目的是在不改變程式執行結果的情況下, 盡可能提高并行度。 有以下幾種重排序:
- 編譯器優化的重排序。 在不改變單線程程式語義的前提下,可以重新安排語句的執行順序。
- 指令級并行的重排序。現在處理器采用了指令級并行技術(ILP)來将多條指令重疊執行。 如果不存在資料依賴性, 處理器可以改變語句對應機器指令的執行順序。
- 記憶體系統的重排序。 由于處理器使用緩存和讀寫緩沖區, 這使得記載和存儲操作看上去可能是亂序執行的。
從源代碼到最終實際執行的指令序列, 經曆的3種重排序
1屬于編譯器重排序, 2和3屬于處理器重排序。
如果兩個操作通路同一個變量, 且這兩個操作中有一個為寫操作, 此時這兩個操作之間就存在資料依賴性
名稱 | 代碼示例 | 說明 |
---|---|---|
寫後讀 | a=1; b=a; | 寫一個變量之後, 在讀這個位置 |
寫後寫 | a=2; | 寫一個變量之後, 再寫一個變量 |
讀後寫 | a=b; b=1; | 讀一個變量之後, 再寫這個變量 |
如果對以上的操作并行重排序, 則會改變程式執行的結果。是以, 編譯器和處理器在重排序時, 會遵循資料依賴性, 編譯器和處理器不會改變存在資料依賴性的兩個操作的執行順序。
此處說的僅僅是單線程的資料依賴性, 多線程的不考慮。
即不管程式怎麼重排序, (單線程)程式的執行結果不能被改變。 編譯器、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, 這兩者可以随意排序。
在以上圓形面積的計算中, 有如下三個 happens-before 關系
- A happens-before B
- B happens-before C
- A happens-before C
其中第三條是根據前面兩條傳遞性推倒出來的。
A happens-before B 并不是要求 A 一定要在 B 之前執行, 而是要求A的執行結果對B可見。 但這裡的A的執行結果不需要對B可見, 在這種情況下, JMM 會認為這種重排序是合法的, JMM 允許此類重排序。
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() 方法的開始。
JMM 是圍繞着在并發過程中如何處理原子性、可見性和有序性這個三個特征來建立的。
Java 中對以上的八種操作是原子性的。 對應起來就是對基本資料類型的讀取/指派操作都是原子性的, 引用類型的讀取和指派也是如此。
舉幾個例子
指派操作
a=10
該操作需要使用 assign 操作, 可能需要 store 和 write 操作。 這些過程都是原子操作。
可有通過
- synchronized關鍵字
- JUC所提供的顯式鎖Lock
來實作原子性
指的是一個線程中修改了共享變量, 其他的線程就能夠立即知道這個修改。 JMM 可以通過以下三種方式來保證可見性
- volatile關鍵字
Java 中天然的有序性可以概括總結為一句話:如果本線程内觀察, 所有的操作都是有序的; 如果在一個線程内觀察另一個線程, 所有的操作都是無序的。 前半句指的是 as-if-serial 語義, 後半句指的是“指令重排”和“線程記憶體與主記憶體同步延遲”的線程。
有序性的保證:
- volatile: 禁止指令重排
- synchronized: 一個變量再同一時刻, 隻允許一條線程對其進行 lock 操作。
- Lock: 同 synchronized
作者:阿進的寫字台
出處:https://www.cnblogs.com/homejim/
本文版權歸作者和部落格園共有,歡迎轉載,但未經作者同意必須保留此段聲明,且在文章頁面明顯位置給出原文連接配接,否則保留追究法律責任的權利。