面試必問系列,源碼解析多線程絕對不容忽視得問題:線程活性故障 死鎖 二、死鎖的產生條件 三、規避死鎖 四、鎖死 1、信號丟失鎖死 2、嵌套監視器鎖死 五、線程飢餓 六、活鎖

看多了各種多線程得內容,我們是不是忘記了某一個很重要得知識點——線程活性故障

線程活性故障是由於資源稀缺性或者程序自身的問題導致線程一直處於非 Runnable 狀態,或者線程雖然處於 Runnable 狀態但是其要執行的任務一直無法取得進展的一種故障現象

關注公衆號:Java架構師聯盟,每日更新技術好文

下面就來介紹幾種常見類型的線程活性故障:

  1. 死鎖
  2. 鎖死
  3. 線程飢餓
  4. 活鎖

死鎖

對於死鎖得問題,我們有一個非常非常好玩的問題---哲學家喫飯,乾飯人乾飯魂,我們就通過這個講解引入一下 啊

假設有 5 個哲學家,他們的生活只是思考和喫飯。這些哲學家共用一個圓桌,每位都有一把椅子。在桌子中央有一碗米飯,在桌子上放着 5 根筷子(圖 1 )。

當一位哲學家思考時,他與其他同事不交流。時而,他會感到飢餓,並試圖拿起與他相近的兩根筷子(筷子在他和他的左或右鄰居之間)。一個哲學家一次只能拿起一根筷子。顯然,他不能從其他哲學家手裏拿走筷子。當一個飢餓的哲學家同時擁有兩根筷子時,他就能喫。在喫完後,他會放下兩根筷子,並開始思考。
哲學家就餐問題是一個經典的同步問題,這不是因爲其本身的實際重要性,也不是因爲計算機科學家不喜歡哲學家,而是因爲它是大量併發控制問題的一個例子。這個代表性的例子滿足:在多個進程之間分配多個資源,而且不會出現死鎖和飢餓。

我們可以用一段程序來模擬並驗證上述的情況

先對筷子 Chopstick 進行定義,其能被操作的行爲只有兩種,即:拿起放下

package com.test.lock;

/**
 * @author :biws
 * @date :Created in 2020/12/18 22:29
 * @description:
 */
public class Semaphore extends Object {
    private int count;

    public Semaphore(int startingCount){
        count = startingCount;
    }

  //放下

    public void down(){
        synchronized (this) {
            while (count <= 0) {
                // We must wait
                try {
                    wait();
                } catch (InterruptedException ex) {
                    // I was interupted, continue onwards
                }
            }

            // We can decrement the count
            count--;
        }

    }

  //拿起
    public void up(){
        synchronized (this) {
            count++;
            //notify a waiting thread to wakeup
            if (count == 1 ) {
                notify();
            }
        }
    }
}

最後,運行程序後,只要我們爲哲學家設定每次思考和喫飯的耗時時間不要太長,那麼應該就能很快看到程序沒有繼續輸出日誌了,似乎被卡住了,此時即發生了死鎖

package com.test.lock;

/**
 * @author :biws
 * @date :Created in 2020/12/18 22:30
 * @description:
 */
public class Philosopher extends Thread {
    // Shared by all Philosophers
    public final static int N = 5;              // Number of philosophers

    public final static int THINKING = 0;       // Philosopher is thinking
    public final static int HUNGRY = 1;         // Philosopher is hungry
    public final static int EATING = 2;         // Philosopher is eating

    private static int state[] = new int[N];    // Array to keep track of
    // everyones state

    private static Semaphore mutex = new Semaphore(1);  // Mutual exclusion for
    // critical regions
    private static Semaphore s[] = new Semaphore[N];    // One for each
    // Philosopher
    // Instance variable
    public int myNumber;                    // Which philosopher am I
    public int myLeft;                      // Number of my left neighbor
    public int myRight;                     // Number of my right neighbor

    public Philosopher(int i) {             // Make a philosopher
        myNumber = i;
        myLeft = (i + N - 1) % N;               // Compute the left neighbor
        myRight = (i + 1) % N;                // Compute the right neighbor
    }

    public void run() {                     // And away we go
        while (true) {
            think();                        // Philosopher is thinking
            take_forks();                   // Acquire two forks or block
            eat();                          // Yum-yum, spahgetti
            put_forks();                    // Put both forks back on the table
        }
    }

