banner
bladedragon

bladedragon

《並發編程的藝術》-閱讀筆記03:Java內存模型

《並發編程的藝術》閱讀筆記第三章,先從底層講起。

基礎不牢,地動山搖。

image

一、基礎#

1、並發的兩個關鍵問題#

線程間通信和線程間同步

線程通信機制:

  • == 共享內存 ==:隱式通信,顯式同步
  • == 消息傳遞 ==:顯式通信,隱式同步

Java 的並發採用的是共享內存模型。

2、java 內存結構#

java 中所有的實例域、靜態域和數組元素都存儲在堆內存中;局部變量、方法定義參數、異常處理器參數不會在線程之間共享

JMM 定義了線程和主內存之間的抽象關係:線程之間的共享變量存儲在主內存中,每個線程都有一個私有的本地內存,本地內存中存儲了該線程讀 / 寫共享變量的副本

本地內存是 JMM 的一個抽象概念,並不真實存在,涵蓋了緩存、寫緩衝區、寄存器以及其他的硬件和編譯器優化

線程 A 與線程 B 之間要通信必須經過兩個步驟:

  1. 線程 A 把本地內存 A 中更新過的共享變量刷新到主內存中去
  2. 線程 B 到主內存中去讀取 A 之前已更新過的共享變量

JMM 通過控制主內存與每個線程的本地內存之間的交互來為 java 程序員提供內存可見性保證

3、指令重排序#

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

java 最終執行的指令序列:

源碼 --------》編譯器優化重排序 -------》指令級並行重排序 ----------》內存系統重排序 ---------》最終的指令序列

第一個屬於 == 編譯器重排序 ==,後面兩個屬於 == 處理器的重排序 ==,JMM 通過禁止特定類型的編譯器重排序和處理器重排序,為程序員提供一致的內存可見性保證

對於編譯器,JMM 的編譯器會禁止特定類型的編譯器重排序。== 對於處理器重排序,JMM 的處理器重排序規則會要求 Java 編譯器在生成指令序列時,插入特定的內存屏障指令 ==,從而禁止特定類型的處理器重排序。

4、內存屏障分類#

load:讀緩衝區

store:寫緩衝區

四種內存屏障:

  • LoadLoad Barriers:確保 load1 的數據先於 load2 及後續所有 load 指令進行裝載
  • StoreStore Barriers:確保 store1 的數據對其他處理器的可見性先於 store2 及後續所有存儲指令
  • LoadStore Barriers:確保 load 裝載先於 store 的存儲刷新到內存
  • StoreLoad Barriers:該屏障前的指令全部完成之後才會執行後面的指令(開銷大)

image

5、先行發生(happens-before)#

程序員必須遵循的編程原則

==JMM 中,如果一個操作執行的結果需要對另一個操作可見,那麼這兩個操作之間必須存在 happens-before 關係。==

其中一些順序規則(針對程序員):

  • 程序順序規則:一個線程中的每個操作,happens-before 於該線程中的任意後續操作
  • 監視器鎖規則:對一個鎖的解鎖,happens-before 於隨後對這個鎖的加鎖。
  • volatile 變量規則:對一個 volatile 域的寫,happens-before 於任意後續對這個 volatile 域的讀。
  • 傳遞性:如果 A happens-before B,且 B happens-before C,那麼 A happens-before C。

happens-before 僅僅要求前一個操作(執行的結果)對後一個操作可見,且前一個操作按順序排在第二個操作之前,並不意味著前一個操作必須要在後一個操作之前執行!

前一個要求對後一個操縱可見不是意味著前一個操作必須在後一個操作之前執行?

JMM要求如果A happens-before B那麼首先必須保證執行結果(A的執行結果對B **不一定可見** )必須和邏輯中的happens-before相同,其次,A的排序順序在B之前(這裡是指重排序之前的順序,後期允許通過重排序調整A和B的操作順序),但是最終結果必須和邏輯上的保持一致,因此從表面上看是按序執行了

二、重排序#

數據依賴性

在單線程程序中,對存在控制依賴的操作重排序不會改變執行結果;但在多線程程序中,對存在控制依賴的操作重排序,可能會改變程序的執行結果。

詳見30頁的例子

as-if-serial

