我靠!Semaphore里面居然有這么一個大坑!


這是why的第 59 篇原創文章

荒腔走板

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

上周寫了一篇文章,一不小心戳到了大家的爽點,其中一個轉載我文章的大號,閱讀量居然突破了 10w+,我也是受寵若驚。

但是其實我是一個技術博主來的,偶爾寫點生活相關的。所以這篇還是回到技術上。

但是我的技術文章有個特點是第一張圖片都是我自己拍的。然后我會圍繞這個圖片進行一個簡短的描述,我稱之為荒腔走板環節。

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

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

那你們是真的棒哦,至少退出去之前,拉到文末,來個一鍵三連吧,給我來點正反饋。

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

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

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

我聽他們的歌的時候,應該是初中,那個時候磁帶已經差不多快過氣了,進入了光碟的時代,我記得一張光碟里面有好幾十首歌,第一次在 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,一個被代碼耽誤的文學創作者,不是大佬,但是喜歡分享,是一個又暖又有料的四川好男人


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM