【Java基礎】樂觀鎖和悲觀鎖

前言

關於線程安全一提到可能就是加鎖,在面試中也是面試官百問不厭的考察點,往往能看出面試者的基本功和是否對線程安全有自己的思考。

那鎖本身是怎麼去實現的呢?又有哪些加鎖的方式呢?

我今天就簡單聊一下樂觀鎖和悲觀鎖,他們對應的實現 CAS ,Synchronized,ReentrantLock

正文

一個120斤一身黑的小夥子走了進來,看到他微微發福的面容,看來是最近疫情伙食好運動少的結果,他難道就是今天的面試官渣渣丙?

等等難道是他?前幾天刷B站看到的不會是他吧!!!

是的我已經開始把面試系列做成視頻了,以後會有各種級別的面試,從大學生到阿里P7+的面試,還有阿里,拼多多,美團,字節風格的面試我也都約好人了,就差時間了,大家可以去B站搜:三太子敖丙 觀看

我也不多跟你BB了,我們直接開始好不好,你能跟我聊一下CAS麼?

CAS(Compare And Swap 比較並且替換)是樂觀鎖的一種實現方式,是一種輕量級鎖,JUC 中很多工具類的實現就是基於 CAS 的。

CAS 是怎麼實現線程安全的?

線程在讀取數據時不進行加鎖,在準備寫回數據時,先去查詢原值,操作的時候比較原值是否修改,若未被其他線程修改則寫回,若已被修改,則重新執行讀取流程。

舉個栗子:現在一個線程要修改數據庫的name,修改前我會先去數據庫查name的值,發現name=“帥丙”,拿到值了,我們準備修改成name=“三歪”,在修改之前我們判斷一下,原來的name是不是等於“帥丙”,如果被其他線程修改就會發現name不等於“帥丙”,我們就不進行操作,如果原來的值還是帥丙,我們就把name修改爲“三歪”,至此,一個流程就結束了。

有點懵?理一下停下來理一下思路。

Tip:比較+更新 整體是一個原子操作,當然這個流程還是有問題的,我下面會提到。

他是樂觀鎖的一種實現,就是說認爲數據總是不會被更改,我是樂觀的仔,每次我都覺得你不會渣我,差不多是這個意思。

你這個栗子不錯,他存在什麼問題呢?

有,當然是有問題的,我也剛好想提到。

你們看圖發現沒,要是結果一直就一直循環了,CUP開銷是個問題,還有ABA問題和只能保證一個共享變量原子操作的問題。

你能分別介紹一下麼?

好的,我先介紹一下ABA這個問題,直接口述可能有點抽象,我畫圖解釋一下:

看到問題所在沒,我說一下順序:

  1. 線程1讀取了數據A
  2. 線程2讀取了數據A
  3. 線程2通過CAS比較,發現值是A沒錯,可以把數據A改成數據B
  4. 線程3讀取了數據B
  5. 線程3通過CAS比較,發現數據是B沒錯,可以把數據B改成了數據A
  6. 線程1通過CAS比較,發現數據還是A沒變,就寫成了自己要改的值

懂了麼,我儘可能的幼兒園化了,在這個過程中任何線程都沒做錯什麼,但是值被改變了,線程1卻沒有辦法發現,其實這樣的情況出現對結果本身是沒有什麼影響的,但是我們還是要防範,怎麼防範我下面會提到。

循環時間長開銷大的問題

是因爲CAS操作長時間不成功的話,會導致一直自旋,相當於死循環了,CPU的壓力會很大。

只能保證一個共享變量的原子操作

CAS操作單個共享變量的時候可以保證原子的操作,多個變量就不行了,JDK 5之後 AtomicReference可以用來保證對象之間的原子性,就可以把多個對象放入CAS中操作。

我還記得你之前說在JUC包下的原子類也是通過這個實現的,能舉個栗子麼?

那我就拿AtomicInteger舉例,他的自增函數incrementAndGet()就是這樣實現的,其中就有大量循環判斷的過程,直到符合條件才成功。

