一起分析執行緒的狀態及執行緒通訊機制

蘇蘇喂蘇蘇+發表於2020-07-29

本文在個人技術部落格同步釋出,詳情可用力戳
亦可掃描螢幕右側二維碼關注個人公眾號,公眾號內有個人聯絡方式,等你來撩...

  多執行緒程式設計一直是普通程式設計師進階為高階程式設計師的必備技能之一!他很難,難就難在難以理解、難以除錯,出現了bug很難發現及排查。他很重要,因為我們可能隨時都面對著執行緒的切換、排程,只是這些都由CPU來幫我們完成我們無法感知。

  記得我在剛開始學C語言的時候,只會在黑視窗裡面列印一個helloworld、列印一個斐波拉契數列、列印乘法口訣表。當時覺得很枯燥,也不知道這個能學來幹嘛。等到後面工作中才發現這些都是基礎,有了這些基礎才能做更高階一點的開發!其實多執行緒程式設計也是一樣,學習基礎的時候很枯燥,也不知道學了能幹嘛。我不會多執行緒程式設計不也一樣能寫CRUD麼,不也能實現工作中的需求麼?但是要是想去大廠或者網際網路公司,不會多執行緒可能就直接被pass掉了吧!

  本文不會講什麼是執行緒、什麼是程式這些概念,不清楚這些概念的可以自己先去學習下,文中的程式碼都由java語言編寫!

執行緒的基本狀態

開局一張圖,內容全靠“編”!我們先來看一張圖。

  1582286718807

  這張圖參考網上看到的一張圖,它比較詳細的描述了執行緒的生命週期以及幾種基本狀態。這張圖我分了兩塊區域來講,我們接下來先分析下區域一。

  目前正值新冠肺炎肆虐之際,口罩一時成了出門必備的稀缺之物!假設我們有個生產口罩的工廠,能夠生產及銷售口罩。

class MaskFactory {

    public void produce() throws InterruptedException {
        // #3
        // Thread.sleep(1000);
        System.out.println("生產了1個口罩");
    }

    public void consume() {
        System.out.println("消費了1個口罩");
    }
}

public class Demo1 {

    public static void main(String[] args) {

        final MaskFactory maskFactory = new MaskFactory();

        // #1
        Thread threadA = new Thread(new Runnable() {
            public void run() {
                try {
                    maskFactory.produce();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        });

        // #2
        threadA.start();
    }
}

  上述程式碼中,我們建立了一個執行緒去生產口罩,那執行緒的的狀態會經過什麼樣的變化呢?

1、在程式碼#1處,我們建立了一個新的執行緒,對應圖中的狀態New

2、這時候執行緒還不會執行,直到我們在程式碼#2處呼叫了start()方法,執行緒的狀態經過線路1變為了Runable

3、此時,執行緒已經準備就緒,隨時可能執行!至於什麼時候能執行呢?這個是不確定的,完全取決於CPU的排程(當然,我們可以設定執行緒的優先順序來控制執行緒獲取執行時間的概率)!如果這條執行緒得到CPU執行時間之後,執行緒的狀態會經過線路2變為Running,這是執行緒就在執行中了。

4、在上述程式碼中如果執行緒狀態為Running後,不出意外會走線路3狀態變為Dead,執行緒執行結束!

5、但是有時候就是可能會有意外的發生,那就是上線文切換!如果發生上下文切換,那麼當前執行緒就會讓出CPU資源,執行緒會從Running狀態經過線路4又變回Runable狀態!直到執行緒再次得到CPU的執行時間!也就是說線路2和線路4可能會來回多次!如圖中所示,如果在Running狀態的執行緒呼叫了yield()方法,那這個執行緒會主動讓出CPU資源,狀態從Running狀態變回Runable狀態!

6、程式碼#3處的註釋如果放開,那執行緒在Running狀態會經過線路5變為Blocked狀態。在阻塞過程中,執行緒會讓出CPU資源進入睡眠狀態。等到阻塞過了指定的時間後,執行緒會經過線路6由Blocked狀態變為Runable狀態,等待CPU的排程再次執行!

  區域一的5個狀態我們前面都已經分析過了,算是比較好理解的!區域二的2個狀態就稍微有點複雜了,分別是Blocked in Object's Lock PoolBlocked in Object's Wait Pool。這兩個狀態的共同點是Blocked in Object's ...,這裡的object是什麼呢?什麼物件?誰的物件?其實這就跟我們的鎖密切相關了。從名字我們能看出來,其實這兩個狀態也屬於阻塞狀態,但是與我們上面說過的通過sleep()阻塞又不一樣!

執行緒間的通訊

我們繼續改進上面的程式碼,並且啟動兩個執行緒,一個負責生產口罩一個負責消費口罩,分別執行10次。並且生產執行緒和消費執行緒必須交替的執行!程式碼如下:

class MaskFactory {

    private int number = 0;

    // 生產
    public synchronized void produce() {
        if (number != 0) {
            try {
                // 等待
                this.wait();
            } catch (InterruptedException e) {
            }
        }

        number++;
        System.out.println("生產執行緒" + Thread.currentThread().getName() + "執行,目前口罩數量:" + number);
        // 通知
        this.notifyAll();
    }

    // 消費
    public synchronized void consume() {
        if (number == 0) {
            try {
                // 等待
                this.wait();
            } catch (InterruptedException e) {
            }
        }

        number--;
        System.out.println("消費執行緒" + Thread.currentThread().getName() + "執行,目前口罩數量:" + number);
        // 通知
        this.notifyAll();
    }
}

public class Demo1 {

    public static void main(String[] args) {

        final MaskFactory maskFactory = new MaskFactory();

        // 1
        new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 10; i++) {
                    maskFactory.produce();
                }

            }
        }, "A").start();

        // 2
        new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 10; i++) {
                    maskFactory.consume();
                }

            }
        }, "B").start();
    }
}

  執行結果如何呢?

  1582120624902

  接下來我們就結合程式碼和之前,我們需要注意下面幾點:

