【Java併發基礎】死鎖

前言

咱們使用加鎖機制來保證線程安全,可是若是過分地使用加鎖,則可能會致使死鎖。下面將介紹關於死鎖的相關知識以及咱們在編寫程序時如何預防死鎖。java

什麼是死鎖

學習操做系統時,給出死鎖的定義爲兩個或兩個以上的線程在執行過程當中,因爲競爭資源而形成的一種阻塞的現象,若無外力做用,它們都將沒法推動下去。簡化一點說就是:一組相互競爭資源的線程由於互相等待,致使「永久」阻塞的現象面試

下面咱們經過一個轉帳例子來深刻理解死鎖。算法

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

爲了使以上轉帳方法transfer()不存在併發問題,很快地咱們能夠想使用Java的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。使用咱們上一篇介紹的鎖模型來描繪這個代碼就是下面這樣:(圖來自參考[1])安全

更具體來講,假設有 A、B、C 三個帳戶,餘額都是 200 元,咱們用兩個線程分別執行兩個轉帳操做:帳戶 A 轉給帳戶 B 100 元,帳戶 B 轉給帳戶 C 100 元,最後咱們指望的結果應該是帳戶 A 的餘額是 100 元,帳戶 B 的餘額是 200 元, 帳戶 C 的餘額是 300 元。
若是有兩個線程1和線程2,線程1 執行帳戶 A 轉帳戶 B 的操做,線程2執行帳戶 B 轉帳戶 C 的操做。這兩個線程分別運行在兩顆的CPU上,因爲this這個鎖只能保護本身的balance而不能保護別人的,線程 1 鎖定的是帳戶 A 的實例(A.this),而線程 2 鎖定的是帳戶 B 的實例(B.this),因此這兩個線程能夠同時進入臨界區 transfer(),所以兩個線程沒有實現互斥。
出現可能的結果就爲,兩個線程同時讀到帳戶B的餘額爲200元,致使最終帳戶 B 的餘額多是 300(線程 1 後於線程 2 寫 B.balance,線程 2 寫的 B.balance 值被線程 1 覆蓋),多是 100(線程 1 先於線程 2 寫 B.balance,線程 1 寫的 B.balance 值被線程 2 覆蓋),就是不多是 200。
併發轉帳示意圖(圖來自參考[1])併發

因而咱們應該使用一個可以覆蓋全部保護資源的鎖,若是還記得咱們上一篇講synchronized修飾靜態方法時默認的鎖對象的話,那這裏就很容易解決了。這個默認的鎖就是類的class對象。因而,咱們就可使用Account.class做爲一個能夠保護這個轉帳過程的鎖。app

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

這個方案雖然不存在併發問題,可是全部帳戶的轉帳操做都是串行的。現實世界中,帳戶 A 轉帳戶 B、帳戶 C 轉帳戶 D 這兩個轉帳操做現實世界裏是能夠並行的。較於實際狀況來講,這個方案就顯得性能太差。性能

因而,咱們儘可能模仿現實世界的轉帳操做:
每一個帳戶都有一個帳本,這些帳本都統一存放在文件架上。當轉帳A給帳戶B轉帳時,櫃員會去拿A帳本和B帳本作登記,此時櫃員在拿帳本時會遇到三種狀況:學習

  1. 文件架上剛好有A帳本和B帳本,那就同時拿走;
  2. 若是文件架上只有A帳本和B帳本之一,那這個櫃員就先把文件架上有的帳本拿到手,同時等着其餘櫃員把另一個帳本送回來;
  3. A帳本和B帳本都沒有,那這個櫃員就等着兩個帳本都被送回來

在編程實現中,咱們可使用兩把鎖來實現這個過程。在 transfer() 方法內部,咱們首先嚐試鎖定轉出帳戶 this(先把A帳本拿到手),而後嘗試鎖定轉入帳戶 target(再把B帳本拿到手),只有當二者都成功時,才執行轉帳操做。
這個邏輯能夠圖形化爲下圖這個樣子,(圖來自參考[1]):優化

代碼以下:

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

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

可是這樣卻會致使死鎖。例如狀況:櫃員張三作帳戶A轉帳戶B的轉帳操做,櫃員李四作帳戶B轉帳戶C的轉帳操做。他們兩個同時操做,因而就會出現下面這種情形:(圖來自參考[1])

他倆會一直等待對方將帳本放到文件架上,形成一個一直僵持的局勢。

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

