每日三道面試題,通往自由的道路13——鎖+Volatile

太子爺哪吒發表於2021-07-15

茫茫人海千千萬萬,感謝這一秒你看到這裡。希望我的面試題系列能對你的有所幫助!共勉!

願你在未來的日子,保持熱愛,奔赴山海!

每日三道面試題,成就更好自我

我們既然聊到了併發多執行緒的問題,怎麼能少得了鎖呢?

1. 你知道volatile是如何保證可見性嗎?

我們先看一組程式碼:

public class VolatileVisibleDemo {

    public static boolean initFlag = false;

    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                System.out.println("等待initFlag改變!!!");
                // 如果initFlag發生改變了,這是為true的話,才會結束迴圈
                while(!initFlag) {
                }
                System.out.println("今天的世界打烊了,晚安!");
            }
        }).start();

        // 這裡是為了能保證執行完上面的程式碼
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        // 這裡是Lambda表示式,就是上面的縮寫
        new Thread(() -> {
            System.out.println("準備填充資料,修改initFlag的值");
            initFlag = true;
            System.out.println("準備資料完了!");
        }).start();
    }
}

執行得到的結果:

我們可以發現,其實在準備資料完後,我們的initFlag的變數其實已經改變,但是為什麼還是沒有結束迴圈輸出今天的世界打烊了,晚安!這一句呢?

從之間的JMM模型,我們可以知道,不同執行緒之間是不能直接訪問對方工作記憶體中的變數,執行緒間變數的值的傳遞需要通過主記憶體中轉來完成,並且執行緒在修改完數值後,也不是馬上同步到主記憶體中,並且另一個執行緒也是無法感知到資料發生改變的,所以就會有可見性問題。

那我們可以加個volatile關鍵字修飾變數試下?

 public static volatile boolean initFlag = false;

我們可以發現:

在我們的變數修飾了volatile關鍵字後,就能輸出今天的世界打烊了,晚安!這一句了。

我們來看看圖解吧:

先解釋下這其中連線的幾個單詞:

  • read(讀取):從主記憶體中讀取資料
  • load (載入):將主記憶體中讀取到的資料寫入到本地(工作)記憶體中
  • user(使用):從本地記憶體中讀取資料給執行緒使用來計算
  • assign(賦值):執行緒將計算好的值重新賦值到工作記憶體中
  • store(儲存):將本地記憶體的資料儲存到主記憶體中
  • write(寫入):將stroe過來的變數值賦值給主記憶體中的變數,重新賦值。

大概講一下流程:

線上程B讀取initFlag變數後,重新賦值true給變數,此時,因為加了volatile修飾,所以會馬上將值寫入到主記憶體中修改變數中的值,此時因為有一個cpu匯流排嗅探機制會監聽到主記憶體的變數值發生改變了,會把本地記憶體的中initFlag變數設定了失效,重新讀取一邊主記憶體的新值,就可以達到解決變數可見性問題。這是它第一個保證可見性的關鍵。

之前我們也有提到他如果發生指令重排序了,那是不是也不能讀取到最新的值呢。答案是不會的呢。

因為被volatile修飾的話,它會禁止指令重排序。那它主要是依靠什麼指令重排序呢?它是通過記憶體屏障來實現的。什麼是記憶體屏障?硬體層面,記憶體屏障分兩種:讀屏障(Load Barrier)和寫屏障(Store Barrier)。記憶體屏障有兩個作用:

  1. 阻止屏障兩側的指令重排序;
  2. 強制把寫緩衝區/快取記憶體中的髒資料等寫回主記憶體,或者讓快取中相應的資料失效。

而編譯器在生成位元組碼時,會在指令序列中插入記憶體屏障來禁止指令重排序。這樣保證了任何程式中都能得到正確的volatile記憶體語義。這個策略是:

  • 在每個volatile寫操作前插入一個StoreStore屏障;
  • 在每個volatile寫操作後插入一個StoreLoad屏障;
  • 在每個volatile讀操作後插入一個LoadLoad屏障;
  • 在每個volatile讀操作後再插入一個LoadStore屏障。

看一下示意圖:

總結:

volatile作用:

  1. volatile可以保證記憶體可見性且禁止重排序。
  2. volatile不具備保證原子性,而鎖可以保證整個臨界區程式碼的執行具有原子性。所以而鎖可以保證整個臨界區程式碼的執行具有原子性。所以在功能上,鎖比volatile更強大;在效能上,volatile更有優勢。

不錯呀!volatile這麼深的底層都有了解,看來你勢要我這個offer呀,那我們們繼續

2. 悲觀鎖和樂觀鎖可以講下你的理解嗎?

悲觀鎖和樂觀鎖都是比較老生常談的了,所以還是得記住呀!

其實聽名字,我們就應該有個概念:

悲觀對應著我們生活中的人,悲觀的人一般看待事物都會相對消極負能量點,會盡可能往壞處去想的。這也是對應著MyGirl,她其實是一個也不能說算是悲觀的人,只能說看待事物可能會更往深入,更壞的一方面的去思考。

這其實跟我很互補,因為算是個樂天派吧,而樂觀對應著我們生活中的人,樂觀的人一般看待事物都會相對積極正能量,會盡可能往好處去想的。我其實對待生活的方方面面可能會更樂觀點,但有時帶來的一些壞處也是難以估計的。

所以說這兩者不能說誰好誰壞,只能對應著場景選擇對應的方法。

悲觀鎖:

MyGilr這個人呢,她總是會假設一種最壞的情況。比如,她每次要去拿資料的同時,認為別人也會來修改資料跟她作對,所以每次在拿資料的時候她都會上鎖,堵上一個界限,這樣別人想拿這個資料就只能等待她出去解鎖成功後,直到它拿到鎖。

在Java中,synchronizedReentrantLock等獨佔鎖就是悲觀鎖思想的實現。而在資料庫裡邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。

樂觀鎖
我這個人呢,總是會假設一種最好的情況。比如, 我每次要去拿資料的同時,認為別人絕對不會來修改資料滴,所以每次拿資料的時候都不會上鎖。但是人還是要點防備心裡的,不是嗎?所以在更新的時候會判斷一下在此期間別人有沒有去更新過這個資料。

而常見的有CAS演算法+版本號實現。樂觀鎖適用於多讀的應用型別,這樣可以提高吞吐量。

在Java中,像原子類就是使用了樂觀鎖的一種實現方式CAS實現的。而在資料庫提供的類似於write_condition機制,其實都是提供的樂觀鎖。

兩者對應的場景的區別:

樂觀鎖多用於讀多寫少的環境,避免頻繁加鎖影響效能,加大了系統的整個吞吐量;而悲觀鎖多用於寫多讀少的環境,避免頻繁失敗和重試影響效能。

不錯,這個常規的鎖也懂嘛,最後問你一道:

3. 你還知道什麼其他的鎖嗎?

可重入鎖和非可重入鎖

所謂重入鎖又名遞迴鎖,顧名思義。就是支援重新進入的鎖,也就是說這個鎖支援一個執行緒對資源重複加鎖。指在同一個執行緒在外層方法獲取鎖的時候,在進入內層方法會自動獲取鎖。不會因為之前已經獲取過還沒釋放而阻塞。

在Java中,ReentrantLocksynchronized都是可重入鎖,可重入鎖的還有一個優點是可一定程度避免死鎖。

public static void main(String[] args) {
    doOne();
}

public static synchronized  void doOne(){
    System.out.println("執行第一個任務");
    try {
        Thread.sleep(10);
    } catch (InterruptedException e) {
        e.printStackTrace();
    }
    // 執行第二個任務
    doTwo();
}

public static synchronized  void doTwo(){
    System.out.println("執行第二個任務");
}

簡單的測試下結果:

執行第一個任務
執行第二個任務

可以驗證得到,類中的兩個方法都是被內建鎖synchronized修飾的,而在doOne方法去呼叫doTwo方法時,因為是可重入鎖,所以同個執行緒下可以直接獲得當前物件鎖,所以synchronized是可重入鎖。

而如果我們自己在繼承AQS實現同步器的時候,沒有考慮到佔有鎖的執行緒再次獲取鎖的場景,可能就會導致執行緒阻塞,那這個就是一個非可重入鎖。

公平鎖和非公平鎖

這裡的公平,可以按生活上來講,如果你跟你女朋友吵架,你覺得你是正確的,最後的結果卻你必須得哄你女朋友還得道歉,你信嗎?所以這是公平的嗎?

如果對一個鎖來說,先對鎖獲取請求的執行緒一定會先被滿足,後對鎖獲取請求的執行緒後被滿足,那這個鎖就是公平的。反之,那就是不公平的。

公平鎖:

多個執行緒按照申請鎖的順序來獲取鎖,執行緒直接進入佇列中排隊,佇列中的第一個執行緒才能獲得鎖。公平鎖的優點是等待鎖的執行緒不會餓死。

缺點是整體吞吐效率相對非公平鎖要低,等待佇列中除第一個執行緒以外的所有執行緒都會阻塞,CPU喚醒阻塞執行緒的開銷比非公平鎖大。

非公平鎖:

多個執行緒加鎖時直接嘗試獲取鎖,獲取不到才會到等待佇列的隊尾等待。但如果此時鎖剛好可用,那麼這個執行緒可以無需阻塞直接獲取到鎖,所以非公平鎖有可能出現後申請鎖的執行緒先獲取鎖的場景。

非公平鎖的優點是可以減少喚起執行緒的開銷,整體的吞吐效率高,因為執行緒有機率不阻塞直接獲得鎖,CPU不必喚醒所有執行緒。缺點是處於等待佇列中的執行緒可能會餓死,或者等很久才會獲得鎖。

在Java中,對於ReentrantLock而言,可以通過建構函式指定該鎖是否是公平鎖,預設是非公平鎖。

獨享鎖和共享鎖

對於獨享和共享,這兩個概念應該可以見名知意,對於MyGirl喜歡的東西,是碰都碰不得,而對於不喜歡,或者還可以的東西,可以和她共享。

獨享鎖:

也叫排他鎖,是指該鎖一次只能被一個執行緒所持有。如果執行緒B對變數A加上排它鎖後,則其他執行緒不能再對A加任何型別的鎖。獲得獨享鎖的執行緒即能讀資料又能修改資料。

在Java中,synchronized就是一種獨享鎖。

共享鎖:

代表該鎖可被多個執行緒所持有。如果執行緒B對變數A加上共享鎖後,則其他執行緒只能對A再加共享鎖,不能加排它鎖。獲得共享鎖的執行緒只能讀資料,不能修改資料。

小夥子不錯嘛!今天就到這裡,期待你明天的到來,希望能讓我繼續保持驚喜!

注: 如果文章有任何錯誤和建議,請各位大佬盡情留言!如果這篇文章對你也有所幫助,希望可愛親切的您給個三連關注下,非常感謝啦!也可以微信搜尋太子爺哪吒公眾號私聊我,感謝各位大佬!

相關文章