線程虛假喚醒問題剖析

 

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

 

 

 


 

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、其次,你爭搶到鎖,我會對你進行一次有效判斷,合格才放行。