1. 程式人生 > 實用技巧 >執行緒虛假喚醒問題剖析

執行緒虛假喚醒問題剖析

好久沒寫部落格,最近在學習過程中遇到一個攔路虎:多執行緒通訊中的虛假喚醒導致資料不一致的問題,看了很多資料,也去一些博主文章下請教,發現大家的解釋都沒理解到點子上,都是在最關鍵的地方囫圇吞棗地一句帶過,這讓人很沮喪,遂寫此文,自我記錄,有需溝通可留言。


1、什麼是虛假喚醒?

虛假喚醒就是在多執行緒執行過程中,執行緒間的通訊未按照我們幻想的順序喚醒,故出現資料不一致等不符合我們預期的結果。比如 我的想法是:加1和減1交替執行,他卻出現了2甚至3這種數:請看下面例子:

假設有四個執行緒A、B、C、D同時啟動,我們定義A和B為加法執行緒,C和D為減法執行緒,每個執行緒執行5次回到原點,我們的期望結果是:0,1,0,1,0,1......0,1,0

順此進行,但執行結果卻是:

package ldk.test;

/**
 * @Author: ldk
 * @Date: 2020/12/18 16:03
 * @Describe:
 */
public class ThreadTest {



    public static void main(String[] args) {
        Data data = new Data();
        //生產者執行緒A
        new Thread(() -> {
            for (int i = 0;i < 5;i++) {
                
try { data.increment(); } catch (InterruptedException e) { e.printStackTrace(); } } },"A").start(); //生產者執行緒B new Thread(() -> { for (int i = 0;i < 5;i++) { try
{ data.increment(); } catch (InterruptedException e) { e.printStackTrace(); } } },"B").start(); //消費者執行緒C new Thread(() -> { for (int i = 0;i < 5;i++) { try { data.decrement(); } catch (InterruptedException e) { e.printStackTrace(); } } },"C").start(); //消費者執行緒D new Thread(() -> { for (int i = 0;i < 5;i++) { try { data.decrement(); } catch (InterruptedException e) { e.printStackTrace(); } } },"D").start(); } //資料類 static class Data { //表示資料個數 private int number = 0; public synchronized void increment() throws InterruptedException { if (number != 0) { this.wait(); } number++; System.out.println(Thread.currentThread().getName() + "生產了資料:" + number); this.notify(); } public synchronized void decrement() throws InterruptedException { if (number == 0) { this.wait(); } number--; System.out.println(Thread.currentThread().getName() + "消費了資料:" + number); this.notify(); } } }

2、為什麼會出現虛假喚醒?

準確的說為什麼會出現2或者3甚至是-2,-3這種情況?

我們先來說清楚概念:1、wait()方法 2、notify方法

wait:此方法出自Object類,所有物件均可呼叫此方法,它的應用主要是跟出身自Thread類的sleep方法作比較。
sleep方法說白了就是迫使當前執行緒拿著鎖睡眠指定時間,時間一到手裡拿著鎖自動醒來,還可以往下繼續執行。
wait方法有兩種使用方式,一個帶引數指定睡眠時間(我們不討論這種實現),一個不帶引數指定無限睡眠,這兩種方式均可迫使當前執行緒進入睡眠
  狀態,但是不同於sleep,wait是放開鎖去睡的,只有當前鎖物件呼叫了notify或者notifyAll方法才會醒來,但手裡是沒有鎖的,
  相對應就沒有了立即執行下去的權利,而是進入了就緒狀態,隨時準備與其他執行緒進行爭搶CPU的執行權。而且wait方法一般情況是配合sync使用的。
notify:說到notify就不得不提起notifyAll,執行完的效果是,前者通過某種底層演算法(沒去深究原理)喚醒所有wait(阻塞中)
   的執行緒中的一個,理所當然,被喚醒之後自然而然獲得了鎖,因為就他一個執行緒嘛,進而擁有了繼續執行下去的權利);後者是喚醒所有
   阻塞執行緒,進而被喚醒的所有執行緒進行一個公平競爭,只有一個勝出者可以幸運的繼續下去,其他的執行緒繼續回到阻塞狀態。