不管怎麼重排序(編譯器和處理器為了提高並行度),(單線程)程序的執行結果不能被改變。編譯器、runtime 和處理器都必須遵守 as-if-serial 語義

三、順序一致性內存模型#

如果程序是正確同步的,程序的執行將具有順序一致性(Sequentially Consistent)—— 即程序的執行結果與該程序在順序一致性內存模型中的執行結果相同

  • 一個線程中的所有操作必須按照程序的順序來執行。
  • (不管程序是否同步)所有線程都只能看到一個單一的操作執行順序。在順序一致性內存模型中,每個操作都必須原子執行且立刻對所有線程可見。

未同步程序在順序一致性模型中給雖然整體執行順序是無序的但是所有線程都只能看見一個一致的的整體執行順序。 之所以能得到這個保證是因為順序一致性內存模型中的每個操作必須立即對任意線程可見

JMM 就沒有這個保證。因為只有當前線程把本地內存中寫過的數據刷新到主內存之後,這個寫操作才能對其他線程可見。

JMM 的處理邏輯:臨界區內的代碼可以重排序;JMM 會在退出臨界區和進入臨界區這兩個關鍵時間點做一些特別處理,使得線程在這兩個時間點具有與順序一致性模型相同的內存視圖

JMM 的具體實現的基本方針:在不改變(正確同步的)程序執行結果的前提下,盡可能地為編譯器和處理器的優化打開方便之門

順序一致性內存模型和 JMM 區別

  1. 順序一致性模型保證單線程內的操作會按程序的順序執行,而 JMM 不保證單線程內的操作會按程序的順序執行(比如上面正確同步的多線程程序在臨界區內的重排序)。這一點前面已經講過了,這裡就不再贅述。
  2. 順序一致性模型保證所有線程只能看到一致的操作執行順序,而 JMM 不保證所有線程能看到一致的操作執行順序。這一點前面也已經講過,這裡就不再贅述。
  3. ==JMM 不保證對 64 位的 long 型和 double 型變量的寫操作具有原子性,而順序一致性模型保證對所有的內存讀 / 寫操作都具有原子性。==

處理器總線工作機制

在計算機中,數據通過總線在處理器和內存之間傳遞。每次處理器和內存之間的數據傳遞都是通過一系列步驟來完成的

總線事務包括讀事務(Read Transaction)寫事務(Write Transaction)。讀事務從內存傳送數據到處理器,寫事務從處理器傳送數據到內存,每個事務會讀 / 寫內存中一個或多個物理上連續的字

  • 總線會同步試圖並發使用總線的事務。在一個處理器執行總線事務期間,總線會禁止其他的處理器和 I/O 設備執行內存的讀 / 寫 (從而保證當單個總線事務之中的內存讀寫具有原子性)
  • 在一些 32 位的處理器上,如果要求對 64 位數據的寫操作具有原子性,會有比較大的開銷。== 當 JVM 在這種處理器上運行時,可能會把一個 64 位 long/double 型變量的寫
    操作拆分為兩個 32 位的寫操作來執行 ==

注意,在 JSR-133 之前的舊內存模型中,一個 64 位 long/double 型變量的讀 / 寫操作可以被拆分為兩個 32 位的讀 / 寫操作來執行。從 JSR-133 內存模型開始(即從 JDK5 開始),僅僅只允許把一個 64 位 long/double 型變量的寫操作拆分為兩個 32 位的寫操作來執行,任意的讀操作在 JSR-133 中都必須具有原子性(即任意讀操作必須要在單個讀事務中執行)。

四、volatile 內存語義#

volatile 變量特性:

可見性:對一個volatile變量的讀,總是能看到(任意線程)對這個變量最後的寫入
原子性:對任意單個volatile變量的讀、寫具有原子性(包括long、double),但類似volatile++
這種複合操作不具有原子性。

volatile 寫 - 讀的內存語義:

寫:當寫一個 volatile 變量時,JMM 會把線程對應的 == 本地內存中的共享變量值刷新到主內存 ==

讀:當讀一個 volatile 變量時,JMM 會把該線程 == 對應的本地內存置為無效 ==。線程接下來從主內存中讀取共享變量

image

volatile讀的時候必須保證主內存中的數據不會更改,因此volatile讀如果是第一個操作怎不能實現重排序
volatile寫的時候必須保證之前的數據能正常寫入主內存,因此volatile寫如果是第二個操作的話不能實現重排序
普通讀寫是在本地內存,但是有概率會寫入主內存,因此具有隨機性

volatile 內存語義的實現#

為了實現 volatile 的內存語義,編譯器在生成字節碼時,會在指令序列中插入內存屏障來禁止特定類型的處理器重排序。

JMM 內存屏障插入策略 (保守!):

  • 在每個volatile寫操作前面插入StoreStore屏障
  • 在每個volatile寫操作後面插入StoreLoad屏障
  • 在每個volatile讀操作後面插入一個LoadLoad、一個LoadStore

image

image

為什麼volatile寫操作之前不用插入loadstore來避免普通讀和volatile寫之間的重排序?
猜測:內存屏障之間存在包含關係,比如storeload就可以實現其他三個所有功能

volatile 寫 - 讀內存語義的常見使用模式是:一個寫線程寫 volatile 變量,多個讀線程讀同一個 volatile 變量。當讀線程的數量大大超過寫線程時,選擇在 volatile 寫之後插入 StoreLoad 屏障將帶來可觀的執行效率的提升

image

實際情況編譯器在生成字節碼的時候可以優化選擇是否添加內存屏障,但是注意一般最後一個 storeload 不能省略,因為無法判斷 return 後是否還會有下個 volatile 讀 / 寫出現

不同的處理器平台也會對內存屏障做出優化

在功能上,鎖比 volatile 更強大;在可伸縮性和執行性能上,volatile 更有優勢

五、鎖的內存語義#

鎖的釋放和獲取的內存語義(和 volatile 一樣)

  • 線程釋放鎖時,會把本地內存中的共享變量刷新到主內存中(對應 volatile 寫)
  • 線程獲取鎖時,會將線程對應的本地內存置為無效,從而臨界區代碼必須從主內存讀取共享變量(對應 volatile 讀)

鎖內存語義的實現

分析 ReentrantLock 源碼#

ReentrantLock的實現依賴於 Java 同步器框架AbstractQueuedSynchronizer(本文簡稱之為AQS)。AQS使用一個整型的volatile變量(命名為state)來維護同步狀態,這個volatile變量是ReentrantLock內存語義實現的關鍵。

ReentrantLock 鎖分為公平鎖和非公平鎖

公平鎖

現在tryAcquire方法有點變化,在查看是不是第一個獲取鎖的對象處添加了hasQueuedPredecessors()方法,一個用來查詢是否有線程比現在線程等待時間更長

非公平鎖
調用 CAS:如果當前狀態值等於預期值,則以原子方式將同步狀態設置為給定的更新值。

公平鎖和非公平鎖語義總結:

  • 公平鎖和非公平鎖釋放時,== 最後都要寫一個 volatile 變量 state==
  • 公平鎖獲取時,== 首先會去讀 volatile 變量 ==
  • 非公平鎖獲取時,首先會 == 用 CAS 更新 volatile 變量,這個操作同時具有 volatile 讀和 volatile 寫的內存語義 ==

可以看出:鎖釋放 - 獲取的內存語義的實現至少有下面兩種方式

  1. 利用 volatile 變量的寫 - 讀所具有的內存語義
  2. 利用 CAS 所附帶的 volatile 讀和 volatile 寫的內存語義

CAS 是如何同時具有 volatile 讀和 volatile 寫的內存語義的?

多處理器環境,會為 cmpxchg 指令加上 lock 前綴,單處理器不用加(單處理器會維護自身的順序一致性)

  • 原先使用總線鎖定,後來考慮開銷使用緩存鎖定,大大降低 lock 前綴指令的執行開銷
  • lock 指令能防止重排序
  • 把寫緩衝區中的所有數據刷新到內存中

上面 2、3 兩點具有的內存屏障效果,足以同時實現 volatile 讀和 volatile 寫的內存語義

concurrent 包的通用實現模式#

  1. ==首先,聲明共享變量為volatile==
  2. ==然後,使用CAS的原子條件更新來實現線程之間的同步==
  3. ==同時,配合以volatile的讀、寫和CAS所具有的volatile讀和寫的內存語義來實現線程之間的通信==

image

六、final 域的內存語義#

1、final 域的重排序規則#

(1)寫:在 == 構造函數內對 == 一個 final 域的寫入,與 == 隨後把這個被構造對象的引用賦值給一個引用變量 ==,這兩個操作不能重排序

