[併發理論基礎] 04 | 互斥鎖(下):如何用一把鎖保護多個資源?

[併發理論基礎] 04 | 互斥鎖(下):如何用一把鎖保護多個資源?

上一篇文章提到受保護資源和鎖之間合理的關聯關係應該是 N:1 的關係,也就是說可以用一把鎖來保護多個資源,但是不能用多把鎖來保護一個資源。這一篇來討論如何保護多個資源。

當我們要保護多個資源時,首先要區分這些資源是否存在關聯關係。

一、保護沒有關聯關係的多個資源

銀行業務中有針對賬戶餘額(餘額是一種資源)的取款操作,也有針對賬戶密碼(密碼也是一種資源)的更改操作,我們可以爲賬戶餘額和賬戶密碼分配不同的鎖來解決併發問題,這個還是很簡單的。

示例代碼如下,賬戶類 Account 有兩個成員變量,分別是賬戶餘額 balance 和賬戶密碼 password。取款 withdraw() 和查看餘額 getBalance() 操作會訪問賬戶餘額 balance,我們創建一個 final 對象 balLock 作爲鎖;而更改密碼 updatePassword() 和查看密碼 getPassword() 操作會修改賬戶密碼 password,我們創建一個 final 對象 pwLock 作爲鎖。不同的資源用不同的鎖保護,各自管各自的,很簡單。

class Account {
  // 鎖:保護賬戶餘額
  private final Object balLock = new Object();
  // 賬戶餘額  
  private Integer balance;
  // 鎖:保護賬戶密碼
  private final Object pwLock = new Object();
  // 賬戶密碼
  private String password;
 
  // 取款
  void withdraw(Integer amt) {
    synchronized(balLock) {
      if (this.balance > amt){
        this.balance -= amt;
      }
    }
  } 
  // 查看餘額
  Integer getBalance() {
    synchronized(balLock) {
      return balance;
    }
  }
 
  // 更改密碼
  void updatePassword(String pw){
    synchronized(pwLock) {
      this.password = pw;
    }
  } 
  // 查看密碼
  String getPassword() {
    synchronized(pwLock) {
      return password;
    }
  }
}

當然,我們也可以用一把互斥鎖來保護多個資源,例如我們可以用 this 這一把鎖來管理賬戶類裏所有的資源:賬戶餘額和用戶密碼。具體實現很簡單,示例程序中所有的方法都增加同步關鍵字 synchronized 就可以了,這裏我就不一一展示了。

但是用一把鎖有個問題,就是性能太差,會導致取款、查看餘額、修改密碼、查看密碼這四個操作都是串行的。而我們用兩把鎖,取款和修改密碼是可以並行的。用不同的鎖對受保護資源進行精細化管理,能夠提升性能。這種鎖還有個名字,叫細粒度鎖

二、保護有關聯關係的多個資源

如果多個資源是有關聯關係的,那這個問題就有點複雜了。例如銀行業務裏面的轉賬操作,賬戶 A 減少 100 元,賬戶 B 增加 100 元。這兩個賬戶就是有關聯關係的。那對於像轉賬這種有關聯關係的操作,我們應該怎麼去解決呢?先把這個問題代碼化。我們聲明瞭個賬戶類:Account,該類有一個成員變量餘額:balance,還有一個用於轉賬的方法:transfer(),然後怎麼保證轉賬操作 transfer() 沒有併發問題呢?

class Account {
  private int balance;
  // 轉賬
  void transfer(Account target, int amt){//amt爲轉賬金額,target爲轉賬對象
    if (this.balance > amt) {
      this.balance -= amt;
      target.balance += amt;
    }
  } 
}

相信你的直覺會告訴你這樣的解決方案:用戶 synchronized 關鍵字修飾一下 transfer() 方法就可以了,於是你很快就完成了相關的代碼,如下所示。

class Account {
  private int balance;
  // 轉賬
  synchronized void transfer(Account target, int amt){
    if (this.balance > amt) {
      this.balance -= amt;
      target.balance += amt;
    }
  } 
}

在這段代碼中,臨界區內有兩個資源,分別是轉出賬戶的餘額 this.balance 和轉入賬戶的餘額 target.balance,並且用的是一把鎖 this,符合我們前面提到的,多個資源可以用一把鎖來保護,這看上去完全正確呀。真的是這樣嗎?可惜,這個方案僅僅是看似正確,爲什麼呢?

問題就出在 this 這把鎖上,this 這把鎖可以保護自己的餘額 this.balance,卻保護不了別人的餘額 target.balance,就像你不能用自家的鎖來保護別人家的資產一樣。

在這裏插入圖片描述
下面我們具體分析一下,假設有 A、B、C 三個賬戶,餘額都是 200 元,我們用兩個線程分別執行兩個轉賬操作:賬戶 A 轉給賬戶 B 100 元,賬戶 B 轉給賬戶 C 100 元,最後我們期望的結果應該是賬戶 A 的餘額是 100 元,賬戶 B 的餘額是 200 元, 賬戶 C 的餘額是 300 元。

