天天看點

【JavaEE】經典面試題之常見的鎖政策詳解——初學者也能看懂樂觀鎖 vs 悲觀鎖讀寫鎖重量級鎖 vs 輕量級鎖挂起等待鎖vs自旋鎖公平鎖 vs 非公平鎖可重入鎖 vs 不可重入鎖CAS(重點)Synchronized 原理相關面試題(重點)

文章目錄

  • 樂觀鎖 vs 悲觀鎖
    • 悲觀鎖(預期鎖沖突的機率很高)
    • 樂觀鎖(預期鎖沖突的機率很低)
  • 讀寫鎖
  • 重量級鎖 vs 輕量級鎖
  • 挂起等待鎖vs自旋鎖
  • 公平鎖 vs 非公平鎖
  • 可重入鎖 vs 不可重入鎖
  • CAS(重點)
    • 什麼是 CAS
    • CAS 是怎麼實作的
    • CAS 有哪些應用
    • CAS 的 ABA 問題(重中之重)
      • 什麼是 ABA 問題
      • ABA 問題引來的 BUG
      • 解決方案
  • Synchronized 原理
    • 基本特點
    • 典型的優化手段
      • 鎖膨脹/鎖更新
      • 鎖消除
      • 鎖粗化
  • 相關面試題(重點)
    • 你是怎麼了解樂觀鎖和悲觀鎖的,具體怎麼實作呢?
    • 介紹下讀寫鎖?
    • 什麼是自旋鎖,為什麼要使用自旋鎖政策呢,缺點是什麼?
    • synchronized 是可重入鎖麼?
    • 講解下你自己了解的 CAS 機制
    • ABA問題怎麼解決?
    • 什麼是偏向鎖?
    • synchronized 實作原理 是什麼?

引言:

接下來我們要講解的鎖政策不僅僅是局限于 Java ,任何和 “鎖” 相關的話題, 都可能會涉及到以下内容;

這些特性主要是給鎖的實作者來參考的,普通的程式猿也需要了解一些,對于合理的使用鎖也是有很大幫助的。

樂觀鎖 vs 悲觀鎖

悲觀鎖(預期鎖沖突的機率很高)

總是假設最壞的情況,每次去拿資料的時候都認為别人會修改,是以每次在拿資料的時候都會上鎖,這樣别人想拿這個資料就會阻塞,直到它拿到鎖。

樂觀鎖(預期鎖沖突的機率很低)

假設資料一般情況下不會産生并發沖突,是以在資料進行送出更新的時候,才會正式對資料是否産生并發沖突進行檢測,如果發現并發沖突了,則讓傳回使用者錯誤的資訊,讓使用者決定如何去做。

讀寫鎖

多線程之間,資料的讀取方之間不會産生線程安全問題,但資料的寫入方互相之間以及和讀者之間都需要進行互斥。

如果兩種場景下都用同一個鎖,就會産生極大的性能損耗。是以讀寫鎖是以而産生。

讀寫鎖(readers-writer lock),看英文可以顧名思義,在執行加鎖操作時需要額外表明讀寫意圖,複數讀者之間并不互斥,而寫者則要求與任何人互斥。

一個線程對于資料的通路, 主要存在兩種操作: 讀資料 和 寫資料。

  • 兩個線程都隻是讀一個資料, 此時并沒有線程安全問題, 直接并發的讀取即可;
  • 兩個線程都要寫一個資料, 有線程安全問題;
  • 一個線程讀另外一個線程寫, 也有線程安全問題。

讀寫鎖就是把讀操作和寫操作區分對待, Java 标準庫提供了 ReentrantReadWriteLock 類,實作了讀寫鎖。

  • ReentrantReadWriteLock.ReadLock 類表示一個讀鎖. 這個對象提供了 lock / unlock 方法進行加鎖解鎖;
  • ReentrantReadWriteLock.WriteLock 類表示一個寫鎖. 這個對象也提供了 lock / unlock 方法進行加鎖解鎖。