    public void take_forks() {               // Take the forks I need
        mutex.down();                       // Enter critical region
        state[myNumber] = HUNGRY;           // Record the fact that I am hungry
        test(myNumber);                     // Try to acquire two forks
        mutex.up();                         // Leave critical region
        s[myNumber].down();                 // Block if forks were not acquired
    }

    public void put_forks() {
        mutex.down();                       // Enter critical region
        state[myNumber] = THINKING;         // Philosopher has finished eating
        test(myLeft);                       // See if left neighbor can now eat
        test(myRight);                      // See if right neighbor can now
        // eat
        mutex.up();                         // Leave critical region
    }

    public void test(int k) {                // Test philosopher k,
        // from 0 to N-1
        int onLeft = (k + N - 1) % N;           // K's left neighbor
        int onRight = (k + 1) % N;            // K's right neighbor
        if (state[k] == HUNGRY
                && state[onLeft] != EATING
                && state[onRight] != EATING) {

            // Grab those forks
            state[k] = EATING;
            s[k].up();
        }
    }

    public void think() {
        System.out.println("Philosopher " + myNumber + " is thinking");
        try {
            sleep(1000);
        } catch (InterruptedException ex) {
        }
    }

    public void eat() {
        System.out.println("Philosopher " + myNumber + " is eating");
        try {
            sleep(5000);
        } catch (InterruptedException ex) {
        }
    }

    public static void main(String args[]) {

        Philosopher p[] = new Philosopher[N];

        for (int i = 0; i < N; i++) {
            // Create each philosopher and their semaphore
            p[i] = new Philosopher(i);
            s[i] = new Semaphore(0);

            // Start the threads running
            p[i].start();
        }
    }

}

根據輸出日誌可以分析出,最後每位哲學家均拿到了其左手邊的筷子,且均在等待右手邊的筷子被放下,但此時由於筷子是獨佔資源,所以每位哲學家都只能幹瞪着眼無法喫飯,最終導致了死鎖

但是我的結果,沒怎麼等得時間特別長

大家可以多等一會,然後自己跟我一樣執行一下,如果有時間的話,我寫着寫着就實在寫不下去了,嘿嘿嘿

二、死鎖的產生條件

哲學家就餐問題反映了發生死鎖的必要條件,線程一旦發生死鎖,那麼這些線程及相關的共享資源就一定同時滿足以下條件:

資源互斥。涉及的資源必須是排他性資源,即每個資源每次只能由一個線程持有

資源不可搶奪。涉及的資源只能由其持有線程主動釋放,其它線程無法從持有線程中主動奪得

佔用並等待其它資源。涉及的線程當前至少已經持有了一個排他性資源,並在申請其它資源,而這些資源同時又被其它線程所持有。在這個資源等待過程中,線程不會主動釋放持有的現有資源

循環等待資源。在涉及到的所有線程列表內部,每個線程均在互相等待其它線程釋放持有的資源,形成了互相等待的圓形依賴關係。即存在一個處於等待狀態的線程集合 {T1, T2, ..., Tn},其中 Ti 等待的資源被 T(i+1) 佔有(i 大於等於 1 小於 n),Tn 等待的資源被 T1 佔有

當產生死鎖得時候,以上條件就一定同時成立

但是需要注意一點:

當上述條件即使同時成立也未必就一定能產生死鎖。所以這也是爲什麼在測試的時候不出問題,但是一上線就出各種問題得原因

三、規避死鎖

從上訴的四個發生死鎖的必要條件來反推,我們只要消除死鎖產生的任意一個必要條件就可以規避死鎖了。由於鎖具有排他性且只能由其持有線程來主動釋放,因此由鎖導致的死鎖只能從消除“佔用並等待資源”和消除“循環等待資源”這兩個方向入手。

今天有點累了,所以從網上找到一些相應得解決方案,附僞代碼,給大家提供一個思路,後面我會將代碼進行提交

方法一

至多隻允許四位哲學家同時去拿左筷子,最終能保證至少有一位哲學家能進餐,並在用完後釋放兩隻筷子供他人使用。

設置一個初值爲 4 的信號量 r,只允許 4 個哲學家同時去拿左筷子,這樣就能保證至少有一個哲學家可以就餐,不會出現餓死和死鎖的現象。

原理:至多隻允許四個哲學家同時進餐,以保證至少有一個哲學家能夠進餐,最終總會釋放出他所使用過的兩支筷子,從而可使更多的哲學家進餐。