我們假設線程 1 執行賬戶 A 轉賬戶 B 的操作,線程 2 執行賬戶 B 轉賬戶 C 的操作。這兩個線程分別在兩顆 CPU 上同時執行,那它們是互斥的嗎?我們期望是,但實際上並不是。因爲線程 1 鎖定的是賬戶 A 的實例(A.this),而線程 2 鎖定的是賬戶 B 的實例(B.this),所以這兩個線程可以同時進入臨界區 transfer()。同時進入臨界區的結果是什麼呢?線程 1 和線程 2 都會讀到賬戶 B 的餘額爲 200,導致最終賬戶 B 的餘額可能是 300(線程 1 後於線程 2 寫 B.balance,線程 2 寫的 B.balance 值被線程 1 覆蓋),可能是 100(線程 1 先於線程 2 寫 B.balance,線程 1 寫的 B.balance 值被線程 2 覆蓋),就是不可能是 200。

在這裏插入圖片描述

三、使用鎖的正確姿勢

如果要用鎖來保護多個資源,那麼鎖就要能覆蓋所有受保護資源。在上面的例子中,this 是對象級別的鎖,所以 A 對象和 B 對象都有自己的鎖,如何讓 A 對象和 B 對象共享一把鎖呢?

稍微開動腦筋,你會發現其實方案還挺多的,比如可以讓所有對象都持有一個唯一性的對象,這個對象在創建 Account 時傳入。方案有了,完成代碼就簡單了。示例代碼如下,我們把 Account 默認構造函數變爲 private,同時增加一個帶 Object lock 參數的構造函數,創建 Account 對象時,傳入相同的 lock,這樣所有的 Account 對象都會共享這個 lock 了。

class Account {
  private Object lock;
  private int balance;
  private Account();
  // 創建 Account 時傳入同一個 lock 對象
  public Account(Object lock) {
    this.lock = lock;
  } 
  // 轉賬
  void transfer(Account target, int amt){
    // 此處檢查所有對象共享的鎖
    synchronized(lock) {
      if (this.balance > amt) {
        this.balance -= amt;
        target.balance += amt;
      }
    }
  }
}

這個辦法確實能解決問題,但是有點小瑕疵,它要求在創建 Account 對象的時候必須傳入同一個對象,如果創建 Account 對象時,傳入的 lock 不是同一個對象,那可就慘了,會出現鎖自家門來保護他家資產的荒唐事。在真實的項目場景中,創建 Account 對象的代碼很可能分散在多個工程中,傳入共享的 lock 真的很難。

所以,上面的方案缺乏實踐的可行性,我們需要更好的方案。還真有,就是用 Account.class 作爲共享的鎖。Account.class 是所有 Account 對象共享的,而且這個對象是 Java 虛擬機在加載 Account 類的時候創建的,所以我們不用擔心它的唯一性。使用 Account.class 作爲共享的鎖,我們就無需在創建 Account 對象時傳入了,代碼更簡單。

class Account {
  private int balance;
  // 轉賬
  void transfer(Account target, int amt){
    synchronized(Account.class) {
      if (this.balance > amt) {
        this.balance -= amt;
        target.balance += amt;
      }
    }
  } 
}

下面這幅圖很直觀地展示了我們是如何使用共享的鎖 Account.class 來保護不同對象的臨界區的。

使用Account.class獲得鎖有很明顯的性能問題,如果用Account.class作爲加鎖對象的話,那所有的轉賬功能將會是串行。實際上4個不同賬戶之間的轉賬是可以並行執行的,例如A給B轉賬,C給D轉賬,這兩個動作是可以一起執行的。
下一篇的內容涉及它的優化

在這裏插入圖片描述

對於方案一,可以在Account中添加一個靜態object,通過鎖這個object來實現一個鎖保護多個資源。這種方式比鎖class更安全。final用來修飾成員變量,該變量只能被賦值一次且它的值無法被改變。
private static final Object lock = new Object();

四、課後題

在第一個示例程序裏,我們用了兩把不同的鎖來分別保護賬戶餘額、賬戶密碼,創建鎖的時候,我們用的是:private final Object xxxLock = new Object();,如果賬戶餘額用 this.balance 作爲互斥鎖,賬戶密碼用 this.password 作爲互斥鎖,你覺得是否可以呢?

回答一:用this.balance 和this.password 都不行。在同一個賬戶多線程訪問時候,A線程取款進行this.balance-=amt;時候此時this.balance對應的值已經發生變換,線程B再次取款時拿到的balance對應的值並不是A線程中的,也就是說不能把可變的對象當成一把鎖。this.password 雖然說是String修飾但也會改變,所以也不行
回答二:不能用balance和password做爲鎖對象。這兩個對象balance是Integer,password是String都是不可變變對象,一但對他們進行賦值就會變成新的對象,加的鎖就失效了

舉例:
比如有線程A、B、C
線程A首先拿到balance1鎖,線程B這個時候也過來,發現鎖被拿走了,線程B被放入一個地方進行等待。
當A修改掉變量balance的值後,鎖由balance1變爲balance2.
線程B也拿到那個balance1鎖,這時候剛好有線程C過來,拿到了balance2鎖。
由於B和C持有的鎖不同,所以可以同時執行這個方法來修改balance的值,這個時候就有可能是線程B修改的值會覆蓋掉線程C修改的值。(BC鎖的不是一個對象。不能保證互斥性)

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