來吧!再談多執行緒

ML李嘉圖發表於2022-04-06

推薦閱讀https://www.cnblogs.com/zwtblog/tag/

再談多執行緒

在我們的作業系統之上,可以同時執行很多個程式,並且每個程式之間相互隔離互不干擾。

我們的CPU會通過時間片輪轉演算法,為每一個程式分配時間片,並在時間片使用結束後切換下一個程式繼續執行,通過這種方式來實現巨集觀上的多個程式同時執行。

由於每個程式都有一個自己的記憶體空間,程式之間的通訊就變得非常麻煩(比如要共享某些資料)而且執行不同程式會產生上下文切換,非常耗時,那麼有沒有一種更好地方案呢?

後來,執行緒橫空出世,一個程式可以有多個執行緒,執行緒是程式執行中一個單一的順序控制流程,現線上程才是程式執行流的最小單元,各個執行緒之間共享程式的記憶體空間(也就是所在程式的記憶體空間),上下文切換速度也高於程式。

現在有這樣一個問題:

public static void main(String[] args) {
    int[] arr = new int[]{3, 1, 5, 2, 4};
    //請將上面的陣列按升序輸出
}

按照正常思維,我們肯定是這樣:

public static void main(String[] args) {
    int[] arr = new int[]{3, 1, 5, 2, 4};
		//直接排序吧
    Arrays.sort(arr);
    for (int i : arr) {
        System.out.println(i);
    }
}

而我們學習了多執行緒之後,可以換個思路來實現:

public static void main(String[] args) {
    int[] arr = new int[]{3, 1, 5, 2, 4};

    for (int i : arr) {
        new Thread(() -> {
            try {
                Thread.sleep(i * 1000);   //越小的數休眠時間越短,優先被列印
                System.out.println(i);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
    }
}

我們接觸過的很多框架都在使用多執行緒,比如Tomcat伺服器,所有使用者的請求都是通過不同的執行緒來進行處理的,這樣我們的網站才可以同時響應多個使用者的請求,要是沒有多執行緒,可想而知伺服器的處理效率會有多低。


在Java 5的時候,新增了java.util.concurrent(JUC)包,其中包括大量用於多執行緒程式設計的工具類,目的是為了更好的支援高併發任務,讓開發者進行多執行緒程式設計時減少競爭條件和死鎖的問題!


併發與並行

我們經常聽到併發程式設計,那麼這個併發代表的是什麼意思呢?而與之相似的並行又是什麼意思?它們之間有什麼區別?

比如現在一共有三個工作需要我們去完成。

image-20220301213510841

順序執行

順序執行其實很好理解,就是我們依次去將這些任務完成了:

image-20220301213629649

實際上就是我們同一時間只能處理一個任務,所以需要前一個任務完成之後,才能繼續下一個任務,依次完成所有任務。

併發執行

併發執行也是我們同一時間只能處理一個任務,但是我們可以每個任務輪著做(時間片輪轉):

image-20220301214032719

而我們Java中的執行緒,正是這種機制,當我們需要同時處理上百個上千個任務時,很明顯CPU的數量是不可能趕得上我們的執行緒數的,所以說這時就要求我們的程式有良好的併發效能,來應對同一時間大量的任務處理。

學習Java併發程式設計,能夠讓我們在以後的實際場景中,知道該如何應對高併發的情況。

並行執行

並行執行就突破了同一時間只能處理一個任務的限制,我們同一時間可以做多個任務:

image-20220301214238743

比如我們要進行一些排序操作,就可以用到平行計算,只需要等待所有子任務完成,最後將結果彙總即可。包括分散式計算模型MapReduce,也是採用的平行計算思路。


再談鎖機制

談到鎖機制,相信各位應該並不陌生了,我們在JavaSE階段,通過使用synchronized關鍵字來實現鎖,

這樣就能夠很好地解決執行緒之間爭搶資源的情況。

那麼,synchronized底層到底是如何實現的呢?

我們知道,使用synchronized,一定是和某個物件相關聯的,比如我們要對某一段程式碼加鎖,那麼我們就需要提供一個物件來作為鎖本身

public static void main(String[] args) {
    synchronized (Main.class) {
        //這裡使用的是Main類的Class物件作為鎖
    }
}

我們來看看,它變成位元組碼之後會用到哪些指令:

image-20220302111724784

其中最關鍵的就是monitorenter指令了,可以看到之後也有monitorexit與之進行匹配(注意這裡有2個),monitorentermonitorexit分別對應加鎖和釋放鎖,在執行monitorenter之前需要嘗試獲取鎖。

每個物件都有一個monitor監視器與之對應,而這裡正是去獲取物件監視器的所有權,一旦monitor所有權被某個執行緒持有,那麼其他執行緒將無法獲得(管程模型的一種實現)。


在程式碼執行完成之後,我們可以看到,一共有兩個monitorexit在等著我們,那麼為什麼這裡會有兩個呢?

按理說monitorentermonitorexit不應該一一對應嗎,這裡為什麼要釋放鎖兩次呢?

首先我們來看第一個,這裡在釋放鎖之後,會馬上進入到一個goto指令,

跳轉到15行,而我們的15行對應的指令就是方法的返回指令,其實正常情況下只會執行第一個monitorexit釋放鎖,在釋放鎖之後就接著同步程式碼塊後面的內容繼續向下執行了。

而第二個,其實是用來處理異常的,可以看到,它的位置是在12行,如果程式執行發生異常,那麼就會執行第二個monitorexit,並且會繼續向下通過athrow指令丟擲異常,而不是直接跳轉到15行正常執行下去。

image-20220302114613847


實際上synchronized使用的鎖就是儲存在Java物件頭中的,我們知道,物件是存放在堆記憶體中的,而每個物件內部,都有一部分空間用於儲存物件頭資訊。

而物件頭資訊中,則包含了Mark Word用於存放hashCode和物件的鎖資訊,在不同狀態下,它儲存的資料結構有一些不同。

image-20220302203846868

重量級鎖

在JDK6之前,synchronized一直被稱為重量級鎖,monitor依賴於底層作業系統的Lock實現。

Java的執行緒是對映到作業系統的原生執行緒上,切換成本較高。而在JDK6之後,鎖的實現得到了改進。

我們先從最原始的重量級鎖開始:

我們說了,每個物件都有一個monitor與之關聯,在Java虛擬機器(HotSpot)中,monitor是由ObjectMonitor實現的:

ObjectMonitor() {
    _header       = NULL;
    _count        = 0; //記錄個數
    _waiters      = 0,
    _recursions   = 0;
    _object       = NULL;
    _owner        = NULL;
    _WaitSet      = NULL; //處於wait狀態的執行緒,會被加入到_WaitSet
    _WaitSetLock  = 0 ;
    _Responsible  = NULL ;
    _succ         = NULL ;
    _cxq          = NULL ;
    FreeNext      = NULL ;
    _EntryList    = NULL ; //處於等待鎖block狀態的執行緒,會被加入到該列表
    _SpinFreq     = 0 ;
    _SpinClock    = 0 ;
    OwnerIsThread = 0 ;
}

每個等待鎖的執行緒都會被封裝成ObjectWaiter物件,進入到如下機制:

img


設計思路

ObjectWaiter首先會進入 Entry Set等著,

當執行緒獲取到物件的monitor後進入 The Owner 區域並把monitor中的owner變數設定為當前執行緒,

同時monitor中的計數器count加1,若執行緒呼叫wait()方法,將釋放當前持有的monitorowner變數恢復為nullcount自減1,

同時該執行緒進入 WaitSet集合中等待被喚醒。若當前執行緒執行完畢也將釋放monitor並復位變數的值,以便其他執行緒進入獲取物件的monitor


雖然這樣的設計思路非常合理,但是在大多數應用上,每一個執行緒佔用同步程式碼塊的時間並不是很長,我們完全沒有必要將競爭中的執行緒掛起然後又喚醒,並且現代CPU基本都是多核心執行的,我們可以採用一種新的思路來實現鎖。


新思路

在JDK1.4.2時,引入了自旋鎖(JDK6之後預設開啟),它不會將處於等待狀態的執行緒掛起,而是通過無限迴圈的方式,不斷檢測是否能夠獲取鎖。

由於單個執行緒佔用鎖的時間非常短,所以說迴圈次數不會太多,可能很快就能夠拿到鎖並執行,這就是自旋鎖。

當然,僅僅是在等待時間非常短的情況下,自旋鎖的表現會很好,但是如果等待時間太長,由於迴圈是需要處理器繼續運算的,所以這樣只會浪費處理器資源,因此自旋鎖的等待時間是有限制的,預設情況下為10次,如果失敗,那麼會進而採用重量級鎖機制。

image-20220302163246988


在JDK6之後,自旋鎖得到了一次優化,自旋的次數限制不再是固定的,而是自適應變化的。

比如在同一個鎖物件上,自旋等待剛剛成功獲得過鎖,並且持有鎖的執行緒正在執行,那麼這次自旋也是有可能成功的,所以會允許自旋更多次。

當然,如果某個鎖經常都自旋失敗,那麼有可能會不再採用自旋策略,而是直接使用重量級鎖。

輕量級鎖

從JDK 1.6開始,為了減少獲得鎖和釋放鎖帶來的效能消耗,就引入了輕量級鎖。

輕量級鎖的目標是,在無競爭情況下,減少重量級鎖產生的效能消耗

(並不是為了代替重量級鎖,實際上就是賭一手同一時間只有一個執行緒在佔用資源),

包括系統呼叫引起的核心態與使用者態切換、執行緒阻塞造成的執行緒切換等。

它不像是重量級鎖那樣,需要向作業系統申請互斥量。


運作機制

在即將開始執行同步程式碼塊中的內容時,會首先檢查物件的Mark Word,檢視鎖物件是否被其他執行緒佔用,

如果沒有任何執行緒佔用,那麼會在當前執行緒中所處的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於複製並儲存物件目前的Mark Word資訊(官方稱為Displaced Mark Word)。

接著,虛擬機器將使用CAS操作將物件的Mark Word更新為輕量級鎖狀態(資料結構變為指向Lock Record的指標,指向的是當前的棧幀)

CAS(Compare And Swap)是一種無鎖演算法,

它並不會為物件加鎖,而是在執行的時候,看看當前資料的值是不是我們預期的那樣,

如果是,那就正常進行替換,

如果不是,那麼就替換失敗。

比如有兩個執行緒都需要修改變數i的值,預設為10,

現在一個執行緒要將其修改為20,另一個要修改為30,

如果他們都使用CAS演算法,那麼並不會加鎖訪問i,而是直接嘗試修改i的值,

但是在修改時,需要確認i是不是10,

如果是,表示其他執行緒還沒對其進行修改,

如果不是,那麼說明其他執行緒已經將其修改,此時不能完成修改任務,修改失敗。

在CPU中,CAS操作使用的是cmpxchg指令,能夠從最底層硬體層面得到效率的提升。

如果CAS操作失敗了的話,那麼說明可能這時有執行緒已經進入這個同步程式碼塊了,

這時虛擬機器會再次檢查物件的Mark Word,是否指向當前執行緒的棧幀,

如果是,說明不是其他執行緒,而是當前執行緒已經有了這個物件的鎖,直接放心大膽進同步程式碼塊即可。

如果不是,那確實是被其他執行緒佔用了。

這時,輕量級鎖一開始的想法就是錯的(這時有物件在競爭資源,已經賭輸了),所以說只能將鎖膨脹為重量級鎖,按照重量級鎖的操作執行(注意鎖的膨脹是不可逆的)

如圖所示:

image-20220302210830272

所以,輕量級鎖 -> 失敗 -> 自適應自旋鎖 -> 失敗 -> 重量級鎖

解鎖過程同樣採用CAS演算法,如果物件的MarkWord仍然指向執行緒的鎖記錄,

那麼就用CAS操作把物件的MarkWord和複製到棧幀中的Displaced Mark Word進行交換。

如果替換失敗,說明其他執行緒嘗試過獲取該鎖,在釋放鎖的同時,需要喚醒被掛起的執行緒。

偏向鎖

偏向鎖相比輕量級鎖更純粹,乾脆就把整個同步都消除掉,不需要再進行CAS操作了。

它的出現主要是得益於人們發現某些情況下某個鎖頻繁地被同一個執行緒獲取,

這種情況下,我們可以對輕量級鎖進一步優化。


偏向鎖實際上就是專門為單個執行緒而生的,當某個執行緒第一次獲得鎖時,如果接下來都沒有其他執行緒獲取此鎖,那麼持有鎖的執行緒將不再需要進行同步操作。

可以從之前的MarkWord結構中看到,偏向鎖也會通過CAS操作記錄執行緒的ID,如果一直都是同一個執行緒獲取此鎖,那麼完全沒有必要在進行額外的CAS操作。

當然,如果有其他執行緒來搶了,那麼偏向鎖會根據當前狀態,決定是否要恢復到未鎖定或是膨脹為輕量級鎖。

如果我們需要使用偏向鎖,可以新增-XX:+UseBiased引數來開啟。

所以,最終的鎖等級為:未鎖定 < 偏向鎖 < 輕量級鎖 < 重量級鎖

值得注意的是,如果物件通過呼叫hashCode()方法計算過物件的一致性雜湊值,

那麼它是不支援偏向鎖的,會直接進入到輕量級鎖狀態,

因為Hash是需要被儲存的,而偏向鎖的Mark Word資料結構,無法儲存Hash值;

如果物件已經是偏向鎖狀態,再去呼叫hashCode()方法,那麼會直接將鎖升級為重量級鎖,並將雜湊值存放在monitor(有預留位置儲存)中。

image-20220302214647735

鎖消除和鎖粗化

鎖消除和鎖粗化都是在執行時的一些優化方案。

比如我們某段程式碼雖然加了鎖,但是在執行時根本不可能出現各個執行緒之間資源爭奪的情況,

這種情況下,完全不需要任何加鎖機制,所以鎖會被消除。

鎖粗化則是我們程式碼中頻繁地出現互斥同步操作,比如在一個迴圈內部加鎖,這樣明顯是非常消耗效能的,所以虛擬機器一旦檢測到這種操作,會將整個同步範圍進行擴充套件。


JMM記憶體模型

注意這裡提到的記憶體模型和我們在JVM中介紹的記憶體模型不在同一個層次,

JVM中的記憶體模型是虛擬機器規範對整個記憶體區域的規劃,

而Java記憶體模型,是在JVM記憶體模型之上的抽象模型,具體實現依然是基於JVM記憶體模型實現的,以前的文章有介紹。

https://www.cnblogs.com/zwtblog/tag/,側邊欄支援搜尋。

Java記憶體模型

我們在計算機組成原理中學習過,在我們的CPU中,一般都會有快取記憶體,而它的出現,是為了解決記憶體的速度跟不上處理器的處理速度的問題。

所以CPU內部會新增一級或多級快取記憶體來提高處理器的資料獲取效率,

但是這樣也會導致一個很明顯的問題,因為現在基本都是多核心處理器,每個處理器都有一個自己的快取記憶體,那麼又該怎麼去保證每個處理器的快取記憶體內容一致呢?

image-20220303113148313


為了解決快取一致性的問題,需要各個處理器訪問快取時都遵循一些協議,在讀寫時要根據協議來進行操作。

這類協議有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。

而Java也採用了類似的模型來實現支援多執行緒的記憶體模型:

image-20220303114228749


JMM(Java Memory Model)記憶體模型規定如下:

  • 所有的變數全部儲存在主記憶體(注意這裡包括下面提到的變數,指的都是會出現競爭的變數,包括成員變數、靜態變數等,而區域性變數這種屬於執行緒私有,不包括在內)
  • 每條執行緒有著自己的工作記憶體(可以類比CPU的快取記憶體)執行緒對變數的所有操作,必須在工作記憶體中進行,不能直接操作主記憶體中的資料。
  • 不同執行緒之間的工作記憶體相互隔離,如果需要線上程之間傳遞內容,只能通過主記憶體完成,無法直接訪問對方的工作記憶體。

也就是說,每一條執行緒如果要操作主記憶體中的資料,那麼得先拷貝到自己的工作記憶體中,並對工作記憶體中資料的副本進行操作,操作完成之後,也需要從工作副本中將結果拷貝回主記憶體中,具體的操作就是Save(儲存)和Load(載入)操作。

那麼各位肯定會好奇,這個記憶體模型,結合之前JVM所講的內容,具體是怎麼實現的呢?

  • 主記憶體:對應堆中存放物件的例項的部分。

  • 工作記憶體:對應執行緒的虛擬機器棧的部分割槽域,虛擬機器可能會對這部分記憶體進行優化,

    將其放在CPU的暫存器或是快取記憶體中。

    比如在訪問陣列時,由於陣列是一段連續的記憶體空間,

    所以可以將一部分連續空間放入到CPU快取記憶體中,那麼之後如果我們順序讀取這個陣列,那麼大概率會直接快取命中。

前面我們提到,在CPU中可能會遇到快取不一致的問題,而Java中,也會遇到,比如下面這種情況:

public class Main {
    private static int i = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            for (int j = 0; j < 100000; j++) i++;
            System.out.println("執行緒1結束");
        }).start();
        new Thread(() -> {
            for (int j = 0; j < 100000; j++) i++;
            System.out.println("執行緒2結束");
        }).start();
        //等上面兩個執行緒結束
        Thread.sleep(1000);
        System.out.println(i);
    }
}

可以看到這裡是兩個執行緒同時對變數i各自進行100000次自增操作,但是實際得到的結果並不是我們所期望的那樣。


那麼為什麼會這樣呢?

在之前學習了JVM之後,相信各位應該已經知道,自增操作實際上並不是由一條指令完成的(注意一定不要理解為一行程式碼就是一個指令完成的):

image-20220303143131899


包括變數i的獲取、修改、儲存,都是被拆分為一個一個的操作完成的,那麼這個時候就有可能出現在修改完儲存之前,另一條執行緒也儲存了,但是當前執行緒是毫不知情的。

image-20220303144344450

所以說,在JavaSE階段講解這個問題的時候,是通過synchronized關鍵字新增同步程式碼塊解決的,另外的解決方案(原子類)。

重排序

在編譯或執行時,為了優化程式的執行效率,編譯器或處理器常常會對指令進行重排序,有以下情況:

  1. 編譯器重排序:Java編譯器通過對Java程式碼語義的理解,根據優化規則對程式碼指令進行重排序。
  2. 機器指令級別的重排序:現代處理器很高階,能夠自主判斷和變更機器指令的執行順序。

令重排序能夠在不改變結果(單執行緒)的情況下,優化程式的執行效率,比如:

public static void main(String[] args) {
    int a = 10;
    int b = 20;
    System.out.println(a + b);
}

