Java 多線程(六)之Java內存模型


@

1. 並發編程的兩個問題

在並發編程中, 需要處理兩個關鍵問題: 線程之間如何通信及線程之間如何同步

通信指的是線程之間是以何種機制來交換信息, 在命令式編程中, 線程之間的通信機制有兩種:共享內存和消息傳遞。在共享內存的模型中, 線程之間共享程序的公共狀態, 通過讀寫內存中的公共狀態進行隱式通信。在消息傳遞的並發模型中, 線程之間沒有公共狀態, 線程之間必須通過發送消息顯示的進行通信。

同步指的是程序中用於控制不同線程之間操作發生相對順序的機制。在共享內存的並發模型里, 同步是顯示進行的。 程序員必須顯示的指定某個方法或某段代碼需要在線程之間互斥。

Java 采用的是共享內存模型, Java線程之間的通信總是隱式的進行, 整個通信過程對程序員完全透明。

2 CPU 緩存模型

2.1 CPU 和 主存

在計算機中, 所有的計算操作都是由 CPU 的寄存器來完成的。 CPU 指令的執行過程需要涉及數據的讀取和寫入操作。 CPU 通常能訪問的都是計算機的主內存(通常是 RAM)。

隨着制造工藝等的飛速發展, CPU 不斷的發展。 但主存的發展卻沒有多大的突破, 因此, 差距就越來越大。

CPU和RAM

因此, 一種新類型的更快的內存-緩存,就出現了(速度越快越貴),用來彌補兩者之間的差距。

2.2 CPU Cache

目前, CPU緩存模型如下所示

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的 CPU Cache 是自己本地的, 別的CPU無法訪問。

2.4 CPU 緩存一致性問題

就如同我們在自己的程序中使用緩存時一樣, CPU 引入了緩存, 提高了訪問速度, 但也帶來了緩存一致性的問題。

舉例

對於 i++ 這個操作, 需要以下幾個步驟

  1. 讀取主內存值 i 到 CPU Cache 中
  2. 對 i 進行自增操作
  3. 將結果寫回 CPU Cache 中
  4. 將數據刷新到緩存中

在單線程的情況下, 該操作是沒有任何問題的。 但是在多線程的情況下, 變量 i 會在多個線程的本地內存中都存在副本, 如果兩個線程都執行以上操作, 讀取到的值剛開始都為 0, 那么在進行兩次自增操作之后, 主存中的值仍然為 1。 這就是緩存一致性問題。

為了解決該問題, 聰明的前人發明了兩種方法

  1. 通過總線加鎖的方式
  2. 通過緩存一致性協議

總線加鎖效率太低, 現在都使用的是緩存一致性協議。

最出名的就是傳說中的 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 提供狀態, 並根據不同狀態的操作做出不同的響應。

CacheLine

在 MESI 協議中, 有如下操作

  • Local Read(LR):讀本地cache中的數據
  • Local Write(LW):將數據寫到本地cache
  • Remote Read(RR):其他核心發生read
  • Remote Write(RW):其他核心發生write
    MESI

3 Java內存模型(JMM)

3.1 Java內存模型(JMM)

Java 虛擬機規范提供了一種Java 內存模型來屏蔽掉各種硬件和操作系統的內存訪問差異, 以實現讓 Java 程序在各種平台下都能達到一致性的內存訪問效果。
JMM

從架構上看, 跟之前提到的物理硬件內存模型有很大的相似度, 但是差別挺大。

  • 主內存: 所有的變量都存儲在主內存中(類似於物理硬件的主內存, 不過該內存只是虛擬機內存的一部分)
  • 工作內存: 工作內存中保存了被該線程用到的變量的主內存副本拷貝(取決於虛擬機的實現, 可能復制的只是對象的引用, 對象的某個字段等), 線程對變量的操作(讀寫等)都必須在工作內存中運行, 而不能直接讀寫主內存中的變量

不同的線程之間無法訪問對方工作內存中的變量, 線程之間變量的傳遞必須通過主內存進行

3.2 內存間交互操作