其中

  • 讀加鎖和讀加鎖之間, 不互斥;
  • 寫加鎖和寫加鎖之間, 互斥;
  • 讀加鎖和寫加鎖之間, 互斥。
讀寫鎖特别适合于 “頻繁讀,不頻繁寫” 的場景中, (這樣的場景其實也是非常廣泛存在的)。

重量級鎖 vs 輕量級鎖

  • 重量級鎖:就是做了更多的事情,開銷更大
  • 輕量級鎖:做的事情更少,開銷更小
通常情況下,也可以認為悲觀鎖一般都是重量級鎖,樂觀鎖一般都是輕量級鎖。(不絕對)
  • 在使用的鎖中,如果鎖是基于核心的一些功能來實作的(比如調用了作業系統提供的mutex接口),此時一般認為這是重量級鎖。——(作業系統的鎖會在核心中做很多事情,比如讓線程阻塞等待…)
  • 如果鎖是純使用者态實作的,此時一般認為這是輕量級鎖——(使用者态的代碼更可控,也更高效)

挂起等待鎖vs自旋鎖

  • 挂起等待鎖,往往就是通過核心的一些機制來實作的,往往較重——(重量級鎖的一種典型實作)
  • 自旋鎖,往往就是通過使用者态代碼來實作的,往往較輕——(輕量級鎖的一種典型實作)

公平鎖 vs 非公平鎖

  • 公平鎖:多個線程在等待一把鎖的時候,誰是先來的,誰就先擷取到這個鎖(遵守先來後到)
  • 非公平鎖:多個線程在等待一把鎖的時候,不遵守先來後到(每個等待的線程取到鎖的機率都是均等的)

可重入鎖 vs 不可重入鎖

由于上一篇部落格已經介紹過此相關知識,是以在此就不在多加贅述了。😊😊簡單來說就是,一個線程,針對一把鎖,咔咔連續加鎖兩次,如果會死鎖,就是不可重入鎖;如果不會死鎖,就是可重入鎖。

CAS(重點)

什麼是 CAS

CAS: 全稱Compare and swap,字面意思:”比較并交換“,一個 CAS 涉及到以下操作:

我們假設記憶體中的原資料V,舊的預期值A,需要修改的新值B。

  • 比較 A 與 V 是否相等。(比較)
  • 如果比較相等,将 B 寫入 V。(交換)
  • 傳回操作是否成功。
    【JavaEE】經典面試題之常見的鎖政策詳解——初學者也能看懂樂觀鎖 vs 悲觀鎖讀寫鎖重量級鎖 vs 輕量級鎖挂起等待鎖vs自旋鎖公平鎖 vs 非公平鎖可重入鎖 vs 不可重入鎖CAS(重點)Synchronized 原理相關面試題(重點)

    上面寫的代碼顯然是線程不安全的,既有讀又有寫,而且讀和寫還不是原子的,這個僞代碼隻是輔助了解CAS 的工作流程。

    真實的 CAS 指的是,CPU提供了一個單獨的CAS指令,通過這一條指令,就完成了上述僞代碼描述的過程。如果上述過程都是這“這一條指令”就幹完了,就相當于這是原子的了(CPU上面執行的指令就是一條一條執行的,指令是不可分割的最小機關)此時線程就安全了!!!

    CAS最大的意義就是,讓我們寫這種多線程安全的代碼時,提供了一個新的思路和方向(就和鎖不一樣了)

有很多功能,既可以是硬體實作也可以是軟體實作,就像上面代碼中的比較交換邏輯,這就相當于硬體直接實作出來了,通過這一 條指令,封裝好,直接讓咱們用了。

CAS 是怎麼實作的

針對不同的作業系統,JVM 用到了不同的 CAS 實作原理,簡單來講:

  • java 的 CAS 利用的的是 unsafe 這個類提供的 CAS 操作;
  • unsafe 的 CAS 依賴了的是 jvm 針對不同的作業系統實作的Atomic::cmpxchg;
  • Atomic::cmpxchg 的實作使用了彙編的 CAS 操作,并使用 cpu 硬體提供的 lock 機制保證其原子性。
簡而言之,是因為硬體予以了支援,軟體層面才能做到。

CAS 有哪些應用

  1. 基于CAS能夠實作"原子類"

    Java标準庫裡提供了一組原子類,針對所常用多一些int, long, int array…進行了封裝可以基于CAS的方式進行修改,并且線程安全。

    典型的就是 AtomicInteger 類. 其中的 getAndIncrement 相當于 i++ 操作。

僞代碼實作:

class AtomicInteger {
    private int value;
    public int getAndIncrement() {
        int oldValue = value;
        while ( CAS(value, oldValue, oldValue+1) != true) {
            oldValue = value;
       }
        return oldValue;
   }
}
           

圖解如下:

【JavaEE】經典面試題之常見的鎖政策詳解——初學者也能看懂樂觀鎖 vs 悲觀鎖讀寫鎖重量級鎖 vs 輕量級鎖挂起等待鎖vs自旋鎖公平鎖 vs 非公平鎖可重入鎖 vs 不可重入鎖CAS(重點)Synchronized 原理相關面試題(重點)
【JavaEE】經典面試題之常見的鎖政策詳解——初學者也能看懂樂觀鎖 vs 悲觀鎖讀寫鎖重量級鎖 vs 輕量級鎖挂起等待鎖vs自旋鎖公平鎖 vs 非公平鎖可重入鎖 vs 不可重入鎖CAS(重點)Synchronized 原理相關面試題(重點)

具體代碼示例如下:

import java.util.concurrent.atomic.AtomicInteger;

public class Demo2 {
    public static void main(String[] args) throws InterruptedException {
        AtomicInteger atomicInteger=new AtomicInteger(0);
        Thread t1=new Thread(()->{
            for(int i=0;i<5000;i++){
                //這個方法就相當于atomicInteger++
                atomicInteger.getAndIncrement();
            }
        });
        Thread t2=new Thread(()->{
            for(int i=0;i<5000;i++){
                atomicInteger.getAndIncrement();
            }
        });
        t1.start();
        t2.start();
        t1.join();
        t2.join();
        //通過get方法得到原子類内部的數值
        System.out.println(atomicInteger.get());
    }
}
           
這個代碼裡面不存線上程安全問題,基于CAS實作的++操作, 這裡面就可以保證既能夠線程安全,又能夠比synchronized高效。因為 synchronized會涉及到鎖的競争,兩個線程要互相等待,而CAS不涉及到線程阻塞等待。
  1. 實作“自旋鎖"

    基于 CAS 實作更靈活的鎖,擷取到更多的控制權。

僞代碼如下:

public class SpinLock {
    private Thread owner = null;
    public void lock(){
        // 通過 CAS 看目前鎖是否被某個線程持有. 
        // 如果這個鎖已經被别的線程持有, 那麼就自旋等待. 
        // 如果這個鎖沒有被别的線程持有, 那麼就把 owner 設為目前嘗試加鎖的線程. 
        while(!CAS(this.owner, null, Thread.currentThread())){
       }
   }
    public void unlock (){
        this.owner = null;
   }
}
           

圖解如下:

【JavaEE】經典面試題之常見的鎖政策詳解——初學者也能看懂樂觀鎖 vs 悲觀鎖讀寫鎖重量級鎖 vs 輕量級鎖挂起等待鎖vs自旋鎖公平鎖 vs 非公平鎖可重入鎖 vs 不可重入鎖CAS(重點)Synchronized 原理相關面試題(重點)

CAS 的 ABA 問題(重中之重)

什麼是 ABA 問題

ABA 的問題:

假設存在兩個線程 t1 和 t2, 有一個共享變量 num, 初始值為 A, 接下來, 線程 t1 想使用 CAS 把 num 值改成 Z, 那麼就需要

  • 先讀取 num 的值, 記錄到 oldNum 變量中;
  • 使用 CAS 判定目前 num 的值是否為 A, 如果為 A,就修改成 Z。

    但是, 在 t1 執行這兩個操作之間, t2 線程可能把 num 的值從 A 改成了 B, 又從 B 改成了 A。

線程 t1 的 CAS 是期望 num 不變就修改, 但是 num 的值已經被 t2 給改了, 隻不過又改成 A 了, 這個時候 t1 究竟是否要更新 num 的值為 Z 呢?

到這一步, t1 線程無法區分目前這個變量始終是 A, 還是經曆了一個變化過程。

ABA 問題引來的 BUG

大部分的情況下, t2 線程這樣的一個反複橫跳改動, 對于 t1 是否修改 num 是沒有影響的, 但是不排除一些特殊情況。

下面給大家舉一個經典的例子:

【JavaEE】經典面試題之常見的鎖政策詳解——初學者也能看懂樂觀鎖 vs 悲觀鎖讀寫鎖重量級鎖 vs 輕量級鎖挂起等待鎖vs自旋鎖公平鎖 vs 非公平鎖可重入鎖 vs 不可重入鎖CAS(重點)Synchronized 原理相關面試題(重點)

解決方案

引入一個“版本号”(也可以使用時間戳),這個版本号隻能變大,不能變小,修改變量的時候,比較就不是比較變量本身了,而是比較版本号了~

【JavaEE】經典面試題之常見的鎖政策詳解——初學者也能看懂樂觀鎖 vs 悲觀鎖讀寫鎖重量級鎖 vs 輕量級鎖挂起等待鎖vs自旋鎖公平鎖 vs 非公平鎖可重入鎖 vs 不可重入鎖CAS(重點)Synchronized 原理相關面試題(重點)

此處就要求,每次針對餘額進行修改,都讓版本+ 1,每次修改之前,也要先對比版本看看舊值和目前值是否一緻~

當引入版本号之後,t2再嘗試進行這裡的比較版本操作

就發現版本的舊值和目前值并不比對,是以就放棄進行修改~

如果直接拿變量本身進行判定,因為變量的值有加有減,就容易出現ABA的情況,現在是拿版本号來進行判定,要求版本号隻能增加,這個時候就不會有ABA問題了!!

Synchronized 原理

基本特點

結合上面的鎖政策,我們就可以總結出, Synchronized 具有以下特性(隻考慮 JDK 1.8):

  1. 開始時是樂觀鎖,如果鎖沖突頻繁, 就轉換為悲觀鎖;
  2. 開始是輕量級鎖實作,如果鎖被持有的時間較長,就轉換成重量級鎖;
  3. 實作輕量級鎖的時候大機率用到的自旋鎖政策;
  4. 是一種不公平鎖;
  5. 是一種可重入鎖;
  6. 不是讀寫鎖。

典型的優化手段

鎖膨脹/鎖更新

JVM 将 synchronized 鎖分為 無鎖、偏向鎖、輕量級鎖、重量級鎖狀态。會根據情況,進行依次更新。
【JavaEE】經典面試題之常見的鎖政策詳解——初學者也能看懂樂觀鎖 vs 悲觀鎖讀寫鎖重量級鎖 vs 輕量級鎖挂起等待鎖vs自旋鎖公平鎖 vs 非公平鎖可重入鎖 vs 不可重入鎖CAS(重點)Synchronized 原理相關面試題(重點)
  1. 偏向鎖

偏向鎖不是真的 “加鎖”, 隻是給對象頭中做一個 “偏向鎖的标記”,記錄這個鎖屬于哪個線程。

如果後續沒有其他線程來競争該鎖,那麼就不用進行其他同步操作了(避免了加鎖解鎖的開銷) 如果後續有其他線程來競争該鎖(剛才已經在鎖對象中記錄了目前鎖屬于哪個線程了,很容易識别 目前申請鎖的線程是不是之前記錄的線程), 那就取消原來的偏向鎖狀态, 進入一般的輕量級鎖狀态。

