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

前言

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

以在 32 位 CPU 上執行 long 型變量的寫操作爲例來說明。 long 型變量是 64 位,在 32 位 CPU 上執行寫操作會被拆分成兩次寫操作(寫高 32 位和寫低 32 位,如下圖所示,圖來自【參考1】)。 <img src="https://img2018.cnblogs.com/blog/1099419/202001/1099419-20200121001638914-1710837925.png" width=500></img>

在單核 CPU 場景下,同一時刻只有一個線程執行,禁止 CPU 中斷,意味着操作系統不會重新調度線程,即禁止了線程切換,獲得 CPU 使用權的線程就可以不間斷地執行。所以兩次寫操作一定是:要麼都被執行,要麼都沒有被執行,具有原子性。 但是在多核場景下,同一時刻,可能有兩個線程同時在執行,一個線程執行在 CPU-1 上,一個線程執行在 CPU-2 上。此時禁止 CPU 中斷,只能保證 CPU 上的線程連續執行,並不能保證同一時刻只有一個線程執行。如果這兩個線程同時向內存寫 long 型變量高 32 位的話,那麼就會造成我們寫入的變量和我們讀出來的是不一致的。

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

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

鎖模型

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

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

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

<img src="https://img2018.cnblogs.com/blog/1099419/202001/1099419-20200121001605104-600176911.png" width=600></img>

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 後續對這個鎖的加鎖。即,<span style="border-bottom:3px silver solid;border-bottom-color:green">一個線程在臨界區修改的共享變量(該操作在解鎖之前),對後續進入臨界區(該操作在加鎖之後)的線程是可見的。</span>

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

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

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

<img src="https://img2018.cnblogs.com/blog/1099419/202001/1099419-20200121001520910-2071976341.png" width=650></img>

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() 也沒有可見性保證,這就導致併發問題。

<img src="https://img2018.cnblogs.com/blog/1099419/202001/1099419-20200121001449307-698491255.png" width=650 ></img>

小結

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

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

原文出處:https://www.cnblogs.com/myworld7/p/12219918.html

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