我們其實可以交換第一行和第二行:

public static void main(String[] args) {
    int b = 10;
    int a = 20;
    System.out.println(a + b);
}

即使發生交換,但是我們程式最後的執行結果是不會變的,當然這裡只通過程式碼的形式演示,實際上JVM在執行位元組碼指令時也會進行優化,可能兩個指令並不會按照原有的順序進行。

雖然單執行緒下指令重排確實可以起到一定程度的優化作用,但是在多執行緒下,似乎會導致一些問題:

public class Main {
    private static int a = 0;
    private static int b = 0;
    public static void main(String[] args) {
        new Thread(() -> {
            if(b == 1) {
                if(a == 0) {
                    System.out.println("A");
                }else {
                    System.out.println("B");
                }   
            }
        }).start();
        new Thread(() -> {
            a = 1;
            b = 1;
        }).start();
    }
}

上面這段程式碼,在正常情況下,按照我們的正常思維,是不可能輸出A的,因為只要b等於1,那麼a肯定也是1才對,因為a是在b之前完成的賦值。

但是,如果進行了重排序,那麼就有可能,a和b的賦值發生交換,b先被賦值為1,而恰巧這個時候,執行緒1開始判定b是不是1了,這時a還沒來得及被賦值為1,可能執行緒1就已經走到列印那裡去了,所以,是有可能輸出A的。