方法二

僅當哲學家的左右手筷子都拿起時才允許進餐。

解法 1:利用 AND 型信號量機制實現。

原理:多個臨界資源,要麼全部分配,要麼一個都不分配,因此不會出現死鎖的情形。

解法 2:利用信號量的保護機制實現。

原理:通過互斥信號量 mutex 對 eat() 之前取左側和右側筷子的操作進行保護,可以防止死鎖的出現。

方法三

規定奇數號哲學家先拿左筷子再拿右筷子,而偶數號哲學家相反。

原理:按照下圖,將是 2,3 號哲學家競爭 3 號筷子,4,5 號哲學家競爭 5 號筷子。1 號哲學家不需要競爭。最後總會有一個哲學家能獲得兩支筷子而進餐。

四、鎖死

等待線程由於喚醒其所需的條件永遠無法成立,或者是其它線程無法喚醒這個線程導致其一直處於非運行狀態(線程並未終止)從而任務一直取得進展,那麼我們稱這個線程被鎖死

鎖死和死鎖之間有着共同的外在表現:故障線程一直處於非運行狀態而使得其任務無法進展。死鎖針對的是多個線程,而鎖死可能只是作用在一個線程上。例如,一個調用了 Object.wait() 處於等待狀態的線程,由於發生異常或者是代碼缺陷,導致一直沒有外部線程調用 Object.notify() 方法來喚醒等待線程,使得線程一直處於等待狀態無法運行,此時就可以說該線程被鎖死

鎖死和死鎖的產生條件是不同的,即便是在產生死鎖的所有必要條件都不成立的情況下(此時死鎖不可能發生),鎖死仍可能出現。因此應對死鎖的辦法未必能夠用來避免鎖死現象的發生。按照鎖死產生的條件來分,鎖死包括信號丟失鎖死和嵌套監視器鎖死

1、信號丟失鎖死

信號丟失鎖死是由於沒有相應的通知線程來喚醒等待線程而使等待線程一直處於等待狀態的一種活性故障

例如,某個等待線程在執行 Object.wait() 前沒有對保護條件進行判斷,而此時保護條件實際上已經成立了,然而此後可能並無其他線程會來喚醒等待線程,因爲在等待線程獲得 Object 內部鎖之前保護條件已經是處於成立狀態了,這就使得等待線程一直處於等待狀態,其任務一直無法取得進展

信號丟失鎖死的另外一個常見例子是由於 CountDownLatch.countDown() 沒有放在 finally塊中,而如果 CountDownLatch.countDown() 的執行線程運行時拋出未捕獲的異常時, CountDownLatch.await() 的執行線程就會一直處於等待狀態從而任務一直無法取得進展

例如,對於以下代碼,當 ServiceB 拋出異常時,main 線程就會由於一直無法收到喚醒通知從而一直處於等待狀態

fun main() {
    val serviceManager = ServicesManager()
    serviceManager.startServices()
    println("等待所有 Services 執行完畢")
    val allSuccess = serviceManager.checkState()
    println("執行結果: $allSuccess")
}

class ServicesManager {

    private val countDownLatch = CountDownLatch(2)

    private val serviceList = mutableListOf<AbstractService>()

    init {
        serviceList.add(ServiceA("ServiceA", countDownLatch))
        serviceList.add(ServiceB("ServiceB", countDownLatch))
    }

    fun startServices() {
        serviceList.forEach {
            it.start()
        }
    }

    fun checkState(): Boolean {
        countDownLatch.await()
        return serviceList.find { !it.checkState() } == null
    }

}

abstract class AbstractService(private val countDownLatch: CountDownLatch) {

    private var success = false

    abstract fun doTask(): Boolean

    fun start() {
        thread {
//            try {
//                success = doTask()
//            } finally {
//                countDownLatch.countDown()
//            }
            success = doTask()
            countDownLatch.countDown()
        }
    }

    fun checkState(): Boolean {
        return success
    }

}

class ServiceA(private val serviceName: String, countDownLatch: CountDownLatch) : AbstractService(countDownLatch) {

    override fun doTask(): Boolean {
        Thread.sleep(2000)
        println("${serviceName}執行完畢")
        return true
    }

}

class ServiceB(private val serviceName: String, countDownLatch: CountDownLatch) : AbstractService(countDownLatch) {

