當Synchronized遇到這玩意兒,有個大坑,要注意!

why技術發表於2022-02-14

你好呀,我是歪歪。

前幾天在某技術平臺上看到別人提的關於 Synchronized 的一個用法問題,我覺得挺有意思的,這個問題其實也是我三年前面試某公司的時候遇到的一個真題,當時不知道面試官想要考什麼,沒有回答的特別好,後來研究了一下就記住了。

所以看到這個問題的時候覺得特別親切,準備分享給你一起看看:

首先為了方便你看文章的時候復現問題,我給你一份直接拿出來就能跑的程式碼,希望你有時間的話也把程式碼拿出來跑一下:

public class SynchronizedTest {

    public static void main(String[] args) {
        Thread why = new Thread(new TicketConsumer(10), "why");
        Thread mx = new Thread(new TicketConsumer(10), "mx");
        why.start();
        mx.start();
    }
}

class TicketConsumer implements Runnable {

    private volatile static Integer ticket;

    public TicketConsumer(int ticket) {
        this.ticket = ticket;
    }

    @Override
    public void run() {
        while (true) {
            System.out.println(Thread.currentThread().getName() + "開始搶第" + ticket + "張票,物件加鎖之前:" + System.identityHashCode(ticket));
            synchronized (ticket) {
                System.out.println(Thread.currentThread().getName() + "搶到第" + ticket + "張票,成功鎖到的物件:" + System.identityHashCode(ticket));
                if (ticket > 0) {
                    try {
                        //模擬搶票延遲
                        TimeUnit.SECONDS.sleep(1);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    System.out.println(Thread.currentThread().getName() + "搶到了第" + ticket-- + "張票,票數減一");
                } else {
                    return;
                }
            }
        }
    }
}

程式邏輯也很簡單,是一個模擬搶票的過程,一共 10 張票,開啟兩個執行緒去搶票。

票是共享資源,且有兩個執行緒來消費,所以為了保證執行緒安全,TicketConsumer 的邏輯裡面用了 synchronized 關鍵字。

這是應該是大家在初學 synchronized 的時候都會寫到的例子,期望的結果是 10 張票,兩個人搶,每張票只有一個人能搶到。

但是實際執行結果是這樣的,我只擷取開始部分的日誌:

截圖裡面有三個框起來的部分。

最上面的部分,就是兩個人都在搶第 10 張票,從日誌輸出上看也完全沒有任何毛病,最終只有一個人搶到了票,然後進入到第 9 張票的爭奪過程。

但是下面被框起來的第 9 張票的爭奪部分就有點讓人懵逼了:

why搶到第9張票,成功鎖到的物件:288246497
mx搶到第9張票,成功鎖到的物件:288246497

為什麼兩個人都搶到了第 9 張票,且成功鎖到的物件都一樣的?

這玩意,超出認知了啊。

這兩個執行緒怎麼可能拿到同一把鎖,然後去執行業務邏輯呢?

所以,提問者的問題就浮現出來了。

  • 1.為什麼 synchronized 沒有生效?
  • 2.為什麼鎖物件 System.identityHashCode 的輸出是一樣的?

為什麼沒有生效?

我們先來看一個問題。

首先,我們從日誌的輸出上已經非常明確的知道,synchronized 在第二輪搶第 9 張票的時候失效了。

經過理論知識支撐,我們知道 synchronized 失效,肯定是鎖出問題了。

如果只有一把鎖,多個執行緒來競爭同一把鎖,synchronized 絕對是不會有任何毛病的。

但是這裡兩個執行緒並沒有達成互斥的條件,也就是說這裡絕對存在的不止一把鎖。

這是我們可以通過理論知識推匯出來的結論。

先得出結論了,那麼我怎麼去證明“鎖不止一把”呢?

能進入 synchronized 說明肯定獲得了鎖,所以我只要看各個執行緒持有的鎖是什麼就知道了。

那麼怎麼去看執行緒持有什麼鎖呢?

jstack 命令,列印執行緒堆疊功能,瞭解一下?

這些資訊都藏線上程堆疊裡面,我們拿出來一看便知。

在 idea 裡面怎麼拿到執行緒堆疊呢?

這就是一個在 idea 裡面除錯的小技巧了,我之前的文章裡面應該也出現過多次。

首先為了方便獲取執行緒堆疊資訊,我把這裡的睡眠時間調整到 10s:

跑起來之後點選這裡的“照相機”圖示:

點選幾次就會有對應點選時間點的幾個 Dump 資訊

由於我需要觀察前兩次鎖的情況,而每次執行緒進入鎖之後都會等待 10s 時間,所以我就在專案啟動的第一個 10s 和第二個 10s 之間各點選一次就行。

為了更直觀的觀察資料,我選擇點選下面這個圖示,把 Dump 資訊複製下來:

複製下來的資訊很多,但是我們只需要關心 why 和 mx 這兩個執行緒即可。

這是第一次 Dump 中的相關資訊:

mx 執行緒是 BLOCKED 狀態,它在等待地址為 0x000000076c07b058 的鎖。

why 執行緒是 TIMED_WAITING 狀態,它在 sleeping,說明它搶到了鎖,在執行業務邏輯。而它搶到的鎖,你說巧不巧,正是 mx 執行緒等待的 0x000000076c07b058。

從輸出日誌上來看,第一次搶票確實是 why 執行緒搶到了:

從 Dump 資訊看,兩個執行緒競爭的是同一把鎖,所以第一次沒毛病。

好,我們接著看第二次的 Dump 資訊:

這一次,兩個執行緒都在 TIMED_WAITING,都在 sleeping,說明都拿到了鎖,進入了業務邏輯。

但是仔細一看,兩個執行緒拿的鎖是不相同的鎖。

mx 鎖的是 0x000000076c07b058。

why 鎖的是 0x000000076c07b048。

由於不是同一把鎖,所以並不存在競爭關係,因此都可以進入 synchronized 執行業務邏輯,所以兩個執行緒都在 sleeping,也沒毛病。

然後,我再把兩次 Dump 的資訊放在一起給你看一下,這樣就更直觀了:

如果我用“鎖一”來代替 0x000000076c07b058,“鎖二”來代替 0x000000076c07b048。

那麼流程是這樣的:

why 加鎖一成功,執行業務邏輯,mx 進入鎖一等待狀態。

why 釋放鎖一,等待鎖一的 mx 被喚醒,持有鎖一,繼續執行業務。

同時 why 加鎖二成功,執行業務邏輯。

從執行緒堆疊中,我們確實證明了 synchronized 沒有生效的原因是鎖發生了變化。

同時,從執行緒堆疊中我們也能看出來為什麼鎖物件 System.identityHashCode 的輸出是一樣的。

第一次 Dump 的時候,ticket 都是 10,其中 mx 沒有搶到鎖,被 synchronized 鎖住。

why 執行緒執行了 ticket-- 操作,ticket 變成了 9,但是此時 mx 執行緒被鎖住的 monitor 還是 ticket=10 這個物件,它還在 monitor 的 _EntryList 裡面等著的,並不會因為 ticket 的變化而變化。

所以,當 why 執行緒釋放鎖之後,mx 執行緒拿到鎖繼續執行,發現 ticket=9。

而 why 也搞到一把新鎖,也可以進入 synchronized 的邏輯,也發現 ticket=9。

好傢伙,ticket 都是 9, System.identityHashCode 能不一樣嗎?

按理來說,why 釋放鎖一後應該繼續和 mx 競爭鎖一,但是卻不知道它在哪搞到一把新鎖。

那麼問題就來了:鎖為什麼發生了變化呢?

誰動了我的鎖?

經過前面一頓分析,我們坐實了鎖確實發生了變化,當你分析出這一點的時候勃然大怒,拍案而起,大喊一聲:是哪個瓜娃子動了我的鎖?這不是坑爹嗎?

按照我的經驗,這個時候不要急著甩鍋,繼續往下看,你會發現小丑竟是自己:

搶完票之後,執行了 ticket-- 的操作,而這個 ticket 不就是你的鎖物件嗎?

這個時候你把大腿一拍,恍然大悟,對著圍觀群眾說:問題不大,手抖而已。

於是大手一揮,把加鎖的地方改成這樣:

synchronized (TicketConsumer.class)

利用 class 物件來作為鎖物件,保證了鎖的唯一性。

經過驗證也確實沒毛病,非常完美,打完收工。

但是,真的就收工了嗎?

其實關於鎖物件為什麼發生了變化,還隔了一點點東西沒有說出來。

它就藏在位元組碼裡面。

我們通過 javap 命令,反查位元組碼,可以看到這樣的資訊:

Integer.valueOf 這是什麼玩意?

讓人熟悉的 Integer 從 -128 到 127 的快取。

也就是說我們的程式裡面,會涉及到拆箱和裝箱的過程,這個過程中會呼叫到 Integer.valueOf 方法。具體其實就是 ticket-- 的這個操作。

對於 Integer,當值在快取範圍內的時候,會返回同一個物件。當超過快取範圍,每次都會 new 一個新物件出來。

這應該是一個必備的八股文知識點,我在這裡給你強調這個是想表達什麼意思呢?

很簡單,改動一下程式碼就明白了。

我把初始化票數從 10 修改為 200,超過快取範圍,程式執行結果是這樣的:

很明顯,從第一次的日誌輸出來看,鎖都不是同一把鎖了。

這就是我前面說的:因為超過快取範圍,執行了兩次 new Integer(200) 的操作,這是兩個不同的物件,拿來作為鎖,就是兩把不一樣的鎖。

再修改回 10,執行一次,你感受一下:

從日誌輸出來看,這個時候只有一把鎖,所以只有一個執行緒搶到了票。

因為 10 是在快取範圍內的數字,所以每次是從快取中獲取出來,是同一個物件。

我寫這一小段的目的是為了體現 Integer 有快取這個知識點,大家都知道。但是當它和其他東西揉在一起的時候因為這個快取會帶來什麼問題,你得分析出來,這比直接記住乾癟的知識點有效一點。

但是...

我們的初始票是 10,ticket-- 之後票變成了 9,也是在快取範圍內的呀,怎麼鎖就變了呢?

如果你有這個疑問的話,那麼我勸你再好好想想。

10 是 10,9 是 9。

雖然它們都在快取範圍內,但是本來就是兩個不同的物件,構建快取的時候也是 new 出來的:

為什麼我要補充這一段看起來很傻的說明呢?

因為我在網上看到其他寫類似問題的時候,有的文章寫的不清楚,會讓讀者誤認為“快取範圍內的值都是同一個物件”,這樣會誤導初學者。

總之一句話:請別用 Integer 作為鎖物件,你把握不住。

但是...

stackoverflow

但是,我寫文章的時候在 stackoverflow 上也看到了一個類似的問題。

這個哥們的問題在於:他知道 Integer 不能做為鎖物件,但是他的需求又似乎必須把 Integer 作為鎖物件。

https://stackoverflow.com/questions/659915/synchronizing-on-an-integer-value

我給你描述一下他的問題。

首先看標號為 ① 的地方,他的程式其實就是先從快取中獲取,如果快取中沒有則從資料庫獲取,然後在放到快取裡面去。

非常簡單清晰的邏輯。

但是他考慮到併發的場景下,如果有多個執行緒同一時刻都來獲取同一個 id,但是這個 id 對應的資料並沒有在快取裡面,那麼這些執行緒都會去執行查詢資料庫並維護快取的動作。

對應查詢和儲存的動作,他用的是 fairly expensive 來形容。

就是“相當昂貴”的意思,說白了就是這個動作非常的“重”,最好不要重複去做。

所以只需要讓某一個執行緒來執行這個 fairly expensive 的操作就好了。

於是他想到了標號為 ② 的地方的程式碼。

用 synchronized 來把 id 鎖一下,不幸的是,id 是 Integer 型別的。

在標號為 ③ 的地方他自己也說了:不同的 Integer 物件,它們並不會共享鎖,那麼 synchronized 也沒啥卵用。

其實他這句話也不嚴謹,經過前面的分析,我們知道在快取範圍內的 Integer 物件,它們還是會共享同一把鎖的,這裡說的“共享”就是競爭的意思。

但是很明顯,他的 id 範圍肯定比 Integer 快取範圍大。

那麼問題就來了:這玩意該咋搞啊?

我看到這個問題的時候想到的第一個問題是:上面這個需求我好像也經常做啊,我是怎麼做的來著?

想了幾秒恍然大悟,哦,現在都是分散式應用了,我特麼直接用的是 Redis 做鎖呀。

根本就沒有考慮過這個問題。

如果現在不讓用 Redis,就是單體應用,那麼怎麼解決呢?

在看高贊回答之前,我們先看看這個問題下面的一個評論:

開頭三個字母:FYI。

看不懂沒關係,因為這個不是重點。

但是你知道的,我的英語水平 very high,所以我也順便教點英文。

FYI,是一個常用的英文縮寫,全稱是 for your information,供參考的意思。

所以你就知道,他後面肯定是給你附上一個資料了,翻譯過來就是: Brian Goetz 在他的 Devoxx 2018 演講中提到,我們不應該把 Integer 作為鎖。

你可以通過這個連結直達這一部分的講解,只有不到 30s秒的時間,隨便練練聽力:https://www.youtube.com/watch?v=4r2Wg-TY7gU&t=3289s

那麼問題又來了?

Brian Goetz 是誰,憑什麼他說的話看起來就很權威的樣子?

Java Language Architect at Oracle,開發 Java 語言的,就問你怕不怕。

同時,他還是我多次推薦過的《Java併發程式設計實踐》這本書的作者。

好了,現在也找到大佬背書了,接下來帶你看看高贊回答是怎麼說的。

前部分就不詳說了,其實就是我們前面提到的那一些點,不能用 Integer ,涉及到快取內、快取外巴拉巴拉的...

關注劃線的部分,我加上自己的理解給你翻譯一下:

如果你真的必須用 Integer 作為鎖,那麼你需要搞一個 Map 或 Integer 的 Set,通過集合類做對映,你就可以保證對映出來的是你想要的明確的一個例項。而這個例項,就那可以拿來做鎖。

然後他給出了這樣的程式碼片段:

就是用 ConcurrentHashMap 然後用 putIfAbsent 方法來做一個對映。

比如多次呼叫 locks.putIfAbsent(200, 200),在 map 裡面也只有一個值為 200 的 Integer 物件,這是 map 的特性保證的,無需過多解釋。

但是這個哥們很好,為了防止有人轉不過這個彎,他又給大家解釋了一下。

首先,他說你也可以這樣的寫:

但這樣一來,你就會多產生一個很小成本,就是每次訪問的時候,如果這個值沒有被對映,你都會建立一個 Object 物件。

為了避免這一點,他只是把整數本身儲存在 Map 中。這樣做的目的是什麼?這與直接使用整數本身有什麼不同呢?

他是這樣解釋的,其實就是我前面說的“這是 map 的特性保證的”:

當你從 Map 中執行 get() 時,會用到 equals() 方法比較鍵值。

兩個相同值的不同 Integer 例項,呼叫 equals() 方法是會判定為相同的 。

因此,你可以傳遞任何數量的 "new Integer(5)" 的不同 Integer 例項作為 getCacheSyncObject 的引數,但是你將永遠只能得到傳遞進來的包含該值的第一個例項。

就是這個意思:

彙總一句話:就是通過 Map 做了對映,不管你 new 多少個 Integer 出來,這多個 Integer 都會被對映為同一個 Integer,從而保證即使超出 Integer 快取範圍時,也只有一把鎖。

除了高贊回答之外,還有兩個回答我也想說一下。

第一個是這個:

不用關心他說的內容是什麼,只是我看到這句話翻譯的時候虎軀一震:

skin this cat ???

太殘忍了吧。

我當時就覺得這個翻譯肯定不太對,這肯定是一個小俚語。於是考證了一下,原來是這個意思:

免費送你一個英語小知識,不用客氣。

第二個應該關注的回答排在最後:

這個哥們叫你看看《Java併發程式設計實戰》的第 5.6 節的內容,裡面有你要尋找的答案。

巧了,我手邊就有這本書,於是我翻開看了一眼。

第 5.6 節的名稱叫做“構建高效且可伸縮的結果快取”:

好傢伙,我仔細一看這一節,發現這是寶貝呀。

你看書裡面的示例程式碼:

不就和提問題的這個哥們的程式碼如出一轍嗎?

都是從快取中獲取,拿不到再去構建。

不同的地方在於書上把 synchronize 加在了方法上。但是書上也說了,這是最差的解決方案,只是為了引出問題。

隨後他藉助了 ConcurrentHashMap、putIfAbsent 和 FutureTask 給出了一個相對較好的解決方案。

你可以看到完全是從另外一個角度去解決問題的,根本就沒有在 synchronize 上糾纏,直接第二個方法就拿掉了 synchronize。

看完書上的方案後我才恍然大悟:好傢伙,雖然前面給出的方案可以解決這個問題,但是總感覺怪怪的,又說不出來哪裡怪。原來是死盯著 synchronize 不放,思路一開始就沒開啟啊。

書裡面一共給出了四段程式碼,解決方案層層遞進,具體是怎麼寫的,由於書上已經寫的很清楚了,我就不贅述了,大家去翻翻書就行了。

沒有書的直接在網上搜“構建高效且可伸縮的結果快取”也能搜出原文。

我就指個路,看去吧。

本文已收錄至個人部落格,歡迎來玩:

https://www.whywhy.vip/

相關文章