天天看點

Java并發程式設計(九)-Java記憶體模型(JMM)Java記憶體模型基礎主記憶體工作記憶體記憶體互動操作并發程式設計特性:原子性、可見性、有序性指令重排序(重要)happens-before-先行先發生原則(重要)

Java記憶體模型

  • Java記憶體模型基礎
    • 并發程式設計模型的兩個關鍵問題
  • 主記憶體
  • 工作記憶體
  • 記憶體互動操作
    • 記憶體互動操作條件
    • long、double類型變量的特殊規則
  • 并發程式設計特性:原子性、可見性、有序性
    • 原子性
    • 可見性
    • 有序性
  • 指令重排序(重要)
    • 資料依賴性
    • 記憶體屏障類型
    • as-if-serial
  • happens-before-先行先發生原則(重要)

Java記憶體模型基礎

并發程式設計模型的兩個關鍵問題

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

通信 是指 線程之間 以何種機制 來 交換資訊。在指令式程式設計中,線程之間的通信機制有兩種:共享記憶體和消息傳遞

  • 在共享記憶體的并發模型裡,線程之間 共享 程式(資料或者叫變量)的公共狀态,通過 寫-讀(兩個動詞) 記憶體中的公共狀态 進行隐式通信
  • 在消息傳遞的并發模型裡,線程之間沒有公共狀态,線程之間必須通過發送消息來顯式進行通信

同步 是指 程式中 用于控制 不同線程間操作發生 的 相對順序的機制

  • 在共享記憶體并發模型裡,同步是顯式進行的。程式員必須顯式指定某個方法或某段代碼需要線上程之間互斥執行
  • 在消息傳遞的并發模型裡,由于消息的發送必須在消息的接收之前,是以同步是隐式進行的

Java的并發采用的是共享記憶體模型,Java線程之間的通信總是隐式進行,整個通信過程對程式員完全透明

Java線程之間的通信由Java記憶體模型-JMM控制,JMM決定一個線程對共享變量的寫入何時對另一個線程可見

主記憶體

所有的變量都存儲在這裡,這裡的變量包括:執行個體字段、靜态字段、構成數組對象的元素

不包括局部變量和方法參數

工作記憶體

每個線程私有,儲存了 被目前線程使用到 的 變量 是 主記憶體副本拷貝,線程對變量的讀取、指派都必須在工作記憶體中進行

不同線程之間無法通路對方工作記憶體中的變量

線程之間變量值的傳遞均需要通過主記憶體來完成

記憶體互動操作

Java記憶體模型定義了以下八種操作來完成

  • lock(鎖定):作用于主記憶體的變量,把一個變量 辨別為 一條線程獨占狀态
  • unlock(解鎖):作用于主記憶體變量,把一個處于鎖定狀态的變量 釋放出來,釋放後的變量 才可以 被其他線程鎖定
  • read(讀取):作用于主記憶體變量,把一個變量值 從主記憶體 傳輸到 線程的工作記憶體中,以便随後的load動作使用
  • load(載入):作用于工作記憶體的變量,它把 read操作從主記憶體中得到的變量值 放入 工作記憶體 的 變量副本中
  • use(使用):作用于工作記憶體的變量,把 工作記憶體中的一個變量值 傳遞給 執行引擎,每當虛拟機遇到一個 需要使用變量的值 的位元組碼指令時 将會執行這個操作
  • assign(指派):作用于工作記憶體的變量,它把一個從執行引擎接收到的值 指派給 工作記憶體的變量,每當虛拟機遇到一個 給變量指派的位元組碼指令時 執行這個操作
  • store(存儲):作用于工作記憶體的變量,把工作記憶體中的一個變量的值 傳送到 主記憶體中,以便随後的write的操作
  • write(寫入):作用于主記憶體的變量,它把store操作從工作記憶體中一個變量的值 傳送到 主記憶體的變量中

