Java併發程式設計實戰 05等待-通知機制和活躍性問題

Johnson木木發表於2020-05-20

Java併發程式設計系列

Java併發程式設計實戰 01併發程式設計的Bug源頭
Java併發程式設計實戰 02Java如何解決可見性和有序性問題
Java併發程式設計實戰 03互斥鎖 解決原子性問題
Java併發程式設計實戰 04死鎖了怎麼辦

前提

Java併發程式設計實戰 04死鎖了怎麼辦中,講到了使用一次性申請所有資源來避免死鎖的發生,但是程式碼中卻是使用不斷的迴圈去獲取鎖資源。如果獲取鎖資源耗時短、且併發衝突量不大的時候,這個方式還是挺合適的。
如果獲取所以資源耗時長且併發衝突量很大的時候,可能會迴圈上千上萬次,這就太消耗CPU了。把上一章的程式碼貼下來吧。

/** 鎖分配器(單例類) */
public class LockAllocator {
    private final List<Object> lock = new ArrayList<Object>();
    /** 同時申請鎖資源 */
    public synchronized boolean lock(Object object1, Object object2) {
        if (lock.contains(object1) || lock.contains(object2)) {
            return false;
        }

        lock.add(object1);
        lock.add(object2);
        return true;
    }
    /** 同時釋放資源鎖 */
    public synchronized void unlock(Object object1, Object object2) {
        lock.remove(object1);
        lock.remove(object2);
    }
}

public class Account {
    // 餘額
    private Long money;
    // 鎖分配器
    private LockAllocator lockAllocator;
    
    public void transfer(Account target, Long money) {
        try {
            // 迴圈獲取鎖,直到獲取成功
            while (!lockAllocator.lock(this, target)) {
            }

            synchronized (this){
                synchronized (target){
                    this.money -= money;
                    if (this.money < 0) {
                        // throw exception
                    }
                    target.money += money;
                }
            }
        } finally {
            // 釋放鎖
            lockAllocator.unlock(this, target);
        }
    }
}

解決這種場景的方案就是使用等待-通知機制。

等待-通知機制

當我們去麥當勞吃漢堡,首先我們需要排隊點餐,就如執行緒搶著獲取鎖進synchronized同步程式碼塊中。
當我們點完餐後需要等待漢堡完成,所以我們需要等待wait(),因為漢堡還沒做好。
當漢堡做好後廣播喊了一句“我做好啦!快來領餐”。廣播就是notifyAll(),喚醒了所有執行緒。
然後每個人都過去看看是不是自己的餐。如果不是又進入了等待中。否則就可以拿到漢堡(獲取到鎖)開吃啦。

當然麥當勞只會說“xx號快來領餐”,我改了一下臺詞比較好做例子(例子感覺也是一般般,看不懂就看程式碼吧)。對不起麥當勞了。

在程式設計領域當中,若執行緒發現鎖資源被其他執行緒佔用了(條件不滿足),執行緒就會進入等待狀態wait(釋放鎖),當其它執行緒釋放鎖時,使用notifyAll()喚醒所有等待中的執行緒。被喚醒的執行緒就會重新去嘗試獲取鎖。如圖:
等待通知1.jpg

那麼何時等待? 何時喚醒?
何時等待:當執行緒的要求不滿足時等待,在轉賬的例子當中就是不能同時獲取到thistarget鎖資源時等待。
何時喚醒:當有執行緒釋放鎖資源時就喚醒。
修改後的程式碼如下:

/** 鎖分配器(單例類) */
public class LockAllocator {
    private final List<Object> lock = new ArrayList<>();

    /** 同時申請鎖資源 */
    public synchronized void lock(Object object1, Object object2) throws InterruptedException {
        while (lock.contains(object1) || lock.contains(object2)) {
            wait(); // 執行緒進入等待狀態 釋放鎖
        }

        lock.add(object1);
        lock.add(object2);
    }
    /** 同時釋放資源鎖 */
    public synchronized void unlock(Object object1, Object object2) {
        lock.remove(object1);
        lock.remove(object2);
        notifyAll(); // 喚醒所有等待中的執行緒
    }
}
public class Account {
    // 餘額
    private Long money;
    // 鎖分配器
    private LockAllocator lockAllocator;

