【Java併發基礎】Java內存模型解決有序性和可見性問題

前言

解決併發編程中的可見性和有序性問題最直接的方法就是禁用CPU緩存和編譯器的優化。可是,禁用這二者又會影響程序性能。因而咱們要作的是按需禁用CPU緩存和編譯器的優化html

如何按需禁用CPU緩存和編譯器的優化就須要提到Java內存模型。Java內存模型是一個複雜的規範。其中最爲重要的即是Happens-Before規則。下面咱們先介紹如何利用Happens-Before規則解決可見性和有序性問題,而後咱們再擴展簡單介紹下Java內存模型以及咱們前篇文章提到的重排序概念。java

volatile

在前一篇文章介紹編譯優化帶來的有序性問題時,給出的一個解決辦法時將共享變量使用volatile關鍵字修飾。volatile關鍵字的做用能夠簡單理解爲①禁用重排序,保證程序的有序性;②禁用緩存,保證程序的可見性。程序員

volatile關鍵字不是Java語言中的特產,C語言中也有,其最原始的意義就是禁用CPU緩存,使得每次訪問均須要直接從內存中讀寫
若是咱們聲明一個volatile變量,那麼也就會讓編譯器不能從CPU緩存中去讀取這個變量,而必須從內存中讀取。編程

class VolatileExample {
  int x = 0;        // 1
  volatile boolean v = false; //2
  public void writer() {    //3
    x = 42;
    v = true;
  }
  public void reader() {    //4
    if (v == true) {
      // 這裏 x 會是多少呢?
    }
  }
}
在這段代碼中,假設線程A執行了3即writer()方法,設置了x=42和v=true。線程B執行了4即reader()方法,線程B能夠看見線程A設置的v爲true,那麼B讀到的x值會是多少呢?(想想再點擊我) 這要分Java版原本說,在1.5以前,會出現x=0的狀況。
因爲可見性問題,線程A修改的x可能存儲在CPU緩存中對線程B是不可見的,因而線程B獲取到的x爲0。
在Java1.5以後,線程B獲取到的x必定就是42。
這是由於Java內存模型對volatile語義進行了加強。加強體如今Java內存模型中的Happens-Before規則上。

Happens-Before規則

Happends-Before規則表達的是:前面一個操做的結果對以後操做是可見的,描述的是兩個操做的內存可見性。
Happens-Before約束了編譯器的優化行爲,雖容許編譯器優化,可是要求編譯器遵循必定的Happens-Before規則進行優化。數組

Happens-Before規則包括:緩存

  • 程序順序規則多線程

    在一個線程中,前面的操做Happens-Before於後續的任意操做。架構

  • volatile變量規則併發

    對volatile變量的寫操做相對於以後對該volatile變量的讀操做是可見的。(這個語義可等價適用於原子變量)
    對volatile變量的寫操做 Happens-Before 對該volatile變量的讀操做app

  • 傳遞性

    若是操做A Happens-Before 操做B而且操做B Happens-Before 操做C, 那麼操做A Happens-Bofore 操做C。

利用程序順序規則、volatile變量規則、傳遞性規則說明例子

根據程序順序規則,在一個線程中,以前的操做是Happens-Before後續的操做,因此x=42; Happens-Before v=true;;根據volatile變量規則,對volatile變量的寫操做相對於以後對該volatile變量的讀操做是可見的,因而寫變量v=ture;Happens-Before讀變量v==true;;根據傳遞性,得出x=42;Happens-Before讀變量v==true;因而,最終讀出的x值會是42。

  • 管程中的鎖規則

    對同一個鎖的解鎖 Happens-Before 後續對這個鎖的加鎖。
    (管程:是一種同步原語,在Java中就是指synchronized。)

  • 線程啓動規則

    線程的啓動操做(即Thread.start()) Happens-Before 該線程的第一個操做。
    主線程A啓動子線程B,那麼子線程B可以看到線程A在啓動B以前的任意操做。

    Thread B = new Thread(()->{
      // 主線程調用 B.start() 以前
      // 全部對共享變量的修改,此處皆可見
      // 此例中,var==77
    });
    // 此處對共享變量 var 修改
    var = 77;
    // 主線程啓動子線程
    B.start();
  • 線程結束規則

    線程的最後一個操做 Happens-Before 它的終止事件。
    主線程A等待子線程B完成(A調用B.join())。當B完成以後(主線程A中的join()返回),主線程A能夠看見子線程的操做。看到針對的是對共享變量。

    Thread B = new Thread(()->{
      // 此處對共享變量 var 修改
      var = 66;
    });
    // 例如此處對共享變量修改,
    // 則這個修改結果對線程 B 可見
    // 主線程啓動子線程
    B.start();
    B.join()
    // 子線程全部對共享變量的修改
    // 在主線程調用 B.join() 以後皆可見
    // 此例中,var==66
  • 中斷規則

    線程對其餘線程的中斷操做 Happens-Before被中斷線程所收到中斷事件。
    一個線程在另外一個線程上調用interrupt,必須在被中斷線程檢測到interrupt調用以前執行。(被中斷線程的InterruptedException異常,或者第三個線程針對被中斷線程的Thread.interrupted或者Thread.isInterrupted調用)

  • 析構器規則

    構造器中的最後一個操做 Happens-Before 析構器的第一個操做
    或者說,對象的構造器必須在啓動該對象的析構器以前執行完成。