記憶體互動操作條件

  • 如果要把一個變量 從主記憶體中 複制 到工作記憶體,就需要按順序地執行read和load操作,如果把變量從 工作記憶體中 同步回 主記憶體中,就要按順序地執行store和write操作。但Java記憶體模型隻要求上述操作必須按順序執行,而沒有保證必須是連續執行
  • 不允許read和load、store和write操作之一 單獨出現
  • 不允許一個線程丢棄它的最近assign-指派操作,即變量 在工作記憶體中 改變了之後 必須同步到 主記憶體中
  • 不允許一個線程無原因地(沒有發生過任何assign-指派操作)把資料從工作記憶體同步回主記憶體中
  • 一個新的變量隻能在主記憶體中誕生,不允許在工作記憶體中直接使用一個未被初始化(load-載入或assign-指派)的變量。即就是對一個變量實施use-使用和store-存儲操作之前,必須先執行過了load-載入和assign-指派操作
  • 一個變量在同一時刻隻允許一條線程對其進行lock操作,但lock操作可以被同一條線程重複執行多次,多次執行lock後,隻有執行相同次數的unlock操作,變量才會被解鎖。lock和unlock必須成對出現
  • 如果對一個變量執行lock操作,将會清空工作記憶體中此變量的值,在執行引擎使用這個變量前 需要重新執行 load-載入或assign-指派操作初始化變量的值
  • 如果一個變量事先沒有被lock操作鎖定,則不允許對它執行unlock操作;也不允許去unlock一個被其他線程鎖定的變量
  • 對一個變量執行unlock操作之前,必須先把此變量同步到主記憶體中(執行store和write操作)

long、double類型變量的特殊規則

  • 虛拟機允許 将 沒有被volatile修飾的 64位資料的 讀寫操作 劃分為兩次32位操作來進行
  • 如果有多個線程 共享一個 并未聲明volatile的long/double變量,并且同時對它進行讀取/修改操作,那麼某些線程可能會讀取到一個 既非原值,也不是其他線程修改值的代表了“半個變量”的數值

并發程式設計特性:原子性、可見性、有序性

原子性

  • 原子性指一個操作的完整性,就是在一個操作中cpu不可以在中途暫停然後再排程,既不被中斷操作,要不執行完成,要不就不執行
  • 由Java記憶體模型來直接保證的 原子性變量操作包括:read、load、assign、use、store、write
  • 兩個原子性的操作結合在一起未必還是原子性的,比如i++
  • 簡單的 讀取與指派操作 是 原子性的,将 一個變量 賦給 另外一個變量 的 操作不是原子性的,因為後者包含兩個步驟:讀取一個變量的值 和 修改另一個變量的值。雖然兩步分别都是原子類型的操作,但是合在一起就不是原子操作了。x=10是原子性的;y=x、y++、z=z+1都不是原子性的
  • volatile關鍵字不保證資料的原子性
  • 如果想要使得某些代碼片段具備原子性,需要使用關鍵字synchronized,或者JUC包中的Lock
  • 自JDK1.5版本起,如果想要使得

    int

    等類型自增操作具備原子性,可以使用JUC包下的原子封裝類型java.util.concurrent.atomic.*

可見性

  • 可見性指當一個線程修改了共享變量後,其他線程能夠立即得知這個修改

Java提供了以下三種方式來保證可見性:

  • 使用關鍵字volatile,當一個變量被volatile關鍵字修飾時,對于共享資源的讀操作 會直接在 主記憶體中進行(當然也會緩存到工作記憶體中,當其他線程對該共享資源進行了修改,則會導緻 目前線程 在工作記憶體中的共享資源失效,是以必須從主記憶體中再次擷取)。對于共享資源的寫操作當然是先要修改工作記憶體,但是修改結束後會立刻将其重新整理到主記憶體中
  • volatile通過加入 記憶體屏障 和 禁止重排序優化 來實作可見性
  • synchronized和final也可以實作 變量可見性。 synchronized是指對一個變量執行unlock之前,必須先把此變量同步回主記憶體中。final是指 被final修飾的字段 一旦在構造器中初始化完成,并且構造器沒有把this傳遞出去,那在其他線程中就可以看到final字段的值
  • 通過JUC提供的顯式鎖Lock也能夠保證可見性,Lock的lock方法能夠保證在同一時刻隻有一個線程獲得鎖然後執行同步方法,并且會確定在鎖釋放(Lock的unlock方法)之前會将對變量的修改重新整理到主記憶體當中

有序性

  • 有序性是指程式代碼在執行過程中的先後順序,由于Java在編譯器以及運作期的優化,導緻了代碼的執行順序未必就是開發者編寫代碼時的順序
  • 如果在本線程内觀察,所有操作都是有序的;如果在一個線程中 觀察 另外一個線程,所有的操作都是無序的
  • 在JMM中,允許編譯器和處理器對指令進行重排序,但是重排序過程不會影響到單線程程式的執行,卻會影響到多線程并發執行的正确性。可以通過volatile、synchronized、Lock保證有序性。synchronized在有序性上的表現是指一個變量在同一個時刻隻允許一條線程對其進行lock-加鎖操作

指令重排序(重要)

一般來說,處理器為了提高程式運作效率,可能會對輸入代碼進行優化,它不保證程式中各個語句的執行先後順序同代碼中的順序一緻,但是它會保證程式最終執行結果和代碼順序執行的結果是一緻的