偏向鎖本質上相當于 “延遲加鎖” ,能不加鎖就不加鎖, 盡量來避免不必要的加鎖開銷,但是該做的标記還是得做的, 否則無法區分何時需要真正加鎖。

舉個栗子:(❁´◡`❁)

假設男主是一個鎖, 女主是一個線程. 如果隻有這一個線程來使用這個鎖, 那麼男主女主即使不領證結婚(避免了高成本操作),也可以一直幸福的生活下去。

——此時如果女配(相當于其他線程)沒有出現的話,男女主就可以一直不結婚,就不必真的加鎖

但是女配出現了, 也嘗試競争男主, 此時不管領證結婚這個操作成本多高, 女主也勢必要把這個動作完成了, 讓女配死心。——偏向鎖更新到輕量級鎖的過程

  1. 輕量級鎖

    随着其他線程進入競争, 偏向鎖狀态被消除, 進入輕量級鎖狀态(自适應的自旋鎖).

    此處的輕量級鎖就是通過 CAS 來實作.

  • 通過 CAS 檢查并更新一塊記憶體 (比如 null => 該線程引用)
  • 如果更新成功, 則認為加鎖成功
  • 如果更新失敗, 則認為鎖被占用, 繼續自旋式的等待(并不放棄 CPU)。

自旋操作是一直讓 CPU 空轉, 比較浪費 CPU 資源。

是以此處的自旋不會一直持續進行, 而是達到一定的時間/重試次數, 就不再自旋了。

也就是所謂的 “自适應”

  • 重量級鎖

    如果競争進一步激烈, 自旋不能快速擷取到鎖狀态, 就會膨脹為重量級鎖

    此處的重量級鎖就是指用到核心提供的 mutex。

  • 執行加鎖操作, 先進入核心态.
  • 在核心态判定目前鎖是否已經被占用
  • 如果該鎖沒有占用, 則加鎖成功, 并切換回使用者态.
  • 如果該鎖被占用, 則加鎖失敗. 此時線程進入鎖的等待隊列, 挂起, 等待被操坐系統喚醒。
  • 經曆了一系列的滄海桑田, 這個鎖被其他線程釋放了, 作業系統也想起了這個挂起的線程, 于是喚醒這個線程, 嘗試重新擷取鎖

鎖消除

編譯器+JVM 判斷鎖是否可消除, 如果可以, 就直接消除。

什麼是 “鎖消除”

有些應用程式的代碼中, 用到了 synchronized, 但其實沒有在多線程環境下。(例如 StringBuffer)
StringBuffer sb = new StringBuffer();
sb.append("a");
sb.append("b");
sb.append("c");
sb.append("d");
           
此時每個 append 的調用都會涉及加鎖和解鎖,但如果隻是在單線程中執行這個代碼, 那麼這些加鎖解鎖操作是沒有必要的,白白浪費了一些資源開銷。

鎖粗化

一段邏輯中如果出現多次加鎖解鎖, 編譯器 + JVM 會自動進行鎖的粗化。

鎖的粒度: 粗和細,“粒度”是指加鎖代碼涉及到的範圍,加鎖代碼的範圍越大,認為鎖的粒度越粗;範圍越小,則認為粒度越細。

那麼鎖粒度是粗好還是細好呢?隻能說各有各的好:

  • 如果鎖粒度比較細,那麼多個線程之間的并發性就更高;
  • 如果鎖粒度比較粗,則加鎖解鎖的開銷就更小。

是以編譯器就會有一個優化,就會自動判定說如果某個地方的代碼鎖的粒度太細了就會進行粗化–

  • 如果兩次加鎖之間的間隔較大(中間隔的代碼多),一般不會進行這種優化
  • 如果加鎖之間間隔比較小(中間隔的代碼少),就很可能觸發這個優化

舉個栗子了解鎖粗化:

滑稽老哥當了上司, 給下屬交代工作任務:

方式一:

打電話, 交代任務1, 挂電話.

打電話, 交代任務2, 挂電話.

打電話,交代任務3, 挂電話.

方式二:

打電話, 交代任務1, 任務2, 任務3, 挂電話.

顯然, 方式二是更高效的方案!!!

相關面試題(重點)

你是怎麼了解樂觀鎖和悲觀鎖的,具體怎麼實作呢?

悲觀鎖 認為多個線程通路同一個共享變量沖突的機率較大, 會在每次通路共享變量之前都去真正加鎖;

樂觀鎖 認為多個線程通路同一個共享變量沖突的機率不大,并不會真的加鎖, 而是直接嘗試通路資料,在通路的同時識别目前的資料是否出現通路沖突。

悲觀鎖的實作 就是先加鎖(比如借助作業系統提供的 mutex), 擷取到鎖再操作資料,擷取不到鎖就等待。

樂觀鎖的實作 可以引入一個版本号,借助版本号識别出目前的資料通路是否沖突。

介紹下讀寫鎖?

讀寫鎖就是把讀操作和寫操作分别進行加鎖。

讀鎖和讀鎖之間不互斥; 寫鎖和寫鎖之間互斥; 寫鎖和讀鎖之間互斥。

讀寫鎖最主要用在 “頻繁讀, 不頻繁寫” 的場景中。

什麼是自旋鎖,為什麼要使用自旋鎖政策呢,缺點是什麼?

如果擷取鎖失敗, 立即再嘗試擷取鎖,無限循環, 直到擷取到鎖為止。 第一次擷取鎖失敗, 第二次的嘗試會在極短的時間内到來; 一旦鎖被其他線程釋放, 就能第一時間擷取到鎖,相比于挂起等待鎖:

優點: 沒有放棄 CPU 資源, 一旦鎖被釋放就能第一時間擷取到鎖, 更高效,在鎖持有時間比較短的場景下非常有用。

缺點: 如果鎖的持有時間較長, 就會浪費 CPU 資源。

synchronized 是可重入鎖麼?

是可重入鎖,可重入鎖指的就是連續兩次加鎖不會導緻死鎖。

實作的方式是在鎖中記錄該鎖持有的線程身份,以及一個計數器(記錄加鎖次數), 如果發現目前加鎖的線程就是持有鎖的線程,則直接計數自增。

講解下你自己了解的 CAS 機制

全稱 Compare and swap, 即 “比較并交換”。相當于通過一個原子的操作,同時完成 “讀取記憶體,比較是否相等, 修改記憶體” 這三個步驟。 本質上需要 CPU 指令的支撐。

ABA問題怎麼解決?

給要修改的資料引入版本号, 在 CAS 比較資料目前值和舊值的同時, 也要比較版本号是否符合預期,如果發現目前版本号和之前讀到的版本号一緻,就真正執行修改操作, 并讓版本号自增;如果發現目前版本号比之前讀到的版本号大, 就認為操作失敗。

什麼是偏向鎖?

偏向鎖不是真的加鎖, 而隻是在鎖的對象頭中記錄一個标記(記錄該鎖所屬的線程)。 如果沒有其他線程參與競争鎖, 那麼就不會真正執行加鎖操作, 進而降低程式開銷。 一旦真的涉及到其他的線程競争, 再取消偏向鎖狀态, 進入輕量級鎖狀态。

synchronized 實作原理 是什麼?

參考上面的 synchronized 原理 章節全部内容↑↑↑

【JavaEE】經典面試題之常見的鎖政策詳解——初學者也能看懂樂觀鎖 vs 悲觀鎖讀寫鎖重量級鎖 vs 輕量級鎖挂起等待鎖vs自旋鎖公平鎖 vs 非公平鎖可重入鎖 vs 不可重入鎖CAS(重點)Synchronized 原理相關面試題(重點)

繼續閱讀