大概意思就是循環判斷給定偏移量是否等於內存中的偏移量,直到成功才退出,看到do while的循環沒。

樂觀鎖在項目開發中的實踐,有麼?

有的就比如我們在很多訂單表,流水錶,爲了防止併發問題,就會加入CAS的校驗過程,保證了線程的安全,但是看場景使用,並不是適用所有場景,他的優點缺點都很明顯。

那開發過程中ABA你們是怎麼保證的?

加標誌位,例如搞個自增的字段,操作一次就自增加一,或者搞個時間戳,比較時間戳的值。

舉個栗子:現在我們去要求操作數據庫,根據CAS的原則我們本來只需要查詢原本的值就好了,現在我們一同查出他的標誌位版本字段vision。

之前不能防止ABA的正常修改:

update table set value = newValue where value = #{oldValue}
//oldValue就是我們執行前查詢出來的值 
  • 1

帶版本號能防止ABA的修改:

update table set value = newValue ,vision = vision + 1 where value = #{oldValue} and vision = #{vision} 
// 判斷原來的值和版本號是否匹配,中間有別的線程修改,值可能相等,但是版本號100%不一樣
  • 1

除了版本號,像什麼時間戳,還有JUC工具包裏面也提供了這樣的類,想要擴展的小夥伴可以去了解一下。

聊一下悲觀鎖?

悲觀鎖從宏觀的角度講就是,他是個渣男,你認爲他每次都會渣你,所以你每次都提防着他。

我們先聊下JVM層面的synchronized:

synchronized加鎖,synchronized 是最常用的線程同步手段之一,上面提到的CAS是樂觀鎖的實現,synchronized就是悲觀鎖了。

它是如何保證同一時刻只有一個線程可以進入臨界區呢?

synchronized,代表這個方法加鎖,相當於不管哪一個線程(例如線程A),運行到這個方法時,都要檢查有沒有其它線程B(或者C、 D等)正在用這個方法(或者該類的其他同步方法),有的話要等正在使用synchronized方法的線程B(或者C 、D)運行完這個方法後再運行此線程A,沒有的話,鎖定調用者,然後直接運行。

我分別從他對對象、方法和代碼塊三方面加鎖,去介紹他怎麼保證線程安全的:

  • synchronized 對對象進行加鎖,在 JVM 中,對象在內存中分爲三塊區域:對象頭(Header)、實例數據(Instance
    Data)和對齊填充(Padding)。

  • 對象頭:我們以Hotspot虛擬機爲例,Hotspot的對象頭主要包括兩部分數據:Mark Word(標記字段)、Klass Pointer(類型指針)。

    • Mark Word:默認存儲對象的HashCode,分代年齡和鎖標誌位信息。它會根據對象的狀態複用自己的存儲空間,也就是說在運行期間Mark Word裏存儲的數據會隨着鎖標誌位的變化而變化。
    • Klass Point:對象指向它的類元數據的指針,虛擬機通過這個指針來確定這個對象是哪個類的實例。

    你可以看到在對象頭中保存了鎖標誌位和指向 monitor 對象的起始地址,如下圖所示,右側就是對象對應的 Monitor 對象。

    當 Monitor 被某個線程持有後,就會處於鎖定狀態,如圖中的 Owner 部分,會指向持有 Monitor 對象的線程。

    另外 Monitor 中還有兩個隊列分別是EntryList和WaitList,主要是用來存放進入及等待獲取鎖的線程。

    如果線程進入,則得到當前對象鎖,那麼別的線程在該類所有對象上的任何操作都不能進行。

在對象級使用鎖通常是一種比較粗糙的方法,爲什麼要將整個對象都上鎖,而不允許其他線程短暫地使用對象中其他同步方法來訪問共享資源?

如果一個對象擁有多個資源,就不需要只爲了讓一個線程使用其中一部分資源,就將所有線程都鎖在外面。