變量如何從主內存拷貝到工作內存、如何從工作內存同步回主內存, 由以下8種原子操作來完成。

  1. lock: 作用於主內存變量, 它把一個變量標識為一條線程獨占的狀態
  2. unlock: 作用於主內存的變量, 它把一個處於加鎖的變量釋放出來, 釋放后的變量才可以被其他線程鎖定
  3. read: 作用於主內存變量, 它把一個變量的值從主內存傳輸到線程的工作內存, 一般隨后的 load 操作
  4. load: 作用於工作內存的變量, 它把 read 操作從主內存宏得到的值寫入工作內存的變量副本中
  5. use: 作用於工作內存的變量, 把工作內存的變量傳遞給執行引擎, 每當虛擬機遇到一個需要使用到變量值的字節碼指令時就會執行該操作
  6. assign: 作用於工作區內存的變量, 它把執行引擎接收到的值賦值給工作內存的變量, 當虛擬機遇到給一個給變量賦值的指令時就會執行這個操作
  7. store: 作用於工作內存變量, 把工作內存中變量的值傳送到主內存中, 以便隨后的 write 操作
  8. write: 作用於主內存變量, 它把 store 操作從工作內存中得到的變量值放入主內存變量中

Java模型還對這些操作進行了更加細致的限定, 加上 volatile 的一些特殊規定, 就可以確定 Java 程序中哪些內存訪問操作在並發下是安全的。

3.3 重排序

重排序是編譯器和處理器為了優化程序性能而對指令序列進行重重排序的一種手段。重排序的目的是在不改變程序執行結果的情況下, 盡可能提高並行度。 有以下幾種重排序:

  1. 編譯器優化的重排序。 在不改變單線程程序語義的前提下,可以重新安排語句的執行順序。
  2. 指令級並行的重排序。現在處理器采用了指令級並行技術(ILP)來將多條指令重疊執行。 如果不存在數據依賴性, 處理器可以改變語句對應機器指令的執行順序。
  3. 內存系統的重排序。 由於處理器使用緩存和讀寫緩沖區, 這使得記載和存儲操作看上去可能是亂序執行的。

從源代碼到最終實際執行的指令序列, 經歷的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 規則

  1. 程序順序規則(單線程): 一個線程中的每個操作, happens-before 於該線程中的后續操作。
  2. 監視器規則: 對一個鎖的解鎖, happens-before 於對該鎖的加鎖
  3. volatile規則:對一個 volatile 域的寫, happens-before 於隨后對這個域的讀
  4. 傳遞性: 如果 A happens-before B, 且 B happens-before C, 則 A happens-before C。
  5. 線程啟動規則: 如果線程A執行操作ThreadB.start()(線程B啟動), 那么A線程的 Thread.start() 操作 happens-before 於線程B的任意操作。
  6. 線程終止規則: 如果線程 A 執行操作 ThreadB.join() 並成功返回, 那么編程B中的任意操作 happens-before 於線程A從ThreadB.join()操作成功返回。
  7. 程序中斷規則: 對線程interrupt()的方法的調用 happens-before 於被中斷線程代碼檢測到中斷事件的發生。
  8. 對象終結規則: 一個對象的初始化完成, happens-before 於發生它的 finalize() 方法的開始。

3.4 原子性、可見性和有序性

JMM 是圍繞着在並發過程中如何處理原子性、可見性和有序性這個三個特征來建立的。

3.4.1 原子性

Java 中對以上的八種操作是原子性的。 對應起來就是對基本數據類型的讀取/賦值操作都是原子性的, 引用類型的讀取和賦值也是如此。

舉幾個例子

賦值操作

a=10

該操作需要使用 assign 操作, 可能需要 store 和 write 操作。 這些過程都是原子操作。

可有通過

  1. synchronized關鍵字
  2. JUC所提供的顯式鎖Lock

來實現原子性

3.4.1 可見性

指的是一個線程中修改了共享變量, 其他的線程就能夠立即知道這個修改。 JMM 可以通過以下三種方式來保證可見性

  1. volatile關鍵字
  2. synchronized關鍵字
  3. JUC所提供的顯式鎖Lock

3.4.2 有序性

Java 中天然的有序性可以概括總結為一句話:如果本線程內觀察, 所有的操作都是有序的; 如果在一個線程內觀察另一個線程, 所有的操作都是無序的。 前半句指的是 as-if-serial 語義, 后半句指的是“指令重排”和“線程內存與主內存同步延遲”的線程。

有序性的保證:

  1. volatile: 禁止指令重排
  2. synchronized: 一個變量再同一時刻, 只允許一條線程對其進行 lock 操作。
  3. Lock: 同 synchronized


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM