併發程式設計之:JMM

黑子的學習筆記發表於2021-08-28

併發程式設計之:JMM

大家好,我是小黑,一個在網際網路苟且偷生的農民工。

上一期給大家分享了關於Java中執行緒相關的一些基礎知識。在關於執行緒終止的例子中,第一個方法講到要想終止一個執行緒,可以使用標誌位的方法,我們再來回顧一下程式碼。

class MyRunnable implements Runnable {
    // volatile關鍵字,保證主執行緒修改後當前執行緒能夠看到被改後的值(可見性)
    private volatile boolean exit = false; 
    @Override
    public void run() {
        while (!exit) { // 迴圈判斷標識位,是否需要退出
            System.out.println("這是我自定義的執行緒");
        }
    }
    public void setExit(boolean exit) {
        this.exit = exit;
    }
}
public class ThreadDemo {
    public static void main(String[] args) {
        MyRunnable runnable = new MyRunnable();
        new Thread(runnable).start();
        runnable.setExit(true); //修改標誌位,退出執行緒
    }
}

在這個程式碼中,標誌位exit欄位在宣告時使用了volatile關機字修飾,目的是為了保證在另外一個執行緒修改後當前執行緒能夠感知到變化,那麼這個關鍵字到底做了些什麼呢?這一期我們來詳細聊一聊。

在開始講volatile關鍵字之前,需要先和大家聊一聊計算機的記憶體模型這個玩意兒。

計算機的記憶體模型

所謂記憶體模型,英文描述是Memory Model,這玩意兒是一個比較底層的東西,它是與計算機硬體有關的一個概念。

我們都知道,計算機在執行程式的時候,最終是一條條的指令在CPU中執行,在執行過程中往往會存在資料的傳遞。而資料是存放在主記憶體上的,對,就是你那個記憶體條。

在剛開始CPU的的執行速度還不夠快的時候並沒有什麼問題,但隨著CPU技術的不斷髮展,CPU計算的速度越來越快,但是呢,從主記憶體上讀取和寫入資料的速度有點拉胯,跟不上呀,這就導致CPU每次操作主記憶體都要花費很多的等待時間。

技術總是要往前發展的,不能因為記憶體讀寫慢CPU就不發展了吧,也不能讓主記憶體的讀寫速度成為瓶頸。

想必這裡大家也應該想到了,就是在CPU和主記憶體之間加一個快取記憶體,將需要的資料在這個快取記憶體上覆制一份,而這個快取記憶體的特點就是讀寫很快,然後定期的將快取中的資料和主記憶體同步。

image

到這裡問題就解決了嗎? too young,too simple啊,這種結構在但執行緒的情況下是沒有問題的,隨著計算機能力不斷提升,開始支援多執行緒了,並且CPU牛逼到支援多核,到現在的4核8核16核,在這種情況下是會存在一些問題的,我們來分析一下。

單核多執行緒情況:多個執行緒同時訪問一個共享資料,CPU將資料從主記憶體載入到快取記憶體中,多個執行緒會訪問快取記憶體中的同一個地址,這樣即使線上程切換時,快取資料也不會失效,因為在單核CPU同一時間只能有一個執行緒在執行,所以也不會有資料訪問的衝突。

多核多執行緒情況:每個CPU核心都會複製一份資料到自己的快取記憶體,這樣的話在不同核心上的兩個執行緒是並行的,這樣就會導致兩個核心各自快取的資料發生不一致。這個問題就叫做快取一致性問題

image

除了上面說到的快取一致性問題,計算機為了使CPU的算力能夠被充分利用,會對輸入的指令進行亂序處理,叫做處理器優化。很多的程式語言為了提高執行效率,也會對程式碼的執行順序重新排序,比如我們們Java虛擬機器的即時編譯器(JIT)也會做,這個動作叫做指令重排

int a = 1;
int b = 2;
int c = a + b;
int d = a - b;

比如我們寫的這段程式碼,第三行和第四行的執行順序就有可能發生改變,這在單執行緒中並沒有問題,但是在多執行緒情況下,會產生和我們預期不一樣的結果。

其實上面提出的快取一致性問題,處理器優化,指令重排就對應我們併發程式設計中的可見性問題,原子性問題,有序性問題。帶著這些問題,我們再來看看,在Java中是如何來解決的。

因為存在這些問題,那麼肯定要有一種機制來解決。這種解決的機制就是記憶體模型

記憶體模型定義了一個規範,用來保證共享記憶體的可見性,有序性,原子性。記憶體模型是怎麼解決的呢?主要採取兩種方式:限制處理器優化記憶體屏障。這裡我們先不深究底層原理。

JMM