在多個線程需要依賴同一個資源的時候,指令重排序不會影響單個線程的執行,但是會影響到線程并發執行的正确性

重排序分3種類型:

  • 編譯器優化的重排序,編譯器在不改變單線程程式語義的前提下,可以重新安排語句的執行順序
  • 指令級并行的重排序,現代處理器采用了指令級并行技術(Instruction-Level Parallelism,ILP)來将多條指令重疊執行。如果不存在資料依賴性,處理器可以改變語句對應機器指令的執行順序
  • 記憶體系統的重排序,由于處理器使用緩存和讀/寫緩沖區,這使得 加載 和 存儲操作 看上去 可能是在亂序執行
  • 指令級并行的重排序 和 記憶體系統的重排序 屬于 處理器重排序。JMM的處理器重排序規則 會要求 Java編譯器 在生成 指令序列時,插入特定類型的記憶體屏障(Memory Barriers,Intel稱之為Memory Fence)指令,通過 記憶體屏障指令 來禁止 特定類型的處理器重排序

資料依賴性

  • 如果兩個操作通路同一個變量,且這兩個操作中有一個為寫操作,此時這兩個操作之間就存在資料依賴性
  • 資料依賴分為3種類型:寫後讀 寫後寫 讀後寫
  • 編譯器和處理器在重排序時,會遵守 資料依賴性,編譯器和處理器 不會改變 存在資料依賴關系的兩個操作 的 執行順序
  • 資料依賴性僅針對單個處理器中執行的指令序列和單個線程中執行的操作,不同處理器之間和不同線程之間的資料依賴性不被編譯器和處理器考慮

記憶體屏障類型

屏障類型 指令示例 說明
LoadLoad Barriers Load1;LoadLoad;Load2 確定Load1的資料的裝載 優先于 Load2及所有後續裝載指令的裝載
StoreStoreBarriers Store1;StoreStore;Store2 確定Store1資料 對其他處理器可見(重新整理到記憶體)優先于 Store2及所有後續存儲指令的存儲
LoadStore Barriers Load1;LoadStore;Store2 確定Load1的資料的裝載 優先于 Store2及所有後續存儲指令的存儲
StoreLoad Barriers Store1;StoreLoad;Load2 確定Store1的資料對其他處理器可見(重新整理到記憶體)優先于 Load2及所有後續的裝載指令的裝載。StoreLoad Barriers會使該屏障之前的所有記憶體通路指令完成之後,才執行該屏障之後的記憶體通路指令

as-if-serial

as-if-serial語義的意思是 是 不管怎麼重排序(編譯器和處理器為了提高并行度),(單線程)程式的執行結果不能被改變

為了遵守as-if-serial語義,編譯器和處理器不會對存在資料依賴關系的操作做重排序,因為這種重排序會改變執行結果。但是,如果操作之間不存在資料依賴關系,這些操作就可能被編譯器和處理器重排序

happens-before-先行先發生原則(重要)

前一個操作的結果可以被後續操作擷取

因為JVM會對代碼進行編譯優化,指令會出現重排序的情況,為了避免編譯優化對并發程式設計安全性的影響,需要happens-before規則定義一些禁止編譯優化的場景,保證并發程式設計的正确性

  • 程式的順序性規則:在一個線程内,按照程式代碼順序,書寫在前面的操作先行發生于書寫在後面的操作,準确的說,應該是控制流順序 而不是 程式代碼順序,因為要考慮分支、循環等結構
  • 管程鎖定規則:一個unlock操作 先行發生于 後面 對 同一個鎖 的 lock操作,這裡必須強調的是同一個鎖,“後面”指的是時間上的先後順序
  • volatile變量規則:對一個volatile變量的寫操作 先行發生于 後面 對這個變量的讀操作,“後面”同樣是時間上的先後順序
  • 線程啟動規則:Thread對象的start()方法 先行發生于 此線程的每一個動作
  • 線程終止規則:線程中的所有操作都 先行發生于 對此線程的終止檢測,可以通過Thread.join()方法結束,Thread.isAlive()的傳回值等手段 檢測到 線程已終止執行
  • 線程中斷規則:對線程interrupt()方法的調用 先行發生于 被中斷線程的代碼 檢測到 中斷事件的發生,可以通過Thread.interruptd()方法檢測到是否有中斷發生
  • 對象終結規則:一個對象的初始化完成(構造函數執行結束)先行發生于 它的finalize()方法的開始
  • 傳遞性:如果操作A先行發生于操作B,操作B先行發生于操作C,那麼就可以認為操作A先行發生于操作C

繼續閱讀