    public void transfer(Account target, Long money) throws InterruptedException {
        try {
            // 獲取鎖
            lockAllocator.lock(this, target);

            this.money -= money;
            if (this.money < 0) {
                // throw exception
            }
            target.money += money;
        } finally {
            // 釋放鎖
            lockAllocator.unlock(this, target);
        }
    }
}

Account類中,對比上面的程式碼,我刪掉了兩層synchronized巢狀,如果涉及到賬戶餘額都先去鎖分配器LockAllocator 中獲取鎖,那麼這兩層synchronized巢狀其實可以去掉。而且使用wait()notifyAll()notify()也是)必須在synchronized程式碼塊中,否則會丟擲java.lang.IllegalMonitorStateException`異常。

儘量使用notifyAll

其實使用notify()也可以喚醒執行緒,但是隻會隨機抽取一位幸運觀眾(隨機喚醒一個執行緒)。這樣做可能有導致有些執行緒沒那麼快被喚醒或者永久都不會有機會被喚醒到。
假如有資源A、B、C、D,執行緒1申請到AB,執行緒2申請到CD,執行緒3申請AB需要等待。此時有執行緒4申請CD等待,若執行緒1釋放資源時喚醒了執行緒4,但是執行緒4還是需要等待執行緒2釋放資源,執行緒3卻沒有被喚醒到。
所以除非你已經思考過了使用notify()沒問題,否則儘量使用notifyAll()

notify何時可以使用

notify需要滿足以下三個條件才能使用

1.所有等待執行緒擁有相同的等待條件。
2.所有等待執行緒被喚醒後,執行相同的操作。
3.只需要喚醒一個執行緒。

活躍性問題

活躍性問題,指的是某個操作無法再執行下去,死鎖就是其中活躍性問題,另外的兩種活躍性問題分別為 飢餓活鎖

飢餓

在上面的例子當中,我們看到執行緒3由於無法訪問它所需要的資源而不能繼續執行時,就發生了“飢餓”,如果在Java應用程式中對執行緒的優先順序使用不當或者在持有鎖時執行一些無法結束的結構(無線迴圈、無限制的等待某個資源),那麼也可能發生飢餓。
解決飢餓的問題有三種:1.保證資源充足,2.公平地分配資源,3.避免執行緒持有鎖的時間過長。但是隻有方案2比較常用到。在併發程式設計裡,主要是使用公平鎖,也就是先來後到的方案,執行緒等待是有順序的,不會去爭搶資源。這裡不展開講公平鎖.

活鎖

活鎖是另一種活躍性問題,儘管不會阻塞執行緒,但是也不能繼續執行,這就是活鎖,因為程式會不斷的重複執行相同的操作,而且總是會失敗。
就如兩個非常有禮貌的人在路上相撞,兩個人都非常有禮貌的讓到另一邊,這樣就又相撞了,然後又....,不斷地變道,不斷地相撞。
在程式設計領域當中:假如有資源A、B,執行緒1獲取到了資源A的鎖,執行緒2獲取到了資源B的鎖,此時執行緒1需要再獲取資源B的鎖,執行緒2需要再獲取資源A的鎖,兩個執行緒獲取鎖資源失敗後釋放自己所持有的鎖,然後再此重新獲取資源鎖。這是就又發生了剛才的事情。就這樣不斷的迴圈,卻又沒阻塞。這就是活鎖的例子。如圖:
活鎖.jpg
解決活鎖的問題就是各自等待一個隨機的時間再做後續操作。這樣同時相撞的概率就很低了。

總結

本文主要討論了使用等待-通知獲取鎖來優化不斷迴圈獲取鎖的機制。若獲取鎖資源耗時短和併發衝突少則也可以使用不斷迴圈獲取鎖的機制,否則儘量使用等待-通知獲取鎖。喚醒執行緒的方式有notify()notifyAll(),但是notify()只會隨機喚醒一個執行緒,容易導致執行緒飢餓,所以儘量使用notifyAll()方式來喚醒執行緒。

參考文章:
《Java併發程式設計實戰》第10章 活躍性危險
極客時間:Java併發程式設計實戰 06: 用“等待-通知”機制優化迴圈等待
極客時間:Java併發程式設計實戰 07: 安全性、活躍性以及效能問題

個人部落格網址: https://colablog.cn/

如果我的文章幫助到您,可以關注我的微信公眾號,第一時間分享文章給您
微信公眾號

相關文章