1、produce和consume方法上的synchronized

2、不滿足條件時候的this.wait()

3、滿足條件執行後的this.notifyAll()

  用過synchronized關鍵字的寶寶應該知道,在非靜態方法上,鎖定的是例項物件。wait和notifyAll方法前面的this也指向當前例項。我們進入wait方法和notifyAll方法發現他們都是object基類的方法。也就是說在java中任何物件都有這兩個方法。既然這樣,那我們可以可以隨便new一個物件,然後在其中呼叫wait和notifyAll方法呢?答案是不行的!因為我們的等待和通知都是跟鎖相關的,所以synchronized鎖定的物件以及呼叫wait和notifyAll方法的物件必須是同一個!

  基於上面的前提我們再來分析一下另外兩個阻塞狀態:

1、如果方法有加synchronized關鍵字,那當A執行緒在進入這個方法後且狀態為Running或者Blocked時,B執行緒再想進入則會被阻塞,也就是說B執行緒會從Running狀態經過線路7變為Blocked in Object's Lock Pool狀態,一直在這裡阻塞著等待鎖資源,所以這個等待區也叫等鎖池。注意,produce和consume雖然是兩個方法,但是他們鎖定都是同一個物件!也就是說A執行緒在執行produce方法時候,如果B執行緒想進入consume方法,那也只能被阻塞!

2、如果A執行緒執行produce方法時發現number!=0,則會執行this.wait(),那A執行緒會從Running狀態經過線路8變為Blocked in Object's Wait Pool狀態,A執行緒釋放鎖資源!這時候執行緒就在這裡等待阻塞著等待著被其他執行緒通知喚醒,所以這個等待區也叫等待池

3、如果B執行緒再次得到執行consume方法時發現number!=0,則會正常執行邏輯,並且執行this.notifyAll()。這時候在等待池中的執行緒A執行緒會收到通知被喚醒,狀態由Blocked in Object's Wait Pool經過線路9變為Blocked in Object's Wait Pool,執行緒A就進入了等鎖池等待著鎖資源。當A執行緒再次競爭到鎖資源時,狀態會經過線路10變為Runable,等待著CPU的再次排程!

