[Java併發-4]解決Java死鎖的問題

在上一篇中,咱們嘗試使用了 Account.class做爲互斥鎖,來解決轉帳問題。可是很容易發現這樣,全部的轉帳操做都是串行的,性能太差了。java

讓咱們嘗試提高下性能。編程

向現實世界要答案

現實世界中,轉帳操做是支持併發的。性能優化

我設想下,在古代沒有信息化的時候。帳戶的存在就是一個個帳本,並且每一個用戶都有一個帳本。這些帳本都放在架子上。銀行櫃員在轉帳時候,是去架子上同時拿到轉入帳本和轉出帳本,而後作轉帳都。這時候這個櫃員會遇到3種狀況
1,架子上恰好有 轉入和轉出帳本,同時拿走便可。
2,若是架子上只有轉入和轉出帳本之一,櫃員先拿走一本,在等着另外一本被送回來。
3,轉入和轉出帳本都沒有,櫃員只好等着2個帳本被送回來。併發

上面的步驟轉換成編碼,其實就是2把鎖實現。轉入帳本一把鎖,轉出帳本一把鎖。在 transfer() 方法內部,咱們首先嚐試鎖定轉出帳戶 this(先把轉出帳本拿到手),而後嘗試鎖定轉入帳戶 target(再把轉入帳本拿到手),只有當二者都成功時,才執行轉帳操做。這個邏輯能夠圖形化爲下圖這個樣子。app

圖片描述

以下所示。通過這樣的優化後,帳戶 A 轉帳戶 B 和帳戶 C 轉帳戶 D 這兩個轉帳操做就能夠並行了。性能

class Account {
  private int balance;
  // 轉帳
  void transfer(Account target, int amt){
    // 鎖定轉出帳戶
    synchronized(this) {              
      // 鎖定轉入帳戶
      synchronized(target) {           
        if (this.balance > amt) {
          this.balance -= amt;
          target.balance += amt;
        }
      }
    }
  } 
}

相對於用 Account.class 做爲互斥鎖,鎖定的範圍太大,而咱們鎖定兩個帳戶範圍就小多了,這樣的鎖,上一章咱們介紹過,叫細粒度鎖使用細粒度鎖能夠提升並行度,是性能優化的一個重要手段。優化

使用細粒度鎖這麼簡單嘛?編寫併發程序就須要這樣時時刻刻保持謹慎。this

使用細粒度鎖是有代價的,這個代價就是可能會致使死鎖。

咱們仍是經過現實世界看一下死鎖產生的緣由。若是有客戶找櫃員張三作個轉帳業務:帳戶 A 轉帳戶 B 100 元,此時另外一個客戶找櫃員李四也作個轉帳業務:帳戶 B 轉帳戶 A 100 元,因而張三和李四同時都去文件架上拿帳本,這時候有可能湊巧張三拿到了帳本 A,李四拿到了帳本 B。張三拿到帳本 A 後就等着帳本 B(帳本 B 已經被李四拿走),而李四拿到帳本 B 後就等着帳本 A(帳本 A 已經被張三拿走),他們要等多久呢?他們會永遠等待下去…由於張三不會把帳本 A 送回去,李四也不會把帳本 B 送回去。咱們姑且稱爲死等吧。編碼

圖片描述

現實世界裏的死等,就是編程領域的死鎖了。spa

死鎖 一組互相競爭資源的線程因互相等待,致使「永久」阻塞的現象
class Account {
  private int balance;
  // 轉帳
  void transfer(Account target, int amt){
    // 鎖定轉出帳戶
    synchronized(this){     ①
      // 鎖定轉入帳戶
      synchronized(target){ ②
        if (this.balance > amt) {
          this.balance -= amt;
          target.balance += amt;
        }
      }
    }
  } 
}

關於這種現象,咱們還能夠藉助資源分配圖來可視化鎖的佔用狀況(資源分配圖是個有向圖,它能夠描述資源和線程的狀態)。其中,資源用方形節點表示,線程用圓形節點表示;資源中的點指向線程的邊表示線程已經得到該資源,線程指向資源的邊則表示線程請求資源,但還沒有獲得。轉帳發生死鎖時的資源分配圖就以下圖所示。

圖片描述
轉帳發生死鎖時的資源分配圖

如何預防死鎖

併發程序一旦死鎖,通常沒有特別好的方法,不少時候咱們只能重啓應用。所以,解決死鎖問題最好的辦法仍是規避死鎖。

那如何避免死鎖呢?要避免死鎖就須要分析死鎖發生的條件,有個叫 Coffman 的牛人早就總結過了,只有如下這四個條件都發生時纔會出現死鎖:

1,互斥,共享資源 X 和 Y 只能被一個線程佔用;
2,佔有且等待,線程 T1 已經取得共享資源 X,在等待共享資源 Y 的時候,不釋放共享資源 X;
3,不可搶佔,其餘線程不能強行搶佔線程 T1 佔有的資源;
4,循環等待,線程 T1 等待線程 T2 佔有的資源,線程 T2 等待線程 T1 佔有的資源,就是循環等待。

反過來分析,也就是說只要咱們破壞其中一個,就能夠成功避免死鎖的發生

其中,互斥這個條件咱們沒有辦法破壞,由於咱們用鎖爲的就是互斥。不過其餘三個條件都是有辦法破壞掉的,到底如何作呢?