Java併發程序一旦死鎖,通常沒有特別好的方法,恢復應用程序的惟一方式就是停止並重啓。所以,咱們要儘可能避免死鎖的發生,最好不要產生死鎖。要知道如何才能作到不要產生死鎖,咱們首先要知道什麼條件會發生死鎖。

死鎖發生的四個必要條件

雖然進程在運行過程當中,可能發生死鎖,但死鎖的發生也必須具有必定的條件,死鎖的發生必須具有如下四個必要條件:

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

破壞死鎖發生的條件預防死鎖

只有這四個條件都發生時纔會出現死鎖,那麼反過來,也就是說只要咱們破壞其中一個,就能夠成功預防死鎖的發生

四個條件中咱們不能破壞互斥,由於咱們使用鎖目的就是保證資源被互斥訪問,因而咱們就對其餘三個條件進行破壞:

  • 佔用且等待:一次性申請全部的資源,這樣就不存在等待了。
  • 不可搶佔,佔用部分資源的線程進一步申請其餘資源時,若是申請不到,能夠主動釋放它佔有的資源。
  • 循環等待,靠按序申請資源來預防。所謂按序申請,是指資源是有線性順序的,申請的時候能夠先申請資源序號小的,再申請資源序號大的,這樣線性化申請後就不存在循環了。

下面咱們使用這些方法去解決如上的死鎖問題。

破壞佔用且等待條件

一次性申請完全部資源。咱們設置一個管理員來管理帳本,櫃員同時申請須要的帳本,而管理員同時出他們須要的帳本。若是不能同時出借,則櫃員就須要等待。

「同時申請」:這個操做是一個臨界區,含有兩個操做,同時申請資源apply()和同時釋放資源free()。

class Allocator {
    private List<Object> als = new ArrayList<>();
    // 一次性申請全部資源
    synchronized boolean apply( Object from, Object to){
        if(als.contains(from) || als.contains(to)){    //from 或者 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))  //最好能夠加個timeout避免一直循環
            ;
            try{
                // 鎖定轉出帳戶
                synchronized(this){ //存在客戶對本身帳戶的操做
                    // 鎖定轉入帳戶
                    synchronized(target){           
                        if (this.balance > amt){
                            this.balance -= amt;
                            target.balance += amt;
                        }
                    }
                }
            } finally {
                actr.free(this, target)    //釋放資源
            }
    }
}

破壞不可搶佔條件

破壞不搶佔要可以主動釋放它佔有的資源,但synchronized是作不到的。緣由爲synchronized申請不到資源時,線程直接進入了阻塞狀態,而線程進入了阻塞狀態也就沒有辦法釋放它佔有的資源了。不過SDK中的java.util.concurrent提供了Lock解決這個問題。

支持定時的鎖

顯示使用Lock類中的定時tryLock功能來代替內置鎖機制,能夠檢測死鎖和從死鎖中恢復過來。使用內置鎖的線程獲取不到鎖會被阻塞,而顯示鎖能夠指定一個超時時限(Timeout),在等待超過該時間後tryLock就會返回一個失敗信息,也會釋放其擁有的資源。

破壞循環等待條件

破壞這個條件,須要對資源進行排序,而後按序申請資源。咱們假設每一個帳戶都有不一樣的屬性 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;
                }
            }
        }
    } 
}

小結

記得學習操做系統時還有避免死鎖,其和預防死鎖的區別在於:預防死鎖是設法至少破壞產生死鎖的四個必要條件之一,嚴格地防止死鎖的出現,可是這也會使系統性能下降;而避免死鎖則不那麼嚴格的限制產生死鎖的必要條件的存在,由於即便死鎖的必要條件存在,也不必定發生死鎖,死鎖避免是在系統運行過程當中注意避免死鎖的最終發生。避免死鎖的經典算法就是銀行家算法,這裏就不擴開介紹了。

還有一個避免出現死鎖的結論:若是全部線程以固定順序來得到鎖,那麼在程序中就不會出現鎖順序死鎖問題。查看參考[4]理解。

咱們使用細粒度鎖鎖住多個資源時,要注意死鎖的產生。只有先嗅到死鎖的味道,纔有咱們的施展之地。

參考: [1]極客時間專欄王寶令《Java併發編程實戰》 [2]Brian Goetz.Tim Peierls. et al.Java併發編程實戰[M].北京:機械工業出版社,2016 [3]iywwuyifan.避免死鎖和預防思索的區別.https://blog.csdn.net/masterchiefcc/article/details/83303813 [4]AddoilDan.死鎖面試題(什麼是死鎖,產生死鎖的緣由及必要條件).https://blog.csdn.net/hd12370/article/details/82814348

相關文章
相關標籤/搜索