執行緒間的虛假喚醒

  到這裡一切好像都很完美,如果執行緒A執行緒不滿足條件則進行等待B執行緒執行後喚醒。執行緒B執行緒不滿足條件則進行等待A執行緒執行後喚醒。那我們再多加兩條執行緒C、D執行會出現什麼樣的結果呢?程式碼如下(MaskFactory類的程式碼與上一個例子一樣):

class MaskFactory {

    private int number = 0;
    private int index = 1;

    // 生產
    public synchronized void produce() {
        if (number != 0) {
            try {
                // 等待
                this.wait();
            } catch (InterruptedException e) {
            }
        }

        number++;
        System.out.println("第"+ index++ + "行:生產執行緒" + Thread.currentThread().getName() + "執行,目前口罩數量:" + number);
        // 通知
        this.notifyAll();
    }

    // 消費
    public synchronized void consume() {
        if (number == 0) {
            try {
                // 等待
                this.wait();
            } catch (InterruptedException e) {
            }
        }

        number--;
        System.out.println("第"+ index++ + "行:消費執行緒" + Thread.currentThread().getName() + "執行,目前口罩數量:" + number);
        // 通知
        this.notifyAll();
    }
}

public class Demo1 {

    public static void main(String[] args) {

        final MaskFactory maskFactory = new MaskFactory();

        // 1
        new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 10; i++) {
                    maskFactory.produce();
                }

            }
        }, "A").start();

        // 2
        new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 10; i++) {
                    maskFactory.consume();
                }

            }
        }, "B").start();

        // 3
        new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 10; i++) {
                    maskFactory.produce();
                }

            }
        }, "C").start();

        // D
        new Thread(new Runnable() {
            public void run() {
                for (int i = 0; i < 10; i++) {
                    maskFactory.consume();
                }

            }
        }, "D").start();
    }
}

  執行緒A、C負責生產,D、B執行緒負責消費,那輸出結果是什麼樣子的呢?思考一分鐘然後看結果:

  1582291734891

  其實輸出結果我們誰也沒法預期會怎麼輸出!但是可以確定的一點是生產執行緒和消費執行緒不一定會交替執行,也就是說不一定會按照01010101規律輸出! 為什麼說不一定呢?難道說也有可能會交替輸出麼?是的,我們多執行幾次程式碼發現每次輸出的結果都可能不一樣!我們就結合上面的結果來分析一下:

1、前面四次都正常,我們可以理解為A、D執行緒在交替執行。

2、在第3行A執行緒執行完之後,A執行緒沒有釋放鎖,而是繼續迴圈再次呼叫produce方法,但是由於此時number!=0,所以A執行緒被阻塞了。

3、第4行D執行緒執行完成後喚醒了執行緒A,A執行緒得到了繼續執行的機會。

4、但是CPU先排程了C執行緒,因此第5行C執行緒輸出了1。

5、CPU並沒有立即切換排程,而是讓C執行緒繼續迴圈再次呼叫produce方法,但是由於此時number!=0,所以C執行緒被阻塞了。

4、之前被喚醒的A執行緒再次搶佔CPU,因此接著this.wait()後面的程式碼執行,但是此時number已經為1了,所以number++就變成2了,因此在第6行輸出了2,並且喚醒了C執行緒。

6、被喚醒的C執行緒再次搶佔CPU,因此接著this.wait()後面的程式碼執行,但是此時number已經為2了,所以number++就變成3了,因此在第7行輸出了3。

.........

  整個過程有點繞,不清楚的得多看幾遍才能慢慢理解。一個大前提是前面說到過的已經準備就緒的執行緒,隨時可能執行!至於什麼時候能執行呢?這個是不確定的,完全取決於CPU的排程!上述的現象產生的原因就是執行緒的虛假喚醒,也就是本不應該喚醒的執行緒被喚醒了,因此輸出出現異常!那這樣的問題該怎麼解決呢?其實很簡單:

//將if替換為while
while (number != 0) {
            try {
                // 等待
                this.wait();
            } catch (InterruptedException e) {
            }
        }

  只需要將之前程式碼中的if替換為while,當每次喚醒之後不是繼續往下執行,而是再判斷一次狀態是否符合條件,不符合條件則繼續等待!