(2)讀:初次讀一個包含 final 域的對象的引用,與 == 隨後初次讀這個 final 域 ==,這兩個操作不能重排序。

2、寫 final 域的重排序規則#

== 禁止把 final 域的寫重排序到構造函數之外 == 包含兩方面:

1、編譯器: JMM禁止編譯器把final域的寫重排序到構造函數之外
2、處理器: 編譯器會在final域的寫之後,構造函數return之前,插入一個StoreStore屏障。這個屏障禁止處理器把final域的寫重排序到構造函數之外

上述規則可以確保:

在對象引用為任意線程可見之前,對象的 final 域已經被正確初始化過了,而普通域不具有這個保障。

3、讀 final 域的重排序規則#

處理器:在一個線程中,初次讀對象引用與初次讀該對象所包含的 final 域,JMM 禁止處理器重排序這兩個操作

編譯器:編譯器會在讀 final 域操作的前面插入一個LoadLoad屏障・

上述重排序規則可以確保:在讀一個對象的 final 域之前,一定會先讀包含這個 final 域的對象的引用(針對少數處理器存在間接依賴關係的操作做重排序)

4、當 final 域為引用類型#

對於引用類型,寫 final 域的重排序規則增加下面的約束:

在構造函數內對一個final引用對象的成員域的寫入,與隨後在構造函數外把這個被構造對象的引用賦值給一個引用變量,這兩個操作不能重排序。

對於存在數據競爭的線程無法保證可見性

5、為什麼 final 域不能從構造函數內溢出#

在構造函數返回前,被構造對象的引用不能為其他線程所見,因為此時的 final 域可能還沒有初始化 (構造函數內可能發生重排序)。

==(在引用變量為任意線程和可見之前,引用變量所指向的對象的 final 域必須要正確初始化,這是寫 final 域的重排序規則)==

七、happens-before#

as-if-serial 語義給編寫單線程程序的程序員創造了一個幻境:單線程程序是按程序的順序執行的。

happens-before 關係給編寫正確同步的多線程程序員創造了一個幻境:正確同步的多線程程序是按 happens-before 指定的順序執行的。

這麼做的目的:為了在不改變程序的執行結果的前提下,盡可能地提高程序執行的並行度。

完整 happens-before 規則

  • 程序順序規則:一個線程中的每個操作,happens-before 於該線程中的任意後續操作
  • 監視器鎖規則:對一個鎖的解鎖,happens-before 於隨後對這個鎖的加鎖。
  • volatile 變量規則:對一個 volatile 域的寫,happens-before 於任意後續對這個 volatile 域的讀。
  • 傳遞性:如果 A happens-before B,且 B happens-before C,那麼 A happens-before C。
  • start () 規則:如果線程 A 執行操作 ThreadB.start ()(啟動線程 B),那麼 A 線程的 ThreadB.start () 操作 happens-before 於線程 B 中的任意操作。
  • join () 規則:如果線程 A 執行操作 ThreadB.join () 並成功返回,那麼線程 B 中的任意操作 happens-before 於線程 A 從 ThreadB.join () 操作成功返回

八、雙重檢查鎖定與延遲初始化#

雙重檢查鎖定

問題描述:(一般是單例模式) 在上鎖之前加一個非空判斷,上鎖後再次非空判斷,實現雙重檢查鎖定

原因:在構造實例時,對象引用指針的操作和初始化操作可能會被重排序,這就導致在 if (instance==null) 的時候認為對象已經創建,但這個時候還沒有進行初始化

1.分配對象的內存空間
2.初始化對象
3.設置instance指向內存空間
4.初次訪問對象

3和2可能會被重排序,導致1342這樣的問題

一開始單例模式一旦出現並發,就可能出現初始化兩個對象的問題

後來選擇加鎖,加鎖能解決問題,但是出現嚴重的性能開銷

後來就選擇在加鎖前加一層非空判斷,這樣就可以只在第一次初始化,後期不用加鎖,但

是正是由於添加了一層非空判斷,導致多線程在進行這個判斷的時候,** 讀取操作跳過了等待時間直接讀取對象,但此時由於鎖內空間的重排序,導致此時對象還沒有初始化完成。** 從而造成嚴重的後果