須要注意,A操做 Happens-Before B操做,但並不意味着A操做必需要在B操做以前執行。
Happens-Before表達的是前一個操做執行後的結果是對後續一個操做是可見的,且前一個操做按順序排在第二個操做以前。

Java內存模型的抽象

共享變量可指代存儲與堆內存中的實例域、靜態域和數組元素,共享變量是線程間共享的。局部變量、方法定義參數和異常處理器參數不會在線程之間共享,因此,它們不會有內存可見性問題。

Java線程之間的通訊由Java內存模型(Java Memory Model, JMM)控制,JMM決定了一個線程對共享變量的寫入什麼時候對另外一個線程可見。

從抽象角度看,JMM定義了線程和主內存之間的抽象關係:線程之間的共享變量存儲在主內存(main memory)中,每一個線程都有一個私有的本地內存(local memory),本地內存存儲了該線程以讀/寫共享變量的副本。
本地內存是JMM的一個抽象概念,實際並不存在,它主要是指代緩存、寫緩衝區、寄存器以及其餘的硬件和編譯器優化。
Java內存模型的抽象示意圖以下:(圖來自程曉明的深刻理解Java內存模型)

從上圖來看,若是線程A和線程B要進行通訊,須要進行兩步:

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

線程A和B的通訊過過了主內存,JMM經過控制主內存和每一個線程的本地內存之間的交互,來爲Java程序員提供內存可見性的保證。

重排序

重排序分類

在執行程序時,爲了提升性能,編譯器和處理器經常會對指令作重排序處理。加上前面提到的編譯器優化,重排序能夠分爲三種類型:

  1. 編譯器優化的重排序。編譯器在不改變單線程程序語義的前提下,能夠從新安排語句的執行順序。即在單線程中,重排序指令後執行的結果與未重排序執行的結果一致,那麼就能夠容許這種優化。
  2. 指令級並行的重排序。現代處理器採用了指令級並行技術(Instruction-Level Parallelism, ILP)來將多條指令重疊執行。若是不存在數據依賴性,處理器即可以改變語句對應機器指令的執行順序。
  3. 內存系統的重排序。因爲處理器使用緩存和讀/寫緩衝區,這使得加載和存儲操做看上去多是在亂序執行。由於緩存可能會改變將寫入變量提交到主內存的次序。

as-if-serial屬性:在單線程狀況下,雖然有可能不是順序執行,可是通過重排序的執行結果要和順序執行的結果一致。 編譯器和處理器須要保證程序可以遵照as-if-serial屬性。

數據依賴性:若是兩個操做訪問同一個變量,且這兩個操做中有一個爲寫操做,此時這兩個操做之間就存在數據依賴性。編譯器和處理器不能對「存在數據依賴關係的兩個操做」執行重排序。

從Java源代碼到最終執行的指令序列,會經歷下面的三種重排序:(圖來自程曉明的深刻理解Java內存模型)

第一個屬於編譯器重排序,第二三個屬於處理器重排序。這些重排序均可能會致使奪多線程出現內存可見性問題。
針對編譯器的重排序,JMM會有編譯器重排序規則禁止特定類型的編譯器重排序,不會禁止全部類型的編譯器重排序。
針對處理器重排序,JMM的處理器重排序規則會要求Java編譯器在生成指令序列時,插入特定類型的內存屏障(memory barriers)指令,來禁止特定類型的處理器重排序。

JMM屬於語言級的內存模型,它確保在不一樣的編譯器和處理器平臺上,經過禁止特定類型的編譯器重排序和處理器重排序,爲程序員提供一致的內存可見性保證。

內存屏障

