1. 程式人生 > >【Java併發基礎】加鎖機制解決原子性問題

【Java併發基礎】加鎖機制解決原子性問題

前言

原子性指一個或多個操作在CPU執行的過程不被中斷的特性。前面提到原子性問題產生的源頭是執行緒切換,而執行緒切換依賴於CPU中斷。於是得出,禁用CPU中斷就可以禁止執行緒切換從而解決原子性問題。但是這種情況只適用於單核,多核時不適用。

以在 32 位 CPU 上執行 long 型變數的寫操作為例來說明。
long 型變數是 64 位,在 32 位 CPU 上執行寫操作會被拆分成兩次寫操作(寫高 32 位和寫低 32 位,如下圖所示,圖來自【參考1】)。

在單核 CPU 場景下,同一時刻只有一個執行緒執行,禁止 CPU 中斷,意味著作業系統不會重新排程執行緒,即禁止了執行緒切換,獲得 CPU 使用權的執行緒就可以不間斷地執行。所以兩次寫操作一定是:要麼都被執行,要麼都沒有被執行,具有原子性。

但是在多核場景下,同一時刻,可能有兩個執行緒同時在執行,一個執行緒執行在 CPU-1 上,一個執行緒執行在 CPU-2 上。此時禁止 CPU 中斷,只能保證 CPU 上的執行緒連續執行,並不能保證同一時刻只有一個執行緒執行。如果這兩個執行緒同時向記憶體寫 long 型變數高 32 位的話,那麼就會造成我們寫入的變數和我們讀出來的是不一致的。

所以解決原子性問題的重要條件還是為:同一時刻只能有一個執行緒對共享變數進行操作,即互斥。如果我們能夠保證對共享變數的修改是互斥的,那麼,無論是單核 CPU 還是多核 CPU,就都能保證原子性。

下面將介紹實現互斥訪問的方案,加鎖機制。

鎖模型

我們把一段需要互斥執行的程式碼稱為臨界區。

執行緒在進入臨界區之前,首先嚐試加鎖 lock(),如果成功,則進入臨界區,此時我們稱這個執行緒持有鎖;
否則就等待或阻塞,直到持有鎖的執行緒釋放鎖。持有鎖的執行緒執行完臨界區的程式碼後,執行解鎖 unlock()。

鎖和鎖要保護的資源是要對應的。這個指的是兩點:①我們要保護一個資源首先要建立一把鎖;②鎖要鎖對資源,即鎖A應該用來保護資源A,而不能用它來鎖資源B。

所以,最後的鎖模型如下:(圖來自【參考1】)

Java提供的鎖技術: synchronized

鎖是一種通用的技術方案,Java 語言提供的 synchronized關鍵字,就是鎖的一種實現。

synchronized 關鍵字可以用來修飾方法,也可以用來修飾程式碼塊,它的使用示例如下:

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

與上面的鎖模型比較,可以發現synchronized修飾的方法和程式碼塊都沒有顯式地有加鎖和釋放鎖操作。但是這並不代表沒有這兩個操作,這兩個操作Java編譯器會幫我們自動實現。Java 編譯器會在 synchronized 修飾的方法或程式碼塊前後自動加上加鎖 lock() 和解鎖 unlock(),這樣的好處在於程式碼更簡潔,並且Java程式設計師也不必擔心會忘記釋放鎖了。

然後我們再觀察可以發現:只有修飾程式碼塊的時候,鎖定了一個 obj 物件。那麼修飾方法的時候鎖了什麼呢?
這是Java的一個隱式規則:

  • 當修飾靜態方法時,鎖的是當前類的 Class 物件,在上面的例子中就是 X.class;
  • 當修飾非靜態方法時,鎖定的是當前例項物件 this。

對於上面的例子,synchronized 修飾靜態方法相當於:

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

修飾非靜態方法,相當於:

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

內建鎖

每個Java物件都可以用作一個實現同步的鎖,這些鎖被稱為內建鎖(Intrinsic Lock)或者監視器鎖(Monitor Lock)。被synchronized關鍵字修飾的方法或者程式碼塊,稱為同步程式碼塊(Synchronized Block)。執行緒在進入同步程式碼塊之前會自動獲取鎖,並且在退出同步程式碼塊時自動釋放鎖,這在前面也提到過。

Java的內建鎖相當於一種互斥體(或互斥鎖),這也就是說,最多隻有一個執行緒能夠持有這個鎖。由於每次只能有一個執行緒執行內建鎖保護的程式碼塊,因此,由這個鎖保護的同步程式碼塊會以原子的方式執行。

內建鎖是可重入的

當某個執行緒請求一個由其他執行緒所持有的鎖時,發出請求的執行緒會被阻塞。然而,由於內建鎖是可重入的,所以當某個執行緒試圖獲取一個已經由它自己所持有的鎖時,這個請求就會成功。

重入實現的一個方法是:為每個鎖關聯一個獲取計數器和一個所有者執行緒。
當計數器值為0時,這個鎖就被認為是沒有被任何執行緒持有的。當執行緒請求一個未被持有的鎖時,JVM將記下鎖的持有者,並且將計數器加1。如果同一個執行緒再次獲取這個鎖,計數器將加1,而當執行緒退出同步程式碼塊時,計數器會相應地減1。當計數器為0時,這個鎖將被釋放。

下面這段程式碼,如果內建鎖是不可重入的,那麼這段程式碼將發生死鎖。

public class Widget{
    public synchronized void doSomething(){
        ....
    }
}
public class LoggingWidget extends Widget{
    public synchronized void doSomething(){
        System.out.println(toString() + ": call doSomething");
        super.doSomething();
    }
}

使用synchronized解決count+=1問題

前面我們介紹原子性問題時提到count+=1存在原子性問題,那麼現在我們使用synchronized來使count+=1成為一個原子操作。

程式碼如下所示。

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

SafeCalc 這個類有兩個方法:一個是 get() 方法,用來獲得 value 的值;另一個是 addOne() 方法,用來給 value 加 1,並且 addOne() 方法我們用 synchronized 修飾。下面我們分析看這個程式碼是否存在併發問題。

addOne() 方法,被 synchronized 修飾後,無論是單核 CPU 還是多核 CPU,只有一個執行緒能夠執行 addOne() 方法,所以一定能保證原子操作。
那麼可見性呢?是否可以保證一個執行緒呼叫addOne()使value加一的結果對另一個執行緒後面呼叫addOne()時可見?
答案是可以的。這就需要回顧到我們上篇部落格提到的Happens-Before規則其中關於管程中的鎖規則:對同一個鎖的解鎖 Happens-Before 後續對這個鎖的加鎖。即,一個執行緒在臨界區修改的共享變數(該操作在解鎖之前),對後續進入臨界區(該操作在加鎖之後)的執行緒是可見的。

此時還不能掉以輕心,我們分析get()方法。執行 addOne() 方法後,value 的值對 get() 方法是可見的嗎?答案是這個可見性沒有保證。管程中鎖的規則,是隻保證後續對這個鎖的加鎖的可見性,而 get() 方法並沒有加鎖操作,所以可見性沒法保證。所以,最終的解決辦法為也是用synchronized修飾get()方法。

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

程式碼轉換成我們的鎖模型為:(圖來自【參考1】)

get() 方法和 addOne() 方法都需要訪問 value 這個受保護的資源,這個資源用 this 這把鎖來保護。執行緒要進入臨界區 get() 和 addOne(),必須先獲得 this 這把鎖,這樣 get() 和 addOne() 也是互斥的。

鎖和受保護資源的關係

受保護資源和鎖之間的關聯關係非常重要,一個合理的關係為:鎖和受保護資源之間的關聯關係是 1:N 。

拿球賽門票管理來類比,一個座位(資源)可以用一張門票(鎖)來保護,但是不可以有兩張門票預定了同一個座位,不然這兩個人就會fight。
在現實中我們可以使用多把鎖鎖同一個資源,如果放在併發領域中,執行緒A獲得鎖1和執行緒B獲得鎖2都可以訪問共享資源,那麼達到互斥訪問共享資源的目的。所以,在併發程式設計中使用多把鎖鎖同一個資源不可行。或許有人會想:要同時獲得鎖1和鎖2才可以訪問共享資源,這樣應該是就可行的。我覺得是可以的,但是能用一個鎖就可以保護資源,為什麼還要加一個鎖呢?
多把鎖鎖一個資源不可以,但是我們可以用同一把鎖來保護多個資源,這個對應到現實球賽門票就是可以用一張門票預定所有座位,即“包場”。

下面舉一個在併發程式設計中使用多把鎖來保護同一個資源將會出現的併發問題:

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

把 value 改成靜態變數,把 addOne() 方法改成靜態方法。
仔細觀察,就會發現改動後的程式碼是用兩個鎖保護一個資源。get()所使用的鎖是this,而addOne()所使用的鎖是SafeCalc.class。兩把鎖保護一個資源的示意圖如下(圖來自【參考1】)。
由於臨界區 get() 和 addOne() 是用兩個鎖保護的,因此這兩個臨界區沒有互斥關係,臨界區 addOne() 對 value 的修改對臨界區 get() 也沒有可見性保證,這就導致併發問題。

小結

Synchronized是 Java 在語言層面提供的互斥原語,Java中還有其他型別的鎖。但是作為互斥鎖,原理都是一樣的,首先要有一個鎖,然後是要鎖住什麼資源以及在哪裡加鎖就需要在設計層面考慮。
最後一個主題提的鎖和受保護資源的關係非常重要,在使用鎖時一定要好好注意。

參考:
[1]極客時間專欄王寶令《Java併發程式設計實戰》
[2]Brian Goetz.Tim Peierls. et al.Java併發程式設計實戰[M].北京:機械工業出版社,2