好的,概念整明白了,再繼續說:為什麼會出現 2,3 等不正常的數字,我們看程式比較極端的執行:

假設: 1、A搶到鎖執行 ++                   1
     2、A執行notify發現沒有人wait,繼續拿著鎖執行 ,A判斷不通過,A阻塞        1
    3、B搶到鎖 ,B判斷不通過,B阻塞      1

 
  4、C 搶到鎖 執行--    0
    5、C 執行Notify 喚醒A, A執行++      1    
    6、A 執行notify喚醒B ,B執行++       2  (注意這個地方恰巧喚醒B,那麼B 從哪阻塞的就從哪喚醒,B繼續執行wait下面的++操作,導致出現2)

再多一些解釋:那麼為什麼會出現
-2,-3,因為我們的減法判斷是 ==0的時候才阻塞,一旦為-1,就會為false,再次執行--操作;

看完上面的步驟分析,我們可以總結出兩大問題:
1、第6步喚醒了B是極大的錯誤,因為B的醒來不是我們想要看到的,我們需要的C或者D醒來,這就是本文題目所說的虛假喚醒,
我們就要像個辦法,過濾掉B;
2、想的深入的同學可能會發現,上面程式碼本應有20步,為什麼到了17步停止了,這就是喚醒不當,所有執行緒均被置為阻塞狀態

3、怎麼解決虛假喚醒?

直接上程式碼:主要修改了 1、if判斷為while判斷 2、notify 為notifyAll

解釋:

while是為了再一次迴圈判斷剛剛爭搶到鎖的執行緒是否滿足繼續執行下去的條件,條件通過才可以繼續執行下去,不通過的執行緒只能再次進入wait狀態,由其他活著的、就緒狀態的執行緒進行爭搶鎖

notifyAll主要是解決執行緒死鎖的情況,每次執行完++或者--操作,都會喚醒其他所有執行緒為活著的、就緒的、隨時可爭搶的狀態。

package ldk.test;

/**
 * @Author: ldk
 * @Date: 2020/12/18 16:03
 * @Describe:
 */
public class ThreadTest {



    public static void main(String[] args) {
        Data data = new Data();
        //生產者執行緒A
        new Thread(() -> {
            for (int i = 0;i < 5;i++) {
                try {
                    data.increment();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"A").start();

        //生產者執行緒B
        new Thread(() -> {
            for (int i = 0;i < 5;i++) {
                try {
                    data.increment();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"B").start();

        //消費者執行緒C
        new Thread(() -> {
            for (int i = 0;i < 5;i++) {
                try {
                    data.decrement();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"C").start();

        //消費者執行緒D
        new Thread(() -> {
            for (int i = 0;i < 5;i++) {
                try {
                    data.decrement();
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        },"D").start();
    }



    //資料類
    static class Data {
        //表示資料個數
        private int number = 0;

        public synchronized void increment() throws InterruptedException {
            //關鍵點,這裡應該使用while迴圈
            while (number != 0) {
                this.wait();
            }
            number++;
            System.out.println(Thread.currentThread().getName() + "生產了資料:" + number);
            this.notifyAll();
        }

        public synchronized void decrement() throws InterruptedException {
            //關鍵點,這裡應該使用while迴圈
            while (number == 0) {
                this.wait();
            }
            number--;
            System.out.println(Thread.currentThread().getName() + "消費了資料:" + number);
            this.notifyAll();
        }
    }
}

舉一個生活小栗子:就好比你的公司僱傭了五個保安,工作要求是:保安輪流站崗,必須按照1、2、3、4、5這個順序來站崗。

  那麼我們上面的程式就是,1號保安站崗結束,喚來其他四個保安,然後讓每個保安舉手搶答,誰先舉手我就先判斷誰,你是2號就輪到你站崗,你不是2號,就繼續wait,然後繼續舉手搶答,如此一來即可解決公平的站崗需求。

  其中最關鍵的是:1、首先,每次工作結束都需要喚醒所有執行緒來任我挑選;

          2、其次,你爭搶到鎖,我會對你進行一次有效判斷,合格才放行。