處理器架構提供了一些特殊的指令(稱爲內存屏障)用來在須要共享數據時實現存儲協調。JMM使編譯器在適當的位置插入內存屏障指令來禁止特定類型的處理器重排序。

內存屏障指令可分爲下列四類:

屏障類型 指令示例 說明
LoadLoad Barriers Load1;LoadLoad;Load2 確保Load1數據的裝載,以前於Load2及全部後續裝載指令的裝載。
StoreStore Barriers Store1;StoreStore;Store2 確保Store1數據對其餘處理器可見(刷新到內存),以前於Store2及全部後續存儲指令的存儲。
LoadStore Barriers Load1;LoadStore;Store2 確保Load1數據裝載,以前於Store2及全部後續的存儲指令刷新到內存。
StoreLoad Barriers Store1;StoreLoad;Load2 確保Store1數據對其餘處理器變得可見(刷新到內存),以前於Load2及全部後續裝載指令的裝載。StoreLoad Barriers會使該屏障以前的全部內存訪問指令(存儲和裝載指令)完成以後,才執行該屏障以後的內存訪問指令。

JMM、Happens-Before和重排序規則之間的關係

(圖來自程曉明的深刻理解Java內存模型)

看圖中的歸納,一個Happens-Before規則對應於一個或者多個編譯器和處理器重排序規則。
對Java程序員來講,只須要熟悉Happens-Before規則,就可使程序避免遭受內存可見性問題,而且不用爲了理解JMM提供的內存可見性保證而學習複雜的重排序規則以及這些規則的具體實現。

再談volatile

爲了避免打亂前面的行文思路,因而就在後面補充關於volatile的知識。

volatile變量是Java語言提供的一種較弱的同步機制,用來確保將變量的更新操做都通知到其餘線程。將變量聲明爲volatile類型後,編譯器與運行時都會注意到這個變量是共享的,不會將該變量上的操做與其餘內存操做一塊兒重排序,即咱們前面所說的保證程序有序性。volatile變量不會被緩存在寄存器或者CPU緩存中對其餘處理器不可見,讀取volatile類型的變量時總會返回最新寫入值,即咱們前面說的保證程序可見性。然而,頻繁地訪問 volatile 字段也會由於不斷地強制刷新緩存而嚴重影響程序的性能。

從內存可見性角度來看,寫入volatile變量至關於退出同步代碼塊,而讀取volatile變量至關於進入同步代碼塊。然而,並不建議過分依賴volatile變量提供的可見性。若是在代碼中依賴volatile變量來控制狀態的可見性,一般比使用鎖的代碼更脆弱也更加難以理解。(下一篇文章將介紹Java併發中的同步機制)

僅當volatile變量能簡化代碼的實現以及對同步策略的驗證時,才應該使用。
volatile變量的正確使用方式包括:確保自身狀態的可見性,確保它們所引用對象的狀態的可見性以及標識一些重要的程序生命週期事件的發生(例如,初始化或者關閉)。

下面的例子是volatile變量的一種典型用法:檢查某個狀態標記以判斷是否退出循環。

volatile boolean asleep;
...
    while(!asleep)
        countSomeSheep();

爲了能使這個程序正確執行,alseep必需要爲volatile變量。不然,當asleep被另一個線程修改時,執行判斷的線程卻發現不了。後面也會講用鎖操做也能夠確保asleep更新操做的可見性,可是這將會使代碼變得複雜。

須要注意,儘管volatile變量常常用於表示某種狀態信息如某個操做完成、發生中斷或者標記,可是volatile的語義是不足以確保遞增操做(count++)的原子性 ,除非確保只有一個線程對變量執行寫操做。後面將要介紹的同步機制中的加鎖機制既能夠確保可見性又能夠確保原子性,而volatile變量只能確保可見性。

小結

行文思路整體看起來有點亂ε(┬┬﹏┬┬)3,不過這也不是有意爲之。本打算是重點介紹Happens-Before規則,而後稍微介紹一點Java內存模型。可奈何中途瞥見了一個網友力推程曉明的深刻理解Java內存模型,因而就去拜讀了一遍。看完發現仍是要補充介紹一些東西,因而補着補着就亂了。唉,也怪我這深刻淺出介紹知識的能力不夠,各位看官擇其所需看看就好。

參考: [1]極客時間專欄王寶令《Java併發編程實戰》 [2]Brian Goetz.Tim Peierls. et al.Java併發編程實戰[M].北京:機械工業出版社,2016 [3]程曉明.深刻理解Java內存模型.https://www.infoq.cn/article/java_memory_model

相關文章
相關標籤/搜索