多執行緒之死鎖就是這麼簡單

Java3y發表於2018-05-06

前言

只有光頭才能變強

回顧前面:

本篇主要是講解死鎖,這是我在多執行緒的最後一篇了。主要將多執行緒的基礎過一遍,以後有機會再繼續深入

死鎖是在多執行緒中也是比較重要的知識點了!

那麼接下來就開始吧,如果文章有錯誤的地方請大家多多包涵,不吝在評論區指正哦~

宣告:本文使用JDK1.8

一、死鎖講解

在Java中使用多執行緒,就會有可能導致死鎖問題。死鎖會讓程式一直住,不再程式往下執行。我們只能通過中止並重啟的方式來讓程式重新執行。

  • 這是我們非常不願意看到的一種現象,我們要儘可能避免死鎖的情況發生!

造成死鎖的原因可以概括成三句話:

  • 當前執行緒擁有其他執行緒需要的資源
  • 當前執行緒等待其他執行緒已擁有的資源
  • 都不放棄自己擁有的資源

1.1鎖順序死鎖

首先我們來看一下最簡單的死鎖(鎖順序死鎖)是怎麼樣發生的:


public class LeftRightDeadlock {
    private final Object left = new Object();
    private final Object right = new Object();

    public void leftRight() {
		// 得到left鎖
        synchronized (left) {
			// 得到right鎖
            synchronized (right) {
                doSomething();
            }
        }
    }

    public void rightLeft() {
		// 得到right鎖
        synchronized (right) {
			// 得到left鎖
            synchronized (left) {
                doSomethingElse();
            }
        }
    }
}
複製程式碼

我們的執行緒是交錯執行的,那麼就很有可能出現以下的情況:

  • 執行緒A呼叫leftRight()方法,得到left鎖
  • 同時執行緒B呼叫rightLeft()方法,得到right鎖
  • 執行緒A和執行緒B都繼續執行,此時執行緒A需要right鎖才能繼續往下執行。此時執行緒B需要left鎖才能繼續往下執行。
  • 但是:執行緒A的left鎖並沒有釋放,執行緒B的right鎖也沒有釋放
  • 所以他們都只能等待,而這種等待是無期限的-->永久等待-->死鎖

多執行緒之死鎖就是這麼簡單

1.2動態鎖順序死鎖

我們看一下下面的例子,你認為會發生死鎖嗎?


    // 轉賬
    public static void transferMoney(Account fromAccount,
                                     Account toAccount,
                                     DollarAmount amount)
            throws InsufficientFundsException {

        // 鎖定匯賬賬戶
        synchronized (fromAccount) {
            // 鎖定來賬賬戶
            synchronized (toAccount) {

                // 判餘額是否大於0
                if (fromAccount.getBalance().compareTo(amount) < 0) {
                    throw new InsufficientFundsException();
                } else {

                    // 匯賬賬戶減錢
                    fromAccount.debit(amount);

                    // 來賬賬戶增錢
                    toAccount.credit(amount);
                }
            }
        }
    }
複製程式碼

上面的程式碼看起來是沒有問題的:鎖定兩個賬戶來判斷餘額是否充足才進行轉賬!

但是,同樣有可能會發生死鎖

  • 如果兩個執行緒同時呼叫transferMoney()
  • 執行緒A從X賬戶向Y賬戶轉賬
  • 執行緒B從賬戶Y向賬戶X轉賬
  • 那麼就會發生死鎖。

A:transferMoney(myAccount,yourAccount,10);


B:transferMoney(yourAccount,myAccount,20);

複製程式碼

1.3協作物件之間發生死鎖

我們來看一下下面的例子:


public class CooperatingDeadlock {
    // Warning: deadlock-prone!
    class Taxi {
        @GuardedBy("this") private Point location, destination;
        private final Dispatcher dispatcher;

        public Taxi(Dispatcher dispatcher) {
            this.dispatcher = dispatcher;
        }

        public synchronized Point getLocation() {
            return location;
        }

        // setLocation 需要Taxi內建鎖
        public synchronized void setLocation(Point location) {
            this.location = location;
            if (location.equals(destination))
                // 呼叫notifyAvailable()需要Dispatcher內建鎖
                dispatcher.notifyAvailable(this);
        }

        public synchronized Point getDestination() {
            return destination;
        }

        public synchronized void setDestination(Point destination) {
            this.destination = destination;
        }
    }

    class Dispatcher {
        @GuardedBy("this") private final Set<Taxi> taxis;
        @GuardedBy("this") private final Set<Taxi> availableTaxis;

        public Dispatcher() {
            taxis = new HashSet<Taxi>();
            availableTaxis = new HashSet<Taxi>();
        }

        public synchronized void notifyAvailable(Taxi taxi) {
            availableTaxis.add(taxi);
        }

        // 呼叫getImage()需要Dispatcher內建鎖
        public synchronized Image getImage() {
            Image image = new Image();
            for (Taxi t : taxis)
                // 呼叫getLocation()需要Taxi內建鎖
                image.drawMarker(t.getLocation());
            return image;
        }
    }

    class Image {
        public void drawMarker(Point p) {
        }
    }
}
複製程式碼

上面的getImage()setLocation(Point location)都需要獲取兩個鎖的

  • 並且在操作途中是沒有釋放鎖的

這就是隱式獲取兩個鎖(物件之間協作)..

這種方式也很容易就造成死鎖.....

二、避免死鎖的方法

避免死鎖可以概括成三種方法:

  • 固定加鎖的順序(針對鎖順序死鎖)
  • 開放呼叫(針對物件之間協作造成的死鎖)
  • 使用定時鎖-->tryLock()
    • 如果等待獲取鎖時間超時,則丟擲異常而不是一直等待

2.1固定鎖順序避免死鎖

上面transferMoney()發生死鎖的原因是因為加鎖順序不一致而出現的~

  • 正如書上所說的:如果所有執行緒以固定的順序來獲得鎖,那麼程式中就不會出現鎖順序死鎖問題!

那麼上面的例子我們就可以改造成這樣子:


public class InduceLockOrder {

    // 額外的鎖、避免兩個物件hash值相等的情況(即使很少)
    private static final Object tieLock = new Object();

    public void transferMoney(final Account fromAcct,
                              final Account toAcct,
                              final DollarAmount amount)
            throws InsufficientFundsException {
        class Helper {
            public void transfer() throws InsufficientFundsException {
                if (fromAcct.getBalance().compareTo(amount) < 0)
                    throw new InsufficientFundsException();
                else {
                    fromAcct.debit(amount);
                    toAcct.credit(amount);
                }
            }
        }
        // 得到鎖的hash值
        int fromHash = System.identityHashCode(fromAcct);
        int toHash = System.identityHashCode(toAcct);

        // 根據hash值來上鎖
        if (fromHash < toHash) {
            synchronized (fromAcct) {
                synchronized (toAcct) {
                    new Helper().transfer();
                }
            }

        } else if (fromHash > toHash) {// 根據hash值來上鎖
            synchronized (toAcct) {
                synchronized (fromAcct) {
                    new Helper().transfer();
                }
            }
        } else {// 額外的鎖、避免兩個物件hash值相等的情況(即使很少)
            synchronized (tieLock) {
                synchronized (fromAcct) {
                    synchronized (toAcct) {
                        new Helper().transfer();
                    }
                }
            }
        }
    }
}
複製程式碼

得到對應的hash值來固定加鎖的順序,這樣我們就不會發生死鎖的問題了!

2.2開放呼叫避免死鎖

在協作物件之間發生死鎖的例子中,主要是因為在呼叫某個方法時就需要持有鎖,並且在方法內部也呼叫了其他帶鎖的方法!

  • 如果在呼叫某個方法時不需要持有鎖,那麼這種呼叫被稱為開放呼叫

我們可以這樣來改造:

  • 同步程式碼塊最好僅被用於保護那些涉及共享狀態的操作


class CooperatingNoDeadlock {
    @ThreadSafe
    class Taxi {
        @GuardedBy("this") private Point location, destination;
        private final Dispatcher dispatcher;

        public Taxi(Dispatcher dispatcher) {
            this.dispatcher = dispatcher;
        }

        public synchronized Point getLocation() {
            return location;
        }

        public synchronized void setLocation(Point location) {
            boolean reachedDestination;

            // 加Taxi內建鎖
            synchronized (this) {
                this.location = location;
                reachedDestination = location.equals(destination);
            }
            // 執行同步程式碼塊後完畢,釋放鎖



            if (reachedDestination)
                // 加Dispatcher內建鎖
                dispatcher.notifyAvailable(this);
        }

        public synchronized Point getDestination() {
            return destination;
        }

        public synchronized void setDestination(Point destination) {
            this.destination = destination;
        }
    }

    @ThreadSafe
    class Dispatcher {
        @GuardedBy("this") private final Set<Taxi> taxis;
        @GuardedBy("this") private final Set<Taxi> availableTaxis;

        public Dispatcher() {
            taxis = new HashSet<Taxi>();
            availableTaxis = new HashSet<Taxi>();
        }

        public synchronized void notifyAvailable(Taxi taxi) {
            availableTaxis.add(taxi);
        }

        public Image getImage() {
            Set<Taxi> copy;

            // Dispatcher內建鎖
            synchronized (this) {
                copy = new HashSet<Taxi>(taxis);
            }
            // 執行同步程式碼塊後完畢,釋放鎖

            Image image = new Image();
            for (Taxi t : copy)
                // 加Taix內建鎖
                image.drawMarker(t.getLocation());
            return image;
        }
    }

    class Image {
        public void drawMarker(Point p) {
        }
    }

}

複製程式碼

使用開放呼叫是非常好的一種方式,應該儘量使用它~

2.3使用定時鎖

使用顯式Lock鎖,在獲取鎖時使用tryLock()方法。當等待超過時限的時候,tryLock()不會一直等待,而是返回錯誤資訊。

使用tryLock()能夠有效避免死鎖問題~~

2.4死鎖檢測

雖然造成死鎖的原因是因為我們設計得不夠好,但是可能寫程式碼的時候不知道哪裡發生了死鎖。

JDK提供了兩種方式來給我們檢測:

  • JconsoleJDK自帶的圖形化介面工具,使用JDK給我們的的工具JConsole
  • Jstack是JDK自帶的命令列工具,主要用於執行緒Dump分析。

具體可參考:

三、總結

發生死鎖的原因主要由於:

  • 執行緒之間交錯執行
    • 解決:以固定的順序加鎖
  • 執行某方法時就需要持有鎖,且不釋放
    • 解決:縮減同步程式碼塊範圍,最好僅操作共享變數時才加鎖
  • 永久等待
    • 解決:使用tryLock()定時鎖,超過時限則返回錯誤資訊

在作業系統層面上看待死鎖問題(這是我之前做的筆記、很淺顯):

參考資料:

  • 《Java核心技術卷一》
  • 《Java併發程式設計實戰》
  • 《計算機作業系統 湯小丹》

如果文章有錯的地方歡迎指正,大家互相交流。習慣在微信看技術文章,想要獲取更多的Java資源的同學,可以關注微信公眾號:Java3y

文章的目錄導航

相關文章