我靠!Semaphore裏面竟然有這麼一個大坑!

這是why的第 59 篇原創文章spring

荒腔走板

你們好,我是why哥 ,歡迎來到我連續周更優質原創文章的第 59 篇。編程

上週寫了一篇文章,一不當心戳到了你們的爽點,其中一個轉載我文章的大號,閱讀量竟然突破了 10w+,我也是受寵若驚。springboot

可是其實我是一個技術博主來的,偶爾寫點生活相關的。因此這篇仍是回到技術上。多線程

可是個人技術文章有個特色是第一張圖片都是我本身拍的。而後我會圍繞這個圖片進行一個簡短的描述,我稱之爲荒腔走板環節。併發

目的是給冰冷的技術文注入一絲色彩。dom

我這樣作已經堅持了不少篇 ,有的讀者給我說:看完荒腔走板部分就退出去了。ide

那大家是真的棒哦,至少退出去以前,拉到文末,來個一鍵三連吧,給我來點正反饋。高併發

好了,先說說這期的荒腔走板。測試

上面這個圖片是我上週末看《樂隊的夏天》的時候拍的。ui

這個樂隊的名字叫作水木年華,我喜歡這個樂隊。

我聽他們的歌的時候,應該是初中,那個時候磁帶已經差很少快過氣了,進入了光碟的時代,我記得一張光碟裏面有好幾十首歌,第一次在 DVD 裏面聽到他們的歌是《一輩子有你》,聽到這首歌的時候就感受很乾淨,很驚豔。

而後一字一句抄在本身的歌詞本上。

聽到這首歌的那個週末,我就看着那個 MV 反覆學,那時的 DVD 有個功能是能夠 A-B 反覆播放某個片斷,我就一句一句的學,學會了這首歌。

那時候的李健,一雙清澈明亮的大眼睛,就像一汪湖水,我一個小男孩,都好想在他的眼睛裏扎個猛子。

這首歌,我願稱之爲校園民謠的巔峯之一。

十多年後的今天,這個樂隊從新出如今個人視野中,只是李健已經再也不其中。

他們在樂夏的舞臺上唱了一首《青春再見》,結果被一個自稱 23 歲的胖小夥說「中年人的油膩」,被另個專業樂迷說:「四十多歲的人怎麼還在唱青春再見?」。第一期就被淘汰出局。

這操做,看的我一愣一愣的。

這個怎麼就油膩了?四十多歲的人怎麼就不能唱青春再見了?男人至死都是少年大家不知道嗎?小子,他們玩音樂的時候你還不會說話呢。

他們離開舞臺的畫面,我感受到一絲辛酸,一絲真的青春再見的辛酸。

水木年華沒有錯,錯的是這個舞臺,這個舞臺不適合他們的歌曲。

好了,說迴文章。

一塊兒看個問題

前幾天有個讀者給我發了一個連接,說這個連接裏面的代碼,爲何會這樣運行,實在是沒有搞懂是怎麼回事,連接以下:

https://springboot.io/t/topic/1139

代碼是這樣的,給你們上個圖:

注意第 10 行,permits 參數,根據他的描述應該是 3:

不知道爲何代碼裏面給了一個 2。可是爲了保證真實,我直接拿過來了,沒有進行改動。一會我會根據這個代碼進行簡單的修改。

知道 semaphore 是幹啥的同窗能夠先看看上面的代碼,爲何形成了「死鎖」。

反正是一個很是無語的低級錯誤,可是我反覆看了幾遍竟然沒有看出來。

不知道 semaphore 是幹啥的同窗,看過來。我先給你科普一下。

semaphore 咱們通常叫它信號量,用來控制同時訪問指定資源的線程數量

若是不懂 semaphore ,那上面代碼你也看不懂了,我按照代碼的邏輯給你舉個例子。

好比一個高端停車場,只有 3 個車位。(這就是「指定資源」)

如今裏面沒有停車,那麼它最多能夠停幾輛車呢?

是的,門口的剩餘車輛指示牌顯示:剩餘停車位 3 輛。

這個時候,有三路人想要過來停車。

三條路分別是:轉發路、點贊路、讚揚路。

路上的車分別是 why 哥的勞斯萊斯、趙四的布加迪、劉能、謝廣坤這對好基友開的法拉利:

這個時候從「點贊路」過來的趙四先開到了,因而停了進去。

門口的停車位顯示:剩餘停車位 2 輛。

劉能、謝廣坤到了後發現,恰好還剩下 2 個車位,因而好基友手拉手,一塊兒停了進去。