由於每個對象都有鎖,可以如下所示使用虛擬對象來上鎖:

 class FineGrainLock{
   MyMemberClassx,y;
   Object xlock = new Object(), ylock = newObject();
   public void foo(){
       synchronized(xlock){
       //accessxhere
        }
       //dosomethinghere-butdon'tusesharedresources
        synchronized(ylock){
        //accessyhere
        }
   }
      public void bar(){
        synchronized(this){
           //accessbothxandyhere
       }
      //dosomethinghere-butdon'tusesharedresources
      }
  }
  • 1
  • synchronized 應用在方法上時,在字節碼中是通過方法的 ACC_SYNCHRONIZED 標誌來實現的。

    我反編譯了一小段代碼,我們可以看一下我加鎖了一個方法,在字節碼長啥樣,flags字段矚目:

    synchronized void test();
      descriptor: ()V
      flags: ACC_SYNCHRONIZED
      Code:
        stack=0, locals=1, args_size=1
           0return
        LineNumberTable:
          line 70
        LocalVariableTable:
          Start  Length  Slot  Name   Signature
              0       1     0  this   Ljvm/ClassCompile;
    • 1

    反正其他線程進這個方法就看看是否有這個標誌位,有就代表有別的仔擁有了他,你就別碰了。

  • synchronized 應用在同步塊上時,在字節碼中是通過 monitorenter 和 monitorexit 實現的。

    每個對象都會與一個monitor相關聯,當某個monitor被擁有之後就會被鎖住,當線程執行到monitorenter指令時,就會去嘗試獲得對應的monitor。

    步驟如下:

  1. 每個monitor維護着一個記錄着擁有次數的計數器。未被擁有的monitor的該計數器爲0,當一個線程獲得monitor(執行monitorenter)後,該計數器自增變爲 1 。

    • 當同一個線程再次獲得該monitor的時候,計數器再次自增;
    • 當不同線程想要獲得該monitor的時候,就會被阻塞。
  2. 當同一個線程釋放 monitor(執行monitorexit指令)的時候,計數器再自減。

    當計數器爲0的時候,monitor將被釋放,其他線程便可以獲得monitor。

    同樣看一下反編譯後的一段鎖定代碼塊的結果:

    public void syncTask();
      descriptor: ()V
      flags: ACC_PUBLIC
      Code:
        stack=3, locals=3, args_size=1
           0: aload_0
           1: dup
           2: astore_1
           3: monitorenter  //注意此處,進入同步方法
           4: aload_0
           5: dup
           6: getfield      #2             // Field i:I
           9: iconst_1
          10: iadd
          11: putfield      #2            // Field i:I
          14: aload_1
          15: monitorexit   //注意此處,退出同步方法
          16: goto          24
          19: astore_2
          20: aload_1
          21: monitorexit //注意此處,退出同步方法
          22: aload_2
          23: athrow
          24return
        Exception table:
        //省略其他字節碼.......
    • 1

小結:

同步方法和同步代碼塊底層都是通過monitor來實現同步的。

兩者的區別:同步方式是通過方法中的access_flags中設置ACC_SYNCHRONIZED標誌來實現,同步代碼塊是通過monitorenter和monitorexit來實現。

我們知道了每個對象都與一個monitor相關聯,而monitor可以被線程擁有或釋放。

🐂,小夥子我只能說,你確實有點東西,以前我們一直鎖synchronized是重量級的鎖,爲啥現在都不提了?

在多線程併發編程中 synchronized 一直是元老級角色,很多人都會稱呼它爲重量級鎖。

但是,隨着 Java SE 1.6 對 synchronized 進行了各種優化之後,有些情況下它就並不那麼重,Java SE 1.6 中爲了減少獲得鎖和釋放鎖帶來的性能消耗而引入的偏向鎖和輕量級鎖。

針對 synchronized 獲取鎖的方式,JVM 使用了鎖升級的優化方式,就是先使用偏向鎖優先同一線程然後再次獲取鎖,如果失敗,就升級爲 CAS 輕量級鎖,如果失敗就會短暫自旋,防止線程被系統掛起。最後如果以上都失敗就升級爲重量級鎖。