volatile關鍵字

關鍵字volatile,開始之前我們先介紹三個詞語:

  • 原子性:其實之前講過很多次了,就是要做什麼事情要麼做完,要麼就不做,不存在做一半的情況。
  • 可見性:指當多個執行緒訪問同一個變數時,一個執行緒修改了這個變數的值,其他執行緒能夠立即看得到修改的值。
  • 有序性:即程式執行的順序按照程式碼的先後順序執行。

我們之前說了,如果多執行緒訪問同一個變數,那麼這個變數會被執行緒拷貝到自己的工作記憶體中進行操作,而不是直接對主記憶體中的變數本體進行操作。

下面這個操作看起來是一個有限迴圈,但是是無限的:

public class Main {
    private static int a = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (a == 0);
            System.out.println("執行緒結束!");
        }).start();

        Thread.sleep(1000);
        System.out.println("正在修改a的值...");
        a = 1;   //很明顯,按照我們的邏輯來說,a的值被修改那麼另一個執行緒將不再迴圈
    }
}

實際上這就是我們之前說的,雖然我們主執行緒中修改了a的值,但是另一個執行緒並不知道a的值發生了改變,所以迴圈中依然是使用舊值在進行判斷,因此,普通變數是不具有可見性的。


要解決這種問題,我們第一個想到的肯定是加鎖,同一時間只能有一個執行緒使用,這樣總行了吧,確實,這樣的話肯定是可以解決問題的:

public class Main {
    private static int a = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (a == 0) {
                synchronized (Main.class){}
            }
            System.out.println("執行緒結束!");
        }).start();

        Thread.sleep(1000);
        System.out.println("正在修改a的值...");
        synchronized (Main.class){
            a = 1;
        }
    }
}

但是,除了硬加一把鎖的方案,我們也可以使用volatile關鍵字來解決。

此關鍵字的第一個作用,就是保證變數的可見性。

當寫一個volatile變數時,JMM會把該執行緒本地記憶體中的變數強制重新整理到主記憶體中去,並且這個寫會操作會導致其他執行緒中的volatile變數快取無效。

這樣,另一個執行緒修改了這個變時,當前執行緒會立即得知,並將工作記憶體中的變數更新為最新的版本。

那麼我們就來試試看:

public class Main {
    //新增volatile關鍵字
    private static volatile int a = 0;
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> {
            while (a == 0);
            System.out.println("執行緒結束!");
        }).start();

        Thread.sleep(1000);
        System.out.println("正在修改a的值...");
        a = 1;
    }
}

結果還真的如我們所說的那樣,當a發生改變時,迴圈立即結束。

當然,雖然說volatile能夠保證可見性,但是不能保證原子性,要解決我們上面的i++的問題,可以使用加鎖來完成:

public class Main {
    private static volatile int a = 0;
    public static void main(String[] args) throws InterruptedException {
        Runnable r = () -> {
            for (int i = 0; i < 10000; i++) a++;
            System.out.println("任務完成!");
        };
        new Thread(r).start();
        new Thread(r).start();

        //等待執行緒執行完成
        Thread.sleep(1000);
        System.out.println(a);
    }
}

volatile不是能在改變變數的時候其他執行緒可見嗎,那為什麼還是不能保證原子性呢?

還是那句話,自增操作是被瓜分為了多個步驟完成的,雖然保證了可見性,但是隻要手速夠快,依然會出現兩個執行緒同時寫同一個值的問題(比如執行緒1剛剛將a的值更新為100,這時執行緒2可能也已經執行到更新a的值這條指令了,已經剎不住車了,所以依然會將a的值再更新為一次100)

那要是真的遇到這種情況,那麼我們不可能都去寫個鎖吧?後面,我們會介紹原子類來專門解決這種問題。


最後一個功能就是volatile會禁止指令重排,也就是說,如果我們操作的是一個volatile變數,它將不會出現重排序的情況.

那麼它是怎麼解決的重排序問題呢?

若用volatile修飾共享變數,在編譯時,會在指令序列中插入記憶體屏障來禁止特定型別的處理器重排序

記憶體屏障(Memory Barrier)又稱記憶體柵欄,是一個CPU指令,它的作用有兩個:

  1. 保證特定操作的順序
  2. 保證某些變數的記憶體可見性(volatile的記憶體可見性,其實就是依靠這個實現的)

由於編譯器和處理器都能執行指令重排的優化,

如果在指令間插入一條Memory Barrier則會告訴編譯器和CPU,

不管什麼指令都不能和這條Memory Barrier指令重排序。

image-20220303172519404