1,對於「佔用且等待」這個條件,咱們能夠一次性申請全部的資源,這樣就不存在等待了。
2,對於「不可搶佔」這個條件,佔用部分資源的線程進一步申請其餘資源時,若是申請不到,能夠主動釋放它佔有的資源,這樣不可搶佔這個條件就破壞掉了。
3,對於「循環等待」這個條件,能夠靠按序申請資源來預防。所謂按序申請,是指資源是有線性順序的,申請的時候能夠先申請資源序號小的,再申請資源序號大的,這樣線性化後天然就不存在循環了。

咱們已經從理論上解決了如何預防死鎖,下面咱們就來嘗試用代碼實踐一下這些理論。

1. 破壞佔用且等待條件

從理論上講,要破壞這個條件,能夠一次性申請全部資源。在現實世界裏,就拿前面咱們提到的轉帳操做來說。能夠增長一個帳本管理員,而後只容許帳本管理員從文件架上拿帳本,也就是說櫃員不能直接在文件架上拿帳本,必須經過帳本管理員才能拿到想要的帳本。例如,張三同時申請帳本 A 和 B,帳本管理員若是發現文件架上只有帳本 A,這個時候帳本管理員是不會把帳本 A 拿下來給張三的,只有帳本 A 和 B 都在的時候纔會給張三。這樣就保證了「一次性申請全部資源」。

圖片描述
經過帳本管理員拿帳本圖

對應到編程領域,「同時申請」這個操做是一個臨界區,咱們也須要一個角色(Java 裏面的類)來管理這個臨界區,咱們就把這個角色定爲 Allocator。它有兩個重要功能,分別是:同時申請資源 apply() 和同時釋放資源 free()。帳戶 Account 類裏面持有一個 Allocator 的單例(必須是單例,只能由一我的來分配資源)。當帳戶 Account 在執行轉帳操做的時候,首先向 Allocator 同時申請轉出帳戶和轉入帳戶這兩個資源,成功後再鎖定這兩個資源;當轉帳操做執行完,釋放鎖以後,咱們需通知 Allocator 同時釋放轉出帳戶和轉入帳戶這兩個資源。具體的代碼實現以下。

class Allocator {
  private List<Object> als =
    new ArrayList<>();
  // 一次性申請全部資源
  synchronized boolean apply(
    Object from, Object to){
    if(als.contains(from) ||
         als.contains(to)){
      return false;  
    } else {
      als.add(from);
      als.add(to);  
    }
    return true;
  }
  // 歸還資源
  synchronized void free(
    Object from, Object to){
    als.remove(from);
    als.remove(to);
  }
}

class Account {
  // actr 應該爲單例
  private Allocator actr;
  private int balance;
  // 轉帳
  void transfer(Account target, int amt){
    // 一次性申請轉出帳戶和轉入帳戶,直到成功
    while(!actr.apply(this, target))
      ;
    try{
      // 鎖定轉出帳戶
      synchronized(this){              
        // 鎖定轉入帳戶
        synchronized(target){           
          if (this.balance > amt){
            this.balance -= amt;
            target.balance += amt;
          }
        }
      }
    } finally {
      actr.free(this, target)
    }
  } 
}

2. 破壞不可搶佔條件

破壞不可搶佔條件看上去很簡單,核心是要可以主動釋放它佔有的資源,這一點 synchronized 是作不到的。緣由是 synchronized 申請資源的時候,若是申請不到,線程直接進入阻塞狀態了,而線程進入阻塞狀態,也釋放不了線程已經佔有的資源。java.util.concurrent 這個包下面提供的 Lock 是能夠輕鬆解決這個問題的。關於這個話題,我們後面會詳細講。

3. 破壞循環等待條件

破壞這個條件,須要對資源進行排序,而後按序申請資源。這個實現很是簡單,咱們假設每一個帳戶都有不一樣的屬性 id,這個 id 能夠做爲排序字段,申請的時候,咱們能夠按照從小到大的順序來申請。好比下面代碼中,①~⑥處的代碼對轉出帳戶(this)和轉入帳戶(target)排序,而後按照序號從小到大的順序鎖定帳戶。這樣就不存在「循環」等待了。

class Account {
  private int id;
  private int balance;
  // 轉帳
  void transfer(Account target, int amt){
    Account left = this        ①
    Account right = target;    ②
    if (this.id > target.id) { ③
      left = target;           ④
      right = this;            ⑤
    }                          ⑥
    // 鎖定序號小的帳戶
    synchronized(left){
      // 鎖定序號大的帳戶
      synchronized(right){ 
        if (this.balance > amt){
          this.balance -= amt;
          target.balance += amt;
        }
      }
    }
  } 
}

總結

當咱們在編程世界裏遇到問題時,應不侷限於當下,能夠換個思路,向現實世界要答案,利用現實世界的模型來構思解決方案,這樣每每可以讓咱們的方案更容易理解,也更可以看清楚問題的本質。

用細粒度鎖來鎖定多個資源時,要注意死鎖的問題.

預防死鎖主要是破壞三個條件中的一個,有了這個思路後,實現就簡單了。但仍需注意的是,有時候預防死鎖成本也是很高的。例如上面轉帳那個例子,咱們破壞佔用且等待條件上咱們也是鎖了全部的帳戶,並且仍是用了死循環 while(!actr.apply(this, target));方法,不過好在 apply() 這個方法基本不耗時。 在轉帳這個例子中,破壞循環等待條件就是成本最低的一個方案。

因此咱們在選擇具體方案的時候,還須要評估一下操做成本,從中選擇一個成本最低的方案

相關文章
相關標籤/搜索