解決方式:

  1. volatile。利用 volatile 的語義禁止重排序。在單例的懶漢模式中,必須給實例添加 volatile 修飾符

    優勢: == 除了可以對靜態字段實現延遲初始化外,還可以對實例字段實現延遲初始化。==

  2. 基於類初始化的解決方案,使得線程訪問之前就完成初始化

    JVM 在類的初始化階段(即在 Class 被加載後,且被線程使用之前),會執行類的初始化。
    執行類的初始化期間,JVM 會去獲取一個鎖。這個鎖可以同步多個線程對同一類的初始化。

    備註:類初始化的幾種情況
    1)T是一個類,而且一個T類型的實例被創建。
    2)T是一個類,且T中聲明的一個靜態方法被調用。
    3)T中聲明的一個靜態字段被賦值。
    4)T中聲明的一個靜態字段被使用,而且這個字段不是一個常量字段。
    5)T是一個頂級類(Top Level Class,見Java語言規範),而且一個斷言語句嵌套在T內部被執行。
    

JVM 初始化期間的同步過程#

初始化階段

  • 第 1 階段:通過在 Class 對象上同步(即獲取 Class 對象的 == 初始化鎖 ==),來控制類或接口的初始化。這個獲取鎖的線程會一直等待,直到當前線程能夠獲取到這個初始化鎖。

    == 此時只有一個線程能獲得初始化鎖 ==

  • 第 2 階段:線程 A 執行類的初始化,,同時線程 B 在初始化鎖對應的condition上等待

    初始化 instance 過程 (== 可能發生重排序,但是對於其他線程不可見 ==)

    1. 分配內存空間
    2. 複製給引用變量
    3. 初始化對象
  • 第 3 階段:線程 A 設置初始化完成標誌,然後喚醒在 condition 中等待的所有線程。

  • 第 4 階段:線程 B 結束類的初始化處理。

    線程 A 在第 2 階段的 A1 執行類的初始化,並在第 3 階段的 A4 釋放初始化鎖;

    線程 B 在第 4 段的 B1 獲取 == 同一個初始化鎖 ==,並在第 4 階段的 B4 之後才開始訪問這個類

  • 第 5 階段:線程 C 執行類的初始化的處理。

    線程 A 在第 2 階段的 A1 執行類的初始化,並在第 3 階段的 A4 釋放鎖;

    線程 C 在第 5 階段的 C1 獲取 == 同一個鎖 ==,並在在第 5 階段的 C4 之後才開始訪問這個類

九、JAVA 內存模型綜述#

處理器內存模型#

常見 處理器內存模型

  • Total Store Ordering 內存模型 (TSO)

    放鬆程序中 == 寫 - 讀 == 操作的順序

  • Total Store Ordering 內存模型(PSO)

    繼續放鬆程序中 == 寫 - 寫 == 操作的順序

  • Relaxed Memory Order 內存模型(RMO)

    繼續放鬆程序中 == 讀 - 寫 == 操作的順序

  • PowerPC 內存模型

    繼續放鬆 == 讀 - 讀 == 操作的順序

image

所有處理器內存模型都允許寫 - 讀重排序,原因:它們都使用了寫緩存區.

  1. 寫緩存區可能導致寫 - 讀操作重排序

  2. 由於寫緩存區僅對當前處理器可見,這個特性導致當前處理器可以比其他處理器先看到臨時保存在自己寫緩存區中的寫。

image

內存模型之間關係#

同處理器內存模型一樣,越是追求執行性能的語言,內存模型設計得會越弱。

image

JMM 的內存可見性保證#

按程序類型可分為

  • 單線程程序

    不會出現內存可見性問題

  • 正確同步的多線程程序

    具有順序一致性(程序的執行結果與該程序在順序一致性內存模型中的執行結果相同)

  • 未同步 / 未正確同步的多線程程序

    JMM 為它們提供了最小安全性保障:線程執行時讀取到的值,要麼是之前某個線程寫入的值,要麼是默認值(0、null、false)。

    但最小安全性並不保證線程讀取到的值,一定是某個線程寫完後的值。== 最小安全性保證線程讀取到的值不會無中生有的冒出來,但並不保證線程讀取到的值一定是正確的 ==。

舊內存模型的修補#

JDK1.5 之後對 volatile 的內存語義、final 的內存語義做了增強

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。