屏障型別 指令示例 說明
LoadLoad Load1;LoadLoad;Load2 保證Load1的讀取操作在Load2及後續讀取操作之前執行
StoreStore Store1;StoreStore;Store2 在Store2及其後的寫操作執行前,保證Store1的寫操作已重新整理到主記憶體
LoadStore Load1;LoadStore;Store2 在Store2及其後的寫操作執行前,保證Load1的讀操作已讀取結束
StoreLoad Store1;StoreLoad;Load2 保證load1的寫操作已重新整理到主記憶體之後,load2及其後的讀操作才能執行

所以volatile能夠保證,之前的指令一定全部執行,之後的指令一定都沒有執行,並且前面語句的結果對後面的語句可見。


最後我們來總結一下volatile關鍵字的三個特性:

  • 保證可見性
  • 不保證原子性
  • 防止指令重排

在之後我們的設計模式系列視訊中,還會講解單例模式下volatile的運用。

happens-before原則

經過我們前面的講解,相信各位已經瞭解了JMM記憶體模型以及重排序等機制帶來的優點和缺點.

綜上,JMM提出了happens-before(先行發生)原則,定義一些禁止編譯優化的場景,來向各位程式設計師做一些保證,只要我們是按照原則進行程式設計,那麼就能夠保持併發程式設計的正確性。具體如下:

  • 程式次序規則:同一個執行緒中,按照程式的順序,前面的操作happens-before後續的任何操作。

    • 同一個執行緒內,程式碼的執行結果是有序的。

      其實就是,可能會發生指令重排,但是保證程式碼的執行結果一定是和按照順序執行得到的一致,

      程式前面對某一個變數的修改一定對後續操作可見的,不可能會出現前面才把a修改為1,

      接著讀a居然是修改前的結果,這也是程式執行最基本的要求。

  • 監視器鎖規則:對一個鎖的解鎖操作,happens-before後續對這個鎖的加鎖操作。

    • 就是無論是在單執行緒環境還是多執行緒環境,

      對於同一個鎖來說,一個執行緒對這個鎖解鎖之後,另一個執行緒獲取了這個鎖都能看到前一個執行緒的操作結果。

      比如前一個執行緒將變數x的值修改為了12並解鎖,

      之後另一個執行緒拿到了這把鎖,對之前執行緒的操作是可見的,可以得到x是前一個執行緒修改後的結果12(所以synchronized是有happens-before規則的)

  • volatile變數規則:對一個volatile變數的寫操作happens-before後續對這個變數的讀操作。

    • 就是如果一個執行緒先去寫一個volatile變數,緊接著另一個執行緒去讀這個變數,那麼這個寫操作的結果一定對讀的這個變數的執行緒可見。
  • 執行緒啟動規則:主執行緒A啟動執行緒B,執行緒B中可以看到主執行緒啟動B之前的操作。

    • 在主執行緒A執行過程中,啟動子執行緒B,那麼執行緒A在啟動子執行緒B之前對共享變數的修改結果對執行緒B可見。
  • 執行緒加入規則:如果執行緒A執行操作join()執行緒B併成功返回,那麼執行緒B中的任意操作happens-before執行緒Ajoin()操作成功返回。

  • 傳遞性規則:如果A happens-before B,B happens-before C,那麼A happens-before C。


那麼我們來從happens-before原則的角度,來解釋一下下面的程式結果:

public class Main {
    private static int a = 0;
  	private static int b = 0;
    public static void main(String[] args) {
        a = 10;
        b = a + 1;
        new Thread(() -> {
          if(b > 10) System.out.println(a); 
        }).start();
    }
}

首先我們定義以上出現的操作:

  • A:將變數a的值修改為10
  • B:將變數b的值修改為a + 1
  • C:主執行緒啟動了一個新的執行緒,並在新的執行緒中獲取b,進行判斷,如果為true那麼就列印a

首先我們來分析,由於是同一個執行緒,並且B是一個賦值操作且讀取了A

那麼按照程式次序規則,A happens-before B,接著在B之後,馬上執行了C,按照執行緒啟動規則

在新的執行緒啟動之前,當前執行緒之前的所有操作對新的執行緒是可見的,

所以 B happens-before C,

最後根據傳遞性規則,由於A happens-before B,B happens-before C,所以A happens-before C,因此在新的執行緒中會輸出a修改後的結果10

更新-後續見:https://www.cnblogs.com/zwtblog/JUC https://www.cnblogs.com/zwtblog/tag/多執行緒/

相關文章