    override fun doTask(): Boolean {
        Thread.sleep(3000)
        if (Random.nextBoolean()) {
            throw  RuntimeException("$serviceName failed")
        } else {
            println("${serviceName}執行完畢")
        }
        return true
    }

}

2、嵌套監視器鎖死

嵌套監視器鎖死是嵌套鎖導致等待線程永遠無法被喚醒的一種活性故障

來看以下僞代碼。假設存在一個等待線程,其先後持有了 monitorX 和 monitorY 兩個不同的鎖,當等待線程監測到當前執行條件不成立時,調用了 monitorY.wait() 等待通知線程來喚醒自身,並同時釋放了鎖 monitorY

    synchronized(monitorX) {
        //...
        synchronized(monitorY) {
            while (!somethingOk) {
                monitorY.wait()
            }
            //執行目標行爲
        }
    }

相應的通知線程其僞代碼如下所示。通知線程需要持有了 monitorX 和 monitorY 兩個鎖才能執行到 monitorY.notifyAll() 這行代碼來喚醒等待線程。而等待線程執行 monitorY.wait() 時僅會釋放 monitorY,而不會釋放 monitorX。這使得通知線程由於一直獲得 monitorX, 從而導致等待線程一直無法被喚醒而一直處於 BLOCKED 狀態

    synchronized(monitorX) {
        //...
        synchronized(monitorY) {
            //...
            somethingOk = true
            monitorY.notifyAll()
            //...
        }
    }

這種由於嵌套鎖導致通知線程始終無法喚醒等待線程的活性故障就被稱爲嵌套監視器鎖死

五、線程飢餓

線程飢餓是指線程一直無法獲得所需資源從而導致任務無法取得進展的一種活性故障現象

產生線程飢餓的一種情況是:線程一直沒有被分配到處理器時間片。這種情況一般是由於處理器時間片一直被高優先級的線程搶佔,低優先級的線程一直無法獲得運行機會,此時即發生了線程飢餓現象。Thread 類提供了修改線程優先級的成員方法setPriority(Int),定義了整數一到十之間的十個優先級級別。不同的操作系統會有不同的線程優先級等級,JVM 會把這 Thread 類的十個優先級級別映射到具體的操作系統所定義的線程優先級關係上。但是我們所設置的線程優先級對線程調度器來說只是一個建議,當我們將一個線程設置爲高優先級時,極可能會被線程調度器忽略,也可能會使該線程過度優先執行而別的線程一直得不到處理器時間片,從而導致線程飢餓。因此我們應該儘量避免修改線程的優先級

把鎖看做一種資源,那麼死鎖也是一種線程飢餓。死鎖的結果是所有故障線程都無法獲得其所需的全部鎖,從而使得其任務一直無法取得進展,這就相當於線程無法獲得所需的全部資源從而導致任務無法取得進展,即產生了線程飢餓

發生線程飢餓並不一定同時存在死鎖。因爲線程飢餓可能只發生在一個線程上(例如上述的低優先級線程無法獲得時間片),且即使是同時發生在多個線程上,也可能並不滿足死鎖發生的必要條件之一:循環等待資源,因爲此時涉及到的多個線程所等待的資源可能並沒有相互依賴關係

六、活鎖

活鎖指的是任務和任務的執行線程均沒有被阻塞,但由於某些條件沒有滿足,導致線程一直在重複嘗試—失敗—嘗試的過程,任務一直無法取得進展。也就是說,產生活鎖的線程雖然處於 Runnable 狀態,但是一直在做無用功

例如,對於上述的哲學家問題,假設某位哲學家“比較有禮貌”,當其拿起了左手邊的筷子時,如果恰好有其他哲學家需要這根筷子,有禮貌的哲學家就主動放下筷子,讓給其他哲學家使用。在最極端的情況下,每當有禮貌的哲學家一想要喫飯並拿起左手邊的筷子時,就有其他哲學家需要這根筷子,此時有禮貌的哲學就會一直處於拿起筷子-放下筷子-拿起筷子這樣一個循環過程中,導致一直無法喫飯。此時並沒有發生死鎖,但對於有禮貌的哲學家所代表的線程來說就是發生了活鎖

到這裏,基本幾個概念都涵蓋了,但是這套代碼實現說時候,有點頭疼,容我慢慢來更新吧,嘿嘿嘿,定時了,晚安

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