精準通知順序訪問

  我們使用notifyAll的時候,只要是在等待池中的執行緒都會一股腦的全部都通知。那麼這裡我們思考一下,當等待池中有消費執行緒也有生產執行緒的時候,我們是不是可以在生產執行緒執行後只通知消費執行緒,在消費執行緒執行後只通知生產執行緒呢?

如果要實現精準的通知,那就得用另外一套鎖邏輯了,我們先看實現程式碼(建立執行緒的邏輯與前面類似,為節約篇幅這裡不再列出):

class MaskFactory {

    private int number = 0;

    private Lock lock = new ReentrantLock();
    Condition consumeCondition = lock.newCondition();
    Condition produceContion = lock.newCondition();

    // 生產
    public void produce() {
        lock.lock();
        try {
            while (number != 0) {
                // 等待一個生產的訊號
                produceContion.await();
            }

            number++;
            System.out.println("生產執行緒" + Thread.currentThread().getName() + "執行,目前口罩數量:" + number);
            // 發出消費的訊號
            consumeCondition.signalAll();
        } catch (Exception e) {
        } finally {
            lock.unlock();
        }

    }

    // 消費
    public void consume() {
        lock.lock();
        try {
            while (number == 0) {
                // 等待一個消費的訊號
                consumeCondition.await();
            }

            number--;
            System.out.println("消費執行緒" + Thread.currentThread().getName() + "執行,目前口罩數量:" + number);
            // 發出生產的訊號
            produceContion.signalAll();
        } catch (Exception e) {
        } finally {
            lock.unlock();
        }
    }
}

  這裡我們就使用了ReentrantLock來替代synchronized,一個ReentrantLock物件可以建立多個通知條件,也就是程式碼中的consumeCondition和produceContion。當生產執行緒呼叫produce時發現不滿足條件,則會執行produceContion.await()進行等待,直到有消費執行緒呼叫produceContion.signalAll()時,生產執行緒才會被喚醒!這也就實現了執行緒的精準通知!因此用ReentrantLock來替代synchronized可以更加靈活的控制執行緒!

常見面試題

  下面我們來看幾個可能會遇到的面試題

synchornized與lock區別?

1、Lock 的鎖定是通過java程式碼實現的,而 synchronized 是在 JVM 層面上實現的。

2、synchronized 在鎖定時如果方法塊丟擲異常,JVM 會自動將鎖釋放掉,不會因為出了異常沒有釋放鎖造成執行緒死鎖。但是 Lock 的話就享受不到 JVM 帶來自動的功能,出現異常時必須在 finally 將鎖釋放掉,否則將會引起死鎖。

3、就像我們上面的例子,Lock能實現精準的通知,但是synchronized不行!

sleep()、yield()有什麼區別?

1、從之前的圖上能看出來,執行緒執行sleep方法後會先變成Blocked狀態,等到了阻塞時間後會辯詞Runable狀態,而執行yield方法後會直接變成Runable狀態。

2、sleep讓出CPU資源而給其他執行緒執行機會時不會考慮執行緒的優先順序,而yield只會給相同優先順序或更高優先順序的執行緒以執行的機會。

3、sleep()方法宣告丟擲InterruptedException,而yield()方法沒有宣告任何異常。

4、sleep()方法比yield()方法(跟作業系統CPU排程相關)具有更好的可移植性。

sleep()、與wait()有什麼區別?

1、sleep方法時Thread類的靜態方法,而wait是Object類的方法。

2、sleep會使執行緒進入阻塞狀態,只有在阻塞時間到後才會重新進入就緒階段等待cpu排程。而wait會使執行緒進入鎖定物件的等待池,直到其他執行緒呼叫同一個物件的notify方法才會重新被啟用。

3、wait方法必須在同步塊中,並且呼叫wait方法的物件必須與同步塊鎖定的是同一個物件。呼叫wait方法的執行緒會釋放鎖資源,而在同步塊中呼叫sleep方法的執行緒不會釋放鎖資源!

相關文章