學習筆記(3)——Java如何解決併發編程線程切換導致的原子性問題

  上一篇筆記寫了如何解決併發導致的三個問題其中兩個:緩存導致的可見性問題和編譯優化導致的順序性問題,我們可以通過按需進行禁用緩存和編譯優化來解決。指導我們如何按需禁用引出java內存模型的概念。那麼這篇筆記的主要目的是討論如何解決線程切換導致的原子性問題。


思考如何解決原子性問題:

原子性:一個或者多個操作在CPU上執行不被中斷的特性

很顯然,原子性問題是線程切換導致的,我們只要禁用線程切換就可以解決原子性問題。而線程切換依賴於CPU中斷,所以我們禁用線程切換需要禁用CPU中斷來實現。

在早起單核CPU場景下這個方案是可行的,但多核場景就不能適用了。舉個栗子:

32位CPU寫long類型的數據,我們知道long類型的數據是64位的,在32位CPU上寫操作被分成兩部分,一次寫高32位,一次寫低32位:

1.單核場景下,我們禁用了CPU中斷,操作系統不會調度其他線程來完成兩次寫操作,要麼都被執行,要麼都沒被執行。我沒說這具有原子性。

2.多核場景下,加入兩個線程同時在CPU1和CPU2上執行long類型變量在32未機器上的高32位寫操作,此時我們禁止CPU中斷只是保證了同一CPU上只有一個線程持續執行。但是由於這個變量指向的內存空間是同一塊,所以可能會出現交叉執行的情況出現,最終導致我們讀的值跟寫的不一樣的詭異情況。

其實其他兩個問題解決的原理都一樣,就是保證多核併發情況下程序執行的結果跟在單核單線程執行結果情況保持一致。那麼原子性問題的解決方案就很明顯了,不管是多核還是單核我們只需要保證同一時間只有一個線程在運行, 這種方案叫做互斥。對同一共享變量的修改是互斥的。這樣就能保證操作的原子性。

一、互斥鎖

簡易鎖模型

我們把需要互斥執行的代碼叫臨界區代碼,在臨界區代碼執行之前嘗試加鎖lock(),如果成功獲得鎖則進入臨界區,否則等待獲得鎖的線程釋放鎖。執行完成unlock()釋放鎖。

這裏有兩個重要的問題需要弄清楚:

1.我們鎖的是什麼?

2.我們保護的是什麼?

改進後的鎖模型

以現實中的鎖爲例,我們的鎖和被保護的對象是對應的。程序中的鎖和保護的共享資源應該是對應的。上面的簡易鎖模型是沒有體現出來的。下面介紹一種改進後的鎖模型:

我們要保護的資源是R,我們創建一把鎖lock(LR)用來保護R,執行完臨界區後解鎖unlock(LR)。這點很重要,我們要明確哪把鎖鎖的是那塊資源,這是對應的。

Java語言提供的鎖 synchronized

synchronized關鍵字可以修飾方法和代碼塊

具體使用方法這裏可以舉個栗子:


class X {
  // 修飾非靜態方法
  synchronized void foo() {
    // 臨界區
    // 鎖定this
  }
  // 修飾靜態方法
  synchronized static void bar() {
    // 臨界區
    // 鎖定 class X
  }
  // 修飾代碼塊
  Object obj = new Object();
  void baz() {
    synchronized(obj) {
      // 臨界區
      // 鎖定obj
    }
  }
}  

                             注:對synchronized的用法詳細介紹,可以看之前的博文介紹。

我們上文所說的lock(),unlock()是java自動加上的,沒有顯式操作。

從例子可以看出,修飾代碼塊的時候鎖了一個Object。那麼修飾方法的時候鎖的是什麼呢?這兒引出Java的一個默認規則:

當修飾靜態方法的時候,鎖定的是當前類的 Class 對象,在上面的例子中就是 Class X;

當修飾非靜態方法的時候,鎖定的是當前實例對象 this。

上面的例子修飾靜態方法時相當於:


class X {
  // 修飾靜態方法
  synchronized(X.class) static void bar() {
    // 臨界區
  }
}

意思就是Class X的所有對象都共用一把鎖,是公有的。

修飾非靜態方法時:


class X {
  // 修飾非靜態方法
  synchronized(this) void foo() {
    // 臨界區
  }
}

意思就是說每個Class X的對象都有一把鎖。

 

用 synchronized 解決 count+=1 問題

上一篇介紹這個問題導致的原子性問題,下面用一個例子:


class SafeCalc {
  long value = 0L;
  long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}

synchronized 是修飾一般方法,說明它是個對象鎖就是this。也就是說同一時間只可能有一個線程訪問該對象的addOne()方法。也就是說synchronized 的臨界區是互斥的。

這裏涉及到一個之前寫過的內存模型的Happens-before原則的其中一條

管程中鎖的規則:對一個鎖的解鎖 Happens-Before 於後續對這個鎖的加鎖。

這個規則的意思就是上一個解鎖操作對下一個加鎖操作是可見的。所以上一個線程進入臨界區對變量的操作對下一個線程進入臨界區是可見的。但是這裏我們忽略了一點就是這些操作是否對get()方法也是可見的?當然我們無法保證。

只需要給get()方法也加上鎖:


class SafeCalc {
  long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized void addOne() {
    value += 1;
  }
}

這樣get()方法也加上了this這把鎖,與addOne()方法使用同一把鎖。這樣就能保證get()和addOne()互斥,也就能保證,addOne()中對變量的操作對get()可見。

如果我們把變量和addOne()方法換成靜態的,會發生什麼呢?


class SafeCalc {
  static long value = 0L;
  synchronized long get() {
    return value;
  }
  synchronized static void addOne() {
    value += 1;
  }
}

這樣是不是就實現了用兩把鎖保護同一塊資源static long value。也就是說兩個方法不存在互斥關係,也就是說addOne()裏的操作是無法保證對get()裏的操作可見的。很顯然這樣是不對的。

好吧,來總結一下:

多核CPU中禁止CPU切換來達到禁止線程切換,從而保證原子性是行不通的。然後就有了互斥鎖的應用,互斥鎖保護臨界區的資源,而且保證對釋放鎖後重新獲得鎖的線程的可見性。

鎖和共享資源之間的對應關係是1:N一把鎖可以鎖住一整箱的金子,一把鑰匙能否開這把鎖,是鎖來決定的,而且同一時間只有一把鑰匙能開這把鎖,去拿裏面的金子。這把鑰匙開了鎖後取走的或者放進去的金子是對後來開鎖的人可見的。

這裏涉及了synchronized的用法,以及內存模型的Happens-before原則。

 

聲明:該文章是學習總結文章,文中圖片已經代碼塊都來自於極客時間,王寶令老師的併發編程課。如有侵權,立即刪除。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章