門口的停車位顯示:餘下車位 0 輛。

沒多久,我也到了,發現沒有停車位了,怎麼辦呢?我只有在門口等一下了。

沒一會,趙四辦完事了,開着他的布加迪走了。

門口的停車位顯示:餘下車位 1 輛。

我趕忙停進去。

門口的停車位顯示:餘下車位 0 輛。

上面的代碼想要描述的就是這樣的一個事情。

可是根據提問者的描述,「在運行時,有時只會執行完線程A,其線程B和線程C都靜默了。」

在上面這個場景中就是:趙四的布加迪開進去停車後,後面劉能、謝廣坤的法拉利和個人勞斯萊斯都停不進去了。

就是這樣式兒的:

爲何停不進去呢?他懷疑是死鎖了,這個懷疑有點無厘頭啊。

咱們先回憶一下死鎖的四個必要條件:

  • 互斥條件:一個資源每次只能被一個進程使用,即在一段時間內某資源僅爲一個進程所佔有。此時如有其餘進程請求該資源,則請求進程只能等待。(不知足,還有兩個停車位沒有用呢。)
  • 請求與保持條件:進程已經保持了至少一個資源,但又提出了新的資源請求,而該資源已被其餘進程佔有,此時請求進程被阻塞,但對本身已得到的資源保持不放。(不知足,張三佔了一個停車位了,沒有提出還要一個停車位的要求,另外的停車位也沒有被佔用)
  • 不可剝奪條件:進程所得到的資源在未使用完畢以前,不能被其餘進程強行奪走,即只能由得到該資源的進程本身來釋放。(知足,張三的車不開出來,這個停車位理論上是不會被奪走的)
  • 循環等待條件: 若干進程間造成首尾相接循環等待資源的關係。(不知足,只有我和劉能、謝廣坤兩撥人在等資源,但沒有循環等待的狀況。)

這四個條件是死鎖的必要條件,必要條件就是說只要有死鎖了,這些條件必然所有成立。

而通過分析,咱們發現沒有知足死鎖的必要條件。那爲何會出現這樣的現象呢?

咱們先根據上面的場景,本身寫一段代碼。

本身擼代碼

下面的程序基本上是按照上面截圖中的示例代碼接合上面的故事改的,能夠直接複製粘貼:

public class ParkDemo {
    public static void main(String[] args) throws InterruptedException {

        Integer parkSpace = 3;
        System.out.println("這裏有" + parkSpace + "個停車位,先到先得啊!");
        Semaphore semaphore = new Semaphore(parkSpace, true);

        Thread threadA = new Thread(new ParkCar(1, "布加迪", semaphore), "趙四");
        Thread threadB = new Thread(new ParkCar(2, "法拉利", semaphore), "劉能、謝廣坤");
        Thread threadC = new Thread(new ParkCar(1, "勞斯萊斯", semaphore), "why哥");

        threadA.start();
        threadB.start();
        threadC.start();
    }
}

class ParkCar implements Runnable {
    
    private int n;
    private String carName;
    private Semaphore semaphore;

    public ParkCar(int n, String carName, Semaphore semaphore) {
        this.n = n;
        this.carName = carName;
        this.semaphore = semaphore;
    }

    @Override
    public void run() {
        try {
            if (semaphore.availablePermits() < n) {
                System.out.println(Thread.currentThread().getName() + "來停車,可是停車位不夠了,等着吧");
            }
            semaphore.acquire(n);
            System.out.println(Thread.currentThread().getName() + "把本身的" + carName + "停進來了,剩餘停車位:" + semaphore.availablePermits() + "輛");
            //模擬停車時長
            int parkTime = ThreadLocalRandom.current().nextInt(1, 6);
            TimeUnit.SECONDS.sleep(parkTime);
            System.out.println(Thread.currentThread().getName() + "把本身的" + carName + "開走了,停了" + parkTime + "小時");
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            semaphore.release(n);
            System.out.println(Thread.currentThread().getName() + "走後,剩餘停車位:" + semaphore.availablePermits() + "輛");
        }
    }
}

運行後的結果以下(因爲是多線程環境,運行結果可能不盡相同):

此次這個運行結果和咱們預期的是一致的。並無線程阻塞的現象。

那爲何以前的代碼就會出現「在運行時,有時只會執行完線程A,其線程B和線程C都靜默了」這種現象呢?

是道德的淪喪,仍是人性的扭曲?我帶你們走進代碼:

差別就體如今獲取剩餘通行證的方法上。上面是連接裏面的代碼,下面是我本身寫的代碼。