從前面我們知道記憶體模型是一個規範,用來解決併發情況下的一些問題。不同的程式語言對於這個規範都有對應的實現。那麼JMM(Java Memory Model)就是Java語言對於這一規範的具體實現。

那麼JMM具體是如何解決這寫問題的呢?我們先來看下面這張圖。

image

記憶體可見性問題

我們一個一個問題來看,首先,如何解決可見性問題

如上圖所示,在JMM中,一個執行緒對於一個資料的操作,分成了6個步驟。

分別是:read,load,use,assign,write,store.

如果說這個變數在宣告時,沒有使用volatile關鍵字,那麼兩個執行緒是各自複製一份到工作記憶體,執行緒B將flag賦值為true,執行緒A是不可見的。

那麼要想執行緒A可見,就需要在宣告flag這個變數時,加上volatile關鍵字。那麼加了關鍵字之後JMM是怎麼做的呢?這裡要分讀和寫兩個情況。

  1. 執行緒在讀取一個volatile變數時,JMM會把工作記憶體中的該變數置為無效,重新從主記憶體中讀取;
  2. 執行緒在寫一個volatile變數時,會立刻將工作記憶體中的值重新整理到主記憶體中。

也就是說,對於volatile關鍵字修飾的變數,在read,load,use操作必須是一起執行的;assign,write,store操作時一起執行。

通過這樣的方式,就能夠解決記憶體可見性的問題。

指令重排

而指令重排這個問題,對於編譯器來說,只要該物件宣告為volatile的,那麼就不會對它進行指令重排的優化。

而volatile禁止指令重排的這種規則是符合一個叫做happens-before的規則。

happens-before除了在volatile變數規則外,還有一些其他規則。

程式次序規則:在一個執行緒內一段程式碼的執行結果是有序的。就是還會指令重排,但是隨便它怎麼排,結果是按照我們程式碼的順序生成的不會變。

管程鎖定規則:就是無論是在單執行緒環境還是多執行緒環境,對於同一個鎖來說,一個執行緒對這個鎖解鎖之後,另一個執行緒獲取了這個鎖都能看到前一個執行緒的操作結果!(管程是一種通用的同步原語,synchronized就是管程的實現)

volatile變數規則:就是如果一個執行緒先去寫一個volatile變數,然後一個執行緒去讀這個變數,那麼這個寫操作的結果一定對讀的這個執行緒可見。

執行緒啟動規則:在主執行緒A執行過程中,啟動子執行緒B,那麼執行緒A在啟動子執行緒B之前對共享變數的修改結果對執行緒B可見。

執行緒終止規則:在主執行緒A執行過程中,子執行緒B終止,那麼執行緒B在終止之前對共享變數的修改結果線上程A中可見。也稱執行緒join()規則。

執行緒中斷規則:對執行緒interrupt()方法的呼叫先行發生於被中斷執行緒程式碼檢測到中斷事件的發生,可以通過Thread.interrupted()檢測到是否發生中斷。

傳遞性規則:happens-before原則具有傳遞性,即hb(A, B) , hb(B, C),那麼hb(A, C)。

物件終結規則:一個物件的初始化的完成,也就是建構函式執行的結束一定 happens-before它的finalize()方法。

競態條件

到這裡,大家是不是感覺問題已經都解決了?emmm,我們來看下面這個場景:

image

假設上圖中的執行緒A和執行緒B執行在兩個CPU核心上,是並行執行的,它們一起讀取到i的值等於0,然後各自加1,然後一起往主記憶體寫。如果執行緒A和執行緒B是有先後順序執行的,i的值最後應該是等於2才對,但是並行情況下是有可能同時操作的,最後寫回到主記憶體中的值只被增加了一次。

這就好比你的銀行卡收到了兩筆100塊的轉賬,但是賬戶上只多了100塊。

對於這種問題通過volatile是無法解決的,volatile不會保證該變數操作的原子性。那我們應該怎麼解決呢,就需要使用synchronized對這個操作加鎖,保證同一時刻只能有一個執行緒進行操作。

總結

因為CPU和記憶體之間存在著快取記憶體,在多執行緒併發情況下,可能會存在快取一致性問題;而CPU對於輸入的指令會做一些處理器優化,一些高階語言的編譯器也會做指令重排。因為這些問題,會導致我們在併發情況下存在記憶體可見性問題,有序性問題,而JMM就是Java中為了解決這些問題而出現的。通過volatile關鍵字可以保證記憶體可見性,並且會禁止指令重排。但是volatile只能保證操作的有序性,無法保證操作的原子性,所以,為了安全,我們對於共享變數的併發處理要進行加鎖。


好的,今天的內容就到這裡,我們下期再見。

相關文章