Tip:本來鎖升級的過程我是搞了個賊詳細賊複雜的圖,但是我發現不便於理解,我就幼兒園化了,所以就有了個簡單版本的,先看下複雜版本的:

幼兒園版本:

看到這你如果還想白嫖,我勸你善良,萬水千山總是情,不要白嫖行不行?點個贊再走哈哈。

對了鎖只能升級,不能降級。

還有其他的同步手段麼?

ReentrantLock但是在介紹這玩意之前,我覺得我有必要先介紹AQS(AbstractQueuedSynchronizer)。

AQS:也就是隊列同步器,這是實現 ReentrantLock 的基礎。

AQS 有一個 state 標記位,值爲1 時表示有線程佔用,其他線程需要進入到同步隊列等待,同步隊列是一個雙向鏈表。

當獲得鎖的線程需要等待某個條件時,會進入 condition 的等待隊列,等待隊列可以有多個。

當 condition 條件滿足時,線程會從等待隊列重新進入同步隊列進行獲取鎖的競爭。

ReentrantLock 就是基於 AQS 實現的,如下圖所示,ReentrantLock 內部有公平鎖和非公平鎖兩種實現,差別就在於新來的線程是否比已經在同步隊列中的等待線程更早獲得鎖。

和 ReentrantLock 實現方式類似,Semaphore 也是基於 AQS 的,差別在於 ReentrantLock 是獨佔鎖,Semaphore 是共享鎖。

從圖中可以看到,ReentrantLock裏面有一個內部類Sync,Sync繼承AQS(AbstractQueuedSynchronizer),添加鎖和釋放鎖的大部分操作實際上都是在Sync中實現的。

它有公平鎖FairSync和非公平鎖NonfairSync兩個子類。

ReentrantLock默認使用非公平鎖,也可以通過構造器來顯示的指定使用公平鎖。

相關資料

Tip:本來這一欄有很多我準備的資料的,但是都是外鏈,或者不合適的分享方式,博客的運營小姐姐提醒了我,所以大家去公衆號回覆【資料】好了。

技術總結

鎖其實有很多,我這裏只是簡單的介紹了一下樂觀鎖和悲觀鎖,後面還會有,自旋鎖,自適應自旋,公平鎖,非公平鎖,可重入(文中提到的都是可重入),不可重入鎖,共享鎖,排他鎖等。

多去了解他們的用法,多去深究他們的原理以及實現,我後面會持續更新多線程方面的知識點。

Tip:這是我新的技術總結方式,以後會慢慢完善,如果被博客平臺二壓公衆號回覆【多線程】獲取。

參考:《Java高併發編程》、《美團技術團隊鎖的思考》、《拉鉤張雷java32個考點》

絮叨

我不斷的嘗試新的文章風格,我也把絮叨環節放到了最後就是給大家一個好的閱讀體驗,有建議隨時提喲,新的技術總結方式如何?

還記得我幫公司內推麼,我收到300封簡歷,但是我放到系統的只有13份,不是我想吐槽大家,是真的得用心點啊,叫我內推發個郵件,簡歷都不發什麼鬼,簡歷總共才100個字又是什麼鬼。。。

週末出一期視頻說一下簡歷的問題,真的爲你們春招擔心啊仔。。。。

我是敖丙,一個在互聯網苟且偷生的工具人。

創作不易,不想被白嫖,各位的「三連」就是丙丙創作的最大動力,我們下次見!


文章持續更新,可以微信搜索「 三太子敖丙 」第一時間閱讀,回覆【資料】【面試】【簡歷】有我準備的一線大廠面試資料和簡歷模板,本文 GitHub https://github.com/JavaFamily 已經收錄,有大廠面試完整考點,歡迎Star。

你知道的越多,你不知道的越多

粉絲轉載、記錄,侵刪
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章