說實在的,連接裏面的代碼我最開始硬是眼神編譯了一分鐘,沒有看出問題來。

當我真正把代碼粘到 IDEA 裏面,跑起來後發現當最早執行了 B 線程後,A、C 線程均可以執行。當最早執行 A 線程的時候,B、C 線程就不會執行。

我人都懵逼了,反覆分析,發現這和我認知不同啊!因而我陷入了沉思:

過了一會,保潔大爺過來收垃圾,問我:「hi,小帥哥,你這瓶紅牛喝完了吧?我把瓶子收走了啊。」而後瞟了一眼屏幕,指着獲取剩餘許可證的那行代碼對我說:「你這個地方方法調用錯了哈,你再好好看看方法說明。」

System.out.println("剩餘可用許可證: " + semaphore.drainPermits());

說完以後,拍了拍個人肩膀,轉身離去。獲得大師點化,我才恍然大悟。

因爲獲取剩餘可用許可證的方法是 drainPermits,因此線程 A 調用完成以後,剩下的許可證爲0,而後執行 release 以後,許可證變爲 1。(後面會有對應的方法解釋)

這時又是一個公平鎖,因此,若是線程 B 先進去排隊了,剩下的許可證不足以讓 B 線程運行,它就一直等着。 C 線程也就沒有機會執行。

把獲取剩餘可用許可證的方法換爲 availablePermits 方法後,正常輸出:

這真的是一個很小的點。所謂當局者迷旁觀者清,就是這個道理。

方法解釋

我估計不少不太瞭解 semaphore 的朋友看完前面這兩部分也仍是略微有點懵逼。

沒事,全部的疑惑將在這一小節解開。

在上面的測試案例中,咱們只用到了 semaphore 的四個方法:

  • availablePermits:獲取剩餘可用許可證。
  • drainPermits :獲取剩餘可用許可證。
  • release(int n):釋放指定數量的許可證。
  • acquire(int n):申請指定數量的許可證。

首先看 availablePermits 和 drainPermits 這個兩個方法的差別:

這兩個地方的文檔描述,有點玩文字遊戲的意思了。稍不留神就被帶進去了。

你仔細看:availablePermits 只是 return 當前可用的許可證數量。而 drainPermits 是 acquires and return,它先所有獲取後再返回。

availablePermits 只是看看還有多少量可證,drainPermits 是拿走全部剩下的許可證。

因此在上面的場景下,這兩個方法的返回值是同樣的,可是內部處理徹底內部不同:

當我把這個發現彙報給保潔大爺後,大爺輕輕一笑:「小夥子,要不你去查一下 drainPermits 前面的 drain 的意思?」

查完以後,我留下了英語四級的淚水:

見名知意。同窗們,可見英語對編程仍是很是重要的。

接下來先看看釋放的方法:release。

該方法就是釋放指定數量許可證。釋放,就意味着許可證的增長。就相似於劉能、謝廣坤把他們各自的法拉利從停車位開出來,駛離停車場,這時停車場就會多兩個停車位。

上面紅框框起來的部分是它的主要邏輯。你們本身看一下,我就不翻譯了,大概意思就是釋放許可證以後,其餘等着用許可證的線程就能夠看一下釋放以後的許可證數量是否夠用,若是夠就能夠獲取許可證,而後運行了。

該方法的精華在 599 到 602 行的說明中:

這句話很是關鍵:說的是執行 release 操做的線程不必定非得是執行了 acquire 方法的線程

開發人員,須要根據實際場景來保證 semaphore 的正確使用。

release 操做這裏,你們都知道須要放到 finally 代碼塊裏面去執行。可是正是這個認知,是最容易踩坑的地方,並且出了問題還很是很差排查的那種。

放確定是要放在 finally 代碼塊裏面的,只是怎麼放,這裏有點講究。

我接合下一節的例子和 acquire 方法一塊兒說明:

acquire 方法主要先關注我紅框框起來的部分。

從該方法的源碼能夠看出,會拋出 InterruptException 異常。記住這點,咱們在下一節,帶入場景討論。

release使用不當的大坑

咱們仍是帶入以前停車的場景。假設趙四和我先把車停進去了,這個時候劉能、謝廣坤他們來了,發現車位不夠了,兩個好基友嘛,就等着,非要停在一塊兒

等了一會,咱們一直沒出來,門口看車的大爺出來對他們說:「我估摸着大家還得等很長時間,別等了,快走吧。」

因而,他們開車離去。

來,就這個場景,整一段代碼:

public class ParkDemo {
    public static void main(String[] args) throws InterruptedException {

        Integer parkSpace = 3;
        System.out.println("這裏有" + parkSpace + "個停車位,先到先得啊!");
        Semaphore semaphore = new Semaphore(parkSpace, true);

        Thread threadA = new Thread(new ParkCar(1, "布加迪", semaphore), "趙四");
        Thread threadB = new Thread(new ParkCar(2, "法拉利", semaphore), "劉能、謝廣坤");
        Thread threadC = new Thread(new ParkCar(1, "勞斯萊斯", semaphore), "why哥");

        threadA.start();
        threadC.start();
        threadB.start();
        //模擬大爺勸退
        threadB.interrupt();
    }
}

class ParkCar implements Runnable {

    private int n;
    private String carName;
    private Semaphore semaphore;

    public ParkCar(int n, String carName, Semaphore semaphore) {
        this.n = n;
        this.carName = carName;
        this.semaphore = semaphore;
    }

    @Override
    public void run() {
        try {
            if (semaphore.availablePermits() < n) {
                System.out.println(Thread.currentThread().getName() + "來停車,可是停車位不夠了,等着吧");
            }
            semaphore.acquire(n);
            System.out.println(Thread.currentThread().getName() + "把本身的" + carName + "停進來了," + "剩餘停車位:" + semaphore.availablePermits() + "輛");
            //模擬停車時長
            int parkTime = ThreadLocalRandom.current().nextInt(1, 6);
            TimeUnit.SECONDS.sleep(parkTime);
            System.out.println(Thread.currentThread().getName() + "把本身的" + carName + "開走了,停了" + parkTime + "小時");
        } catch (InterruptedException e) {
            System.err.println(Thread.currentThread().getName() + "被門口大爺勸走了。");
        } finally {
            semaphore.release(n);
            System.out.println(Thread.currentThread().getName() + "走後,剩餘停車位:" + semaphore.availablePermits() + "輛");
        }
    }
}

看着代碼是沒有毛病,可是運行起來你會發現,有可能出現這樣的狀況:

why哥走後,剩餘停車位變成了 5 輛?我是開着勞斯萊斯去給他們開發停車位去了嗎?

在往前看日誌發現,原來是劉能、謝廣坤走後,顯示了剩餘停車位 3 輛。

問題就出在這個地方。

而這個地方對應的代碼是這樣的:

有沒有一點恍然大悟的感受。

50 行拋出了 InterruptedException,致使明明沒有獲取到許可證的線程,執行了 release 方法,而該方法致使許可證增長。

在咱們的例子裏面就是劉能、謝廣坤的車都還沒停進去,走的時候門口的顯示屏就增長了兩個停車位。

這就是坑,就是你代碼中的 BUG 潛伏地帶。

那麼怎麼修復呢?

答案已經呼之欲出了,這個地方須要 catch 起來,若是出現中斷異常,直接返回:

跑起來,結果也正確,全部車都走了後,停車位仍是隻有 3 輛:

上面的寫法還有一個疑問,若是我剛剛拿到許可證,就被中斷了,怎麼辦?

看源碼啊,源碼裏面有答案的。

拋出 InterruptedException 後,分配給這個線程的全部許可證都會被分配給其餘想要獲取許可證的線程,就像經過調用 release 方法同樣。

加強release

你分析上面的問題會發現,致使問題的緣由是沒有獲取到許可證的線程,調用了 release 方法。

我以爲這個設定,就是很是容易踩坑的地方。簡直就是一個大坑!

咱們能夠就這個問題,對 release 方法進行加強,只有獲取後的線程,才能調用 release 方法。

這一招我是在《Java高併發編程詳解-深刻理解併發核心庫》裏面學到的:

其中的 3.4.4 小節《擴展 Semaphore 加強 release》:

獲取許可證的方法被修改爲這樣了(我只截取其中一個方法),獲取成功後放入到隊列裏面:

裏面的 release 方法修改爲這樣了,執行以前先看看當前線程是不是在隊列裏面:

還有一段舒適提示:

這本書寫的仍是不錯的,推薦給你們。

最後說一句(求關注)

才疏學淺,不免會有紕漏,若是你發現了錯誤的地方,還請你留言指出來,我對其加以修改。

感謝您的閱讀,我堅持原創,十分歡迎並感謝您的關注。

我是 why,一個被代碼耽誤的文學創做者,不是大佬,可是喜歡分享,是一個又暖又有料的四川好男人

相關文章
相關標籤/搜索