不懂什麼是鎖?看看這篇你就明白了

Java 鎖分類

Java 中的鎖有不少,能夠按照不一樣的功能、種類進行分類,下面是我對 Java 中一些經常使用鎖的分類,包括一些基本的概述java

image.png

  • 從線程是否須要對資源加鎖能夠分爲 悲觀鎖樂觀鎖
  • 從資源已被鎖定,線程是否阻塞能夠分爲 自旋鎖
  • 從多個線程併發訪問資源,也就是 Synchronized 能夠分爲 無鎖偏向鎖輕量級鎖重量級鎖
  • 從鎖的公平性進行區分,能夠分爲公平鎖非公平鎖
  • 從根據鎖是否重複獲取能夠分爲 可重入鎖不可重入鎖
  • 從那個多個線程可否獲取同一把鎖分爲 共享鎖排他鎖

下面咱們依次對各個鎖的分類進行詳細闡述。node

線程是否須要對資源加鎖

Java 按照是否對資源加鎖分爲樂觀鎖悲觀鎖,樂觀鎖和悲觀鎖並非一種真實存在的鎖,而是一種設計思想,樂觀鎖和悲觀鎖對於理解 Java 多線程和數據庫來講相當重要,下面就來探討一下這兩種實現方式的區別和優缺點mysql

悲觀鎖

悲觀鎖是一種悲觀思想,它總認爲最壞的狀況可能會出現,它認爲數據極可能會被其餘人所修改,因此悲觀鎖在持有數據的時候總會把資源 或者 數據 鎖住,這樣其餘線程想要請求這個資源的時候就會阻塞,直到等到悲觀鎖把資源釋放爲止。傳統的關係型數據庫裏邊就用到了不少這種鎖機制,好比行鎖,表鎖等,讀鎖,寫鎖等,都是在作操做以前先上鎖。悲觀鎖的實現每每依靠數據庫自己的鎖功能實現。git

Java 中的 SynchronizedReentrantLock 等獨佔鎖(排他鎖)也是一種悲觀鎖思想的實現,由於 Synchronzied 和 ReetrantLock 無論是否持有資源,它都會嘗試去加鎖,生怕本身心愛的寶貝被別人拿走。github

樂觀鎖

樂觀鎖的思想與悲觀鎖的思想相反,它總認爲資源和數據不會被別人所修改,因此讀取不會上鎖,可是樂觀鎖在進行寫入操做的時候會判斷當前數據是否被修改過(具體如何判斷咱們下面再說)。樂觀鎖的實現方案通常來講有兩種: 版本號機制CAS實現 。樂觀鎖多適用於多度的應用類型,這樣能夠提升吞吐量。算法

在Java中java.util.concurrent.atomic包下面的原子變量類就是使用了樂觀鎖的一種實現方式 CAS 實現的。sql

兩種鎖的使用場景

上面介紹了兩種鎖的基本概念,並提到了兩種鎖的適用場景,通常來講,悲觀鎖不只會對寫操做加鎖還會對讀操做加鎖,一個典型的悲觀鎖調用:數據庫

select * from student where name="cxuan" for update

這條 sql 語句從 Student 表中選取 name = "cxuan" 的記錄並對其加鎖,那麼其餘寫操做再這個事務提交以前都不會對這條數據進行操做,起到了獨佔和排他的做用。編程

悲觀鎖由於對讀寫都加鎖,因此它的性能比較低,對於如今互聯網提倡的三高(高性能、高可用、高併發)來講,悲觀鎖的實現用的愈來愈少了,可是通常多讀的狀況下仍是須要使用悲觀鎖的,由於雖然加鎖的性能比較低,可是也阻止了像樂觀鎖同樣,遇到寫不一致的狀況下一直重試的時間。緩存

相對而言,樂觀鎖用於讀多寫少的狀況,即不多發生衝突的場景,這樣能夠省去鎖的開銷,增長系統的吞吐量。

樂觀鎖的適用場景有不少,典型的好比說成本系統,櫃員要對一筆金額作修改,爲了保證數據的準確性和實效性,使用悲觀鎖鎖住某個數據後,再遇到其餘須要修改數據的操做,那麼此操做就沒法完成金額的修改,對產品來講是災難性的一刻,使用樂觀鎖的版本號機制可以解決這個問題,咱們下面說。

樂觀鎖的實現方式

樂觀鎖通常有兩種實現方式:採用版本號機制CAS(Compare-and-Swap,即比較並替換)算法實現。

版本號機制

版本號機制是在數據表中加上一個 version 字段來實現的,表示數據被修改的次數,當執行寫操做而且寫入成功後,version = version + 1,當線程A要更新數據時,在讀取數據的同時也會讀取 version 值,在提交更新時,若剛纔讀取到的 version 值爲當前數據庫中的version值相等時才更新,不然重試更新操做,直到更新成功。

咱們以上面的金融系統爲例,來簡述一下這個過程。

image.png

  • 成本系統中有一個數據表,表中有兩個字段分別是 金額version,金額的屬性是可以實時變化,而 version 表示的是金額每次發生變化的版本,通常的策略是,當金額發生改變時,version 採用遞增的策略每次都在上一個版本號的基礎上 + 1。
  • 在瞭解了基本狀況和基本信息以後,咱們來看一下這個過程:公司收到回款後,須要把這筆錢放在金庫中,假如金庫中存有100 元錢

    • 下面開啓事務一:當男櫃員執行回款寫入操做前,他會先查看(讀)一下金庫中還有多少錢,此時讀到金庫中有 100 元,能夠執行寫操做,並把數據庫中的錢更新爲 120 元,提交事務,金庫中的錢由 100 -> 120,version的版本號由 0 -> 1。
    • 開啓事務二:女櫃員收到給員工發工資的請求後,須要先執行讀請求,查看金庫中的錢還有多少,此時的版本號是多少,而後從金庫中取出員工的工資進行發放,提交事務,成功後版本 + 1,此時版本由 1 -> 2。

上面兩種狀況是最樂觀的狀況,上面的兩個事務都是順序執行的,也就是事務一和事務二互不干擾,那麼事務要並行執行會如何呢?

image.png

  • 事務一開啓,男櫃員先執行讀操做,取出金額和版本號,執行寫操做

    begin
    update 表 set 金額 = 120,version = version + 1 where 金額 = 100 and version = 0

    此時金額改成 120,版本號爲1,事務尚未提交

    事務二開啓,女櫃員先執行讀操做,取出金額和版本號,執行寫操做

    begin
    update 表 set 金額 = 50,version = version + 1 where 金額 = 100 and version = 0

    此時金額改成 50,版本號變爲 1,事務未提交

    如今提交事務一,金額改成 120,版本變爲1,提交事務。理想狀況下應該變爲 金額 = 50,版本號 = 2,可是實際上事務二 的更新是創建在金額爲 100 和 版本號爲 0 的基礎上的,因此事務二不會提交成功,應該從新讀取金額和版本號,再次進行寫操做。

    這樣,就避免了女櫃員 用基於 version = 0 的舊數據修改的結果覆蓋男操做員操做結果的可能。

CAS 算法

省略代碼,完整代碼請參照 看完你就應該能明白的悲觀鎖和樂觀鎖

CAS 即 compare and swap(比較與交換),是一種有名的無鎖算法。即不使用鎖的狀況下實現多線程之間的變量同步,也就是在沒有線程被阻塞的狀況下實現變量的同步,因此也叫非阻塞同步(Non-blocking Synchronization

Java 從 JDK1.5 開始支持,java.util.concurrent 包裏提供了不少面向併發編程的類,也提供了 CAS 算法的支持,一些以 Atomic 爲開頭的一些原子類都使用 CAS 做爲其實現方式。使用這些類在多核 CPU 的機器上會有比較好的性能。

若是要把證它們的原子性,必須進行加鎖,使用 Synchronzied 或者 ReentrantLock,咱們前面介紹它們是悲觀鎖的實現,咱們如今討論的是樂觀鎖,那麼用哪一種方式保證它們的原子性呢?請繼續往下看

CAS 中涉及三個要素:

  • 須要讀寫的內存值 V
  • 進行比較的值 A
  • 擬寫入的新值 B

當且僅當預期值A和內存值V相同時,將內存值V修改成B,不然什麼都不作。

咱們以 java.util.concurrent 中的 AtomicInteger 爲例,看一下在不用鎖的狀況下是如何保證線程安全的

public class AtomicCounter {

    private AtomicInteger integer = new AtomicInteger();

    public AtomicInteger getInteger() {
        return integer;
    }

    public void setInteger(AtomicInteger integer) {
        this.integer = integer;
    }

    public void increment(){
        integer.incrementAndGet();
    }

    public void decrement(){
        integer.decrementAndGet();
    }

}

public class AtomicProducer extends Thread{

    private AtomicCounter atomicCounter;

    public AtomicProducer(AtomicCounter atomicCounter){
        this.atomicCounter = atomicCounter;
    }

    @Override
    public void run() {
        for(int j = 0; j < AtomicTest.LOOP; j++) {
            System.out.println("producer : " + atomicCounter.getInteger());
            atomicCounter.increment();
        }
    }
}

public class AtomicConsumer extends Thread{

    private AtomicCounter atomicCounter;

    public AtomicConsumer(AtomicCounter atomicCounter){
        this.atomicCounter = atomicCounter;
    }

    @Override
    public void run() {
        for(int j = 0; j < AtomicTest.LOOP; j++) {
            System.out.println("consumer : " + atomicCounter.getInteger());
            atomicCounter.decrement();
        }
    }
}

public class AtomicTest {

    final static int LOOP = 10000;

    public static void main(String[] args) throws InterruptedException {

        AtomicCounter counter = new AtomicCounter();
        AtomicProducer producer = new AtomicProducer(counter);
        AtomicConsumer consumer = new AtomicConsumer(counter);

        producer.start();
        consumer.start();

        producer.join();
        consumer.join();

        System.out.println(counter.getInteger());

    }
}

經測試可得,無論循環多少次最後的結果都是0,也就是多線程並行的狀況下,使用 AtomicInteger 能夠保證線程安全性。 incrementAndGet 和 decrementAndGet 都是原子性操做。

樂觀鎖的缺點

任何事情都是有利也有弊,軟件行業沒有完美的解決方案只有最優的解決方案,因此樂觀鎖也有它的弱點和缺陷:

ABA 問題

ABA 問題說的是,若是一個變量第一次讀取的值是 A,準備好須要對 A 進行寫操做的時候,發現值仍是 A,那麼這種狀況下,能認爲 A 的值沒有被改變過嗎?能夠是由 A -> B -> A 的這種狀況,可是 AtomicInteger 卻不會這麼認爲,它只相信它看到的,它看到的是什麼就是什麼。

JDK 1.5 之後的 AtomicStampedReference 類就提供了此種能力,其中的 compareAndSet 方法就是首先檢查當前引用是否等於預期引用,而且當前標誌是否等於預期標誌,若是所有相等,則以原子方式將該引用和該標誌的值設置爲給定的更新值。

也能夠採用CAS的一個變種DCAS來解決這個問題。
DCAS,是對於每個V增長一個引用的表示修改次數的標記符。對於每一個V,若是引用修改了一次,這個計數器就加1。而後再這個變量須要update的時候,就同時檢查變量的值和計數器的值。

循環開銷大

咱們知道樂觀鎖在進行寫操做的時候會判斷是否可以寫入成功,若是寫入不成功將觸發等待 -> 重試機制,這種狀況是一個自旋鎖,簡單來講就是適用於短時間內獲取不到,進行等待重試的鎖,它不適用於長期獲取不到鎖的狀況,另外,自旋循環對於性能開銷比較大。

CAS與synchronized的使用情景

簡單的來講 CAS 適用於寫比較少的狀況下(多讀場景,衝突通常較少),synchronized 適用於寫比較多的狀況下(多寫場景,衝突通常較多)

  • 對於資源競爭較少(線程衝突較輕)的狀況,使用 Synchronized 同步鎖進行線程阻塞和喚醒切換以及用戶態內核態間的切換操做額外浪費消耗 cpu 資源;而 CAS 基於硬件實現,不須要進入內核,不須要切換線程,操做自旋概率較少,所以能夠得到更高的性能。
  • 對於資源競爭嚴重(線程衝突嚴重)的狀況,CAS 自旋的機率會比較大,從而浪費更多的 CPU 資源,效率低於 synchronized。

資源已被鎖定,線程是否阻塞

自旋鎖的提出背景

因爲在多處理器環境中某些資源的有限性,有時須要互斥訪問(mutual exclusion),這時候就須要引入鎖的概念,只有獲取了鎖的線程纔可以對資源進行訪問,因爲多線程的核心是CPU的時間分片,因此同一時刻只能有一個線程獲取到鎖。那麼就面臨一個問題,那麼沒有獲取到鎖的線程應該怎麼辦?

一般有兩種處理方式:一種是沒有獲取到鎖的線程就一直循環等待判斷該資源是否已經釋放鎖,這種鎖叫作自旋鎖,它不用將線程阻塞起來(NON-BLOCKING);還有一種處理方式就是把本身阻塞起來,等待從新調度請求,這種叫作互斥鎖

什麼是自旋鎖

自旋鎖的定義:當一個線程嘗試去獲取某一把鎖的時候,若是這個鎖此時已經被別人獲取(佔用),那麼此線程就沒法獲取到這把鎖,該線程將會等待,間隔一段時間後會再次嘗試獲取。這種採用循環加鎖 -> 等待的機制被稱爲自旋鎖(spinlock)

image.png

自旋鎖的原理

自旋鎖的原理比較簡單,若是持有鎖的線程能在短期內釋放鎖資源,那麼那些等待競爭鎖的線程就不須要作內核態和用戶態之間的切換進入阻塞狀態,它們只須要等一等(自旋),等到持有鎖的線程釋放鎖以後便可獲取,這樣就避免了用戶進程和內核切換的消耗。

由於自旋鎖避免了操做系統進程調度和線程切換,因此自旋鎖一般適用在時間比較短的狀況下。因爲這個緣由,操做系統的內核常用自旋鎖。可是,若是長時間上鎖的話,自旋鎖會很是耗費性能,它阻止了其餘線程的運行和調度。線程持有鎖的時間越長,則持有該鎖的線程將被 OS(Operating System) 調度程序中斷的風險越大。若是發生中斷狀況,那麼其餘線程將保持旋轉狀態(反覆嘗試獲取鎖),而持有該鎖的線程並不打算釋放鎖,這樣致使的是結果是無限期推遲,直到持有鎖的線程能夠完成並釋放它爲止。

解決上面這種狀況一個很好的方式是給自旋鎖設定一個自旋時間,等時間一到當即釋放自旋鎖。自旋鎖的目的是佔着CPU資源不進行釋放,等到獲取鎖當即進行處理。可是如何去選擇自旋時間呢?若是自旋執行時間太長,會有大量的線程處於自旋狀態佔用 CPU 資源,進而會影響總體系統的性能。所以自旋的週期選的額外重要!JDK在1.6 引入了適應性自旋鎖,適應性自旋鎖意味着自旋時間不是固定的了,而是由前一次在同一個鎖上的自旋時間以及鎖擁有的狀態來決定,基本認爲一個線程上下文切換的時間是最佳的一個時間。

自旋鎖的優缺點

自旋鎖儘量的減小線程的阻塞,這對於鎖的競爭不激烈,且佔用鎖時間很是短的代碼塊來講性能能大幅度的提高,由於自旋的消耗會小於線程阻塞掛起再喚醒的操做的消耗,這些操做會致使線程發生兩次上下文切換!

可是若是鎖的競爭激烈,或者持有鎖的線程須要長時間佔用鎖執行同步塊,這時候就不適合使用自旋鎖了,由於自旋鎖在獲取鎖前一直都是佔用 cpu 作無用功,佔着 XX 不 XX,同時有大量線程在競爭一個鎖,會致使獲取鎖的時間很長,線程自旋的消耗大於線程阻塞掛起操做的消耗,其它須要 cpu 的線程又不能獲取到 cpu,形成 cpu 的浪費。因此這種狀況下咱們要關閉自旋鎖。

自旋鎖的實現

下面咱們用Java 代碼來實現一個簡單的自旋鎖

public class SpinLockTest {

    private AtomicBoolean available = new AtomicBoolean(false);

    public void lock(){

        // 循環檢測嘗試獲取鎖
        while (!tryLock()){
            // doSomething...
        }

    }

    public boolean tryLock(){
        // 嘗試獲取鎖,成功返回true,失敗返回false
        return available.compareAndSet(false,true);
    }

    public void unLock(){
        if(!available.compareAndSet(true,false)){
            throw new RuntimeException("釋放鎖失敗");
        }
    }

}

這種簡單的自旋鎖有一個問題:沒法保證多線程競爭的公平性。對於上面的 SpinlockTest,當多個線程想要獲取鎖時,誰最早將available設爲false誰就能最早得到鎖,這可能會形成某些線程一直都未獲取到鎖形成線程飢餓。就像咱們下課後蜂擁的跑向食堂,下班後蜂擁地擠向地鐵,一般咱們會採起排隊的方式解決這樣的問題,相似地,咱們把這種鎖叫排隊自旋鎖(QueuedSpinlock)。計算機科學家們使用了各類方式來實現排隊自旋鎖,如TicketLock,MCSLock,CLHLock。接下來咱們分別對這幾種鎖作個大體的介紹。

TicketLock

在計算機科學領域中,TicketLock 是一種同步機制或鎖定算法,它是一種自旋鎖,它使用ticket 來控制線程執行順序。

就像票據隊列管理系統同樣。麪包店或者服務機構(例如銀行)都會使用這種方式來爲每一個先到達的顧客記錄其到達的順序,而不用每次都進行排隊。一般,這種地點都會有一個分配器(叫號器,掛號器等等都行),先到的人須要在這個機器上取出本身如今排隊的號碼,這個號碼是按照自增的順序進行的,旁邊還會有一個標牌顯示的是正在服務的標誌,這一般是表明目前正在服務的隊列號,當前的號碼完成服務後,標誌牌會顯示下一個號碼能夠去服務了。

像上面系統同樣,TicketLock 是基於先進先出(FIFO) 隊列的機制。它增長了鎖的公平性,其設計原則以下:TicketLock 中有兩個 int 類型的數值,開始都是0,第一個值是隊列ticket(隊列票據), 第二個值是 出隊(票據)。隊列票據是線程在隊列中的位置,而出隊票據是如今持有鎖的票證的隊列位置。可能有點模糊不清,簡單來講,就是隊列票據是你取票號的位置,出隊票據是你距離叫號的位置。如今應該明白一些了吧。

當叫號叫到你的時候,不能有相同的號碼同時辦業務,必須只有一我的能夠去辦,辦完後,叫號機叫到下一我的,這就叫作原子性。你在辦業務的時候不能被其餘人所幹擾,並且不可能會有兩個持有相同號碼的人去同時辦業務。而後,下一我的看本身的號是否和叫到的號碼保持一致,若是一致的話,那麼就輪到你去辦業務,不然只能繼續等待。上面這個流程的關鍵點在於,每一個辦業務的人在辦完業務以後,他必須丟棄本身的號碼,叫號機才能繼續叫到下面的人,若是這我的沒有丟棄這個號碼,那麼其餘人只能繼續等待。下面來實現一下這個票據排隊方案

public class TicketLock {

    // 隊列票據(當前排隊號碼)
    private AtomicInteger queueNum = new AtomicInteger();

    // 出隊票據(當前需等待號碼)
    private AtomicInteger dueueNum = new AtomicInteger();

    // 獲取鎖:若是獲取成功,返回當前線程的排隊號
    public int lock(){
        int currentTicketNum = dueueNum.incrementAndGet();
        while (currentTicketNum != queueNum.get()){
            // doSomething...
        }
        return currentTicketNum;
    }

    // 釋放鎖:傳入當前排隊的號碼
    public void unLock(int ticketNum){
        queueNum.compareAndSet(ticketNum,ticketNum + 1);
    }

}

每次叫號機在叫號的時候,都會判斷本身是否是被叫的號,而且每一個人在辦完業務的時候,叫號機根據在當前號碼的基礎上 + 1,讓隊列繼續往前走。

可是上面這個設計是有問題的,由於得到本身的號碼以後,是能夠對號碼進行更改的,這就形成系統紊亂,鎖不能及時釋放。這時候就須要有一個能確保每一個人按會着本身號碼排隊辦業務的角色,在得知這一點以後,咱們從新設計一下這個邏輯

public class TicketLock2 {

    // 隊列票據(當前排隊號碼)
    private AtomicInteger queueNum = new AtomicInteger();

    // 出隊票據(當前需等待號碼)
    private AtomicInteger dueueNum = new AtomicInteger();

    private ThreadLocal<Integer> ticketLocal = new ThreadLocal<>();

    public void lock(){
        int currentTicketNum = dueueNum.incrementAndGet();

        // 獲取鎖的時候,將當前線程的排隊號保存起來
        ticketLocal.set(currentTicketNum);
        while (currentTicketNum != queueNum.get()){
            // doSomething...
        }
    }

    // 釋放鎖:從排隊緩衝池中取
    public void unLock(){
        Integer currentTicket = ticketLocal.get();
        queueNum.compareAndSet(currentTicket,currentTicket + 1);
    }

}

此次就再也不須要返回值,辦業務的時候,要將當前的這一個號碼緩存起來,在辦完業務後,須要釋放緩存的這條票據。

缺點

TicketLock 雖然解決了公平性的問題,可是多處理器系統上,每一個進程/線程佔用的處理器都在讀寫同一個變量queueNum ,每次讀寫操做都必須在多個處理器緩存之間進行緩存同步,這會致使繁重的系統總線和內存的流量,大大下降系統總體的性能。

爲了解決這個問題,MCSLock 和 CLHLock 應運而生。

CLHLock

上面說到TicketLock 是基於隊列的,那麼 CLHLock 就是基於鏈表設計的,CLH的發明人是:Craig,Landin and Hagersten,用它們各自的字母開頭命名。CLH 是一種基於鏈表的可擴展,高性能,公平的自旋鎖,申請線程只能在本地變量上自旋,它會不斷輪詢前驅的狀態,若是發現前驅釋放了鎖就結束自旋。

public class CLHLock {

    public static class CLHNode{
        private volatile boolean isLocked = true;
    }

    // 尾部節點
    private volatile CLHNode tail;
    private static final ThreadLocal<CLHNode> LOCAL = new ThreadLocal<>();
    private static final AtomicReferenceFieldUpdater<CLHLock,CLHNode> UPDATER =
            AtomicReferenceFieldUpdater.newUpdater(CLHLock.class,CLHNode.class,"tail");


    public void lock(){
        // 新建節點並將節點與當前線程保存起來
        CLHNode node = new CLHNode();
        LOCAL.set(node);

        // 將新建的節點設置爲尾部節點,並返回舊的節點(原子操做),這裏舊的節點實際上就是當前節點的前驅節點
        CLHNode preNode = UPDATER.getAndSet(this,node);
        if(preNode != null){
            // 前驅節點不爲null表示當鎖被其餘線程佔用,經過不斷輪詢判斷前驅節點的鎖標誌位等待前驅節點釋放鎖
            while (preNode.isLocked){

            }
            preNode = null;
            LOCAL.set(node);
        }
        // 若是不存在前驅節點,表示該鎖沒有被其餘線程佔用,則當前線程得到鎖
    }

    public void unlock() {
        // 獲取當前線程對應的節點
        CLHNode node = LOCAL.get();
        // 若是tail節點等於node,則將tail節點更新爲null,同時將node的lock狀態職位false,表示當前線程釋放了鎖
        if (!UPDATER.compareAndSet(this, node, null)) {
            node.isLocked = false;
        }
        node = null;
    }
}

MCSLock

MCS Spinlock 是一種基於鏈表的可擴展、高性能、公平的自旋鎖,申請線程只在本地變量上自旋,直接前驅負責通知其結束自旋,從而極大地減小了沒必要要的處理器緩存同步的次數,下降了總線和內存的開銷。MCS 來自於其發明人名字的首字母: John Mellor-Crummey 和 Michael Scott。

public class MCSLock {

    public static class MCSNode {
        volatile MCSNode next;
        volatile boolean isLocked = true;
    }

    private static final ThreadLocal<MCSNode> NODE = new ThreadLocal<>();

    // 隊列
    @SuppressWarnings("unused")
    private volatile MCSNode queue;

    private static final AtomicReferenceFieldUpdater<MCSLock,MCSNode> UPDATE =
            AtomicReferenceFieldUpdater.newUpdater(MCSLock.class,MCSNode.class,"queue");


    public void lock(){
        // 建立節點並保存到ThreadLocal中
        MCSNode currentNode = new MCSNode();
        NODE.set(currentNode);

        // 將queue設置爲當前節點,而且返回以前的節點
        MCSNode preNode = UPDATE.getAndSet(this, currentNode);
        if (preNode != null) {
            // 若是以前節點不爲null,表示鎖已經被其餘線程持有
            preNode.next = currentNode;
            // 循環判斷,直到當前節點的鎖標誌位爲false
            while (currentNode.isLocked) {
            }
        }
    }

    public void unlock() {
        MCSNode currentNode = NODE.get();
        // next爲null表示沒有正在等待獲取鎖的線程
        if (currentNode.next == null) {
            // 更新狀態並設置queue爲null
            if (UPDATE.compareAndSet(this, currentNode, null)) {
                // 若是成功了,表示queue==currentNode,即當前節點後面沒有節點了
                return;
            } else {
                // 若是不成功,表示queue!=currentNode,即當前節點後面多了一個節點,表示有線程在等待
                // 若是當前節點的後續節點爲null,則須要等待其不爲null(參考加鎖方法)
                while (currentNode.next == null) {
                }
            }
        } else {
            // 若是不爲null,表示有線程在等待獲取鎖,此時將等待線程對應的節點鎖狀態更新爲false,同時將當前線程的後繼節點設爲null
            currentNode.next.isLocked = false;
            currentNode.next = null;
        }
    }
}

CLHLock 和 MCSLock

  • 都是基於鏈表,不一樣的是CLHLock是基於隱式鏈表,沒有真正的後續節點屬性,MCSLock是顯示鏈表,有一個指向後續節點的屬性。
  • 將獲取鎖的線程狀態藉助節點(node)保存,每一個線程都有一份獨立的節點,這樣就解決了TicketLock多處理器緩存同步的問題。

多個線程併發訪問資源

鎖狀態的分類

Java 語言專門針對 synchronized 關鍵字設置了四種狀態,它們分別是:無鎖、偏向鎖、輕量級鎖和重量級鎖,可是在瞭解這些鎖以前還須要先了解一下 Java 對象頭和 Monitor。

Java 對象頭

咱們知道 synchronized 是悲觀鎖,在操做同步以前須要給資源加鎖,這把鎖就是對象頭裏面的,而Java 對象頭又是什麼呢?咱們以 Hotspot 虛擬機爲例,Hopspot 對象頭主要包括兩部分數據:Mark Word(標記字段)class Pointer(類型指針)

Mark Word:默認存儲對象的HashCode,分代年齡和鎖標誌位信息。這些信息都是與對象自身定義無關的數據,因此Mark Word被設計成一個非固定的數據結構以便在極小的空間內存存儲儘可能多的數據。它會根據對象的狀態複用本身的存儲空間,也就是說在運行期間Mark Word裏存儲的數據會隨着鎖標誌位的變化而變化。

class Point:對象指向它的類元數據的指針,虛擬機經過這個指針來肯定這個對象是哪一個類的實例。

在32位虛擬機和64位虛擬機的 Mark Word 所佔用的字節大小不同,32位虛擬機的 Mark Word 和 class Pointer 分別佔用 32bits 的字節,而 64位虛擬機的 Mark Word 和 class Pointer 佔用了64bits 的字節,下面咱們以 32位虛擬機爲例,來看一下其 Mark Word 的字節具體是如何分配的

image.png

image.png

用中文翻譯過來就是

image.png

  • 無狀態也就是無鎖的時候,對象頭開闢 25bit 的空間用來存儲對象的 hashcode ,4bit 用於存放分代年齡,1bit 用來存放是否偏向鎖的標識位,2bit 用來存放鎖標識位爲01
  • 偏向鎖 中劃分更細,仍是開闢25bit 的空間,其中23bit 用來存放線程ID,2bit 用來存放 epoch,4bit 存放分代年齡,1bit 存放是否偏向鎖標識, 0表示無鎖,1表示偏向鎖,鎖的標識位仍是01
  • 輕量級鎖中直接開闢 30bit 的空間存放指向棧中鎖記錄的指針,2bit 存放鎖的標誌位,其標誌位爲00
  • 重量級鎖中和輕量級鎖同樣,30bit 的空間用來存放指向重量級鎖的指針,2bit 存放鎖的標識位,爲11
  • GC標記開闢30bit 的內存空間卻沒有佔用,2bit 空間存放鎖標誌位爲11。

其中無鎖和偏向鎖的鎖標誌位都是01,只是在前面的1bit區分了這是無鎖狀態仍是偏向鎖狀態。

關於爲何這麼分配的內存,咱們能夠從 OpenJDK 中的markOop.hpp類中的枚舉窺出端倪

image.png
來解釋一下

  • age_bits 就是咱們說的分代回收的標識,佔用4字節
  • lock_bits 是鎖的標誌位,佔用2個字節
  • biased_lock_bits 是是否偏向鎖的標識,佔用1個字節
  • max_hash_bits 是針對無鎖計算的hashcode 佔用字節數量,若是是32位虛擬機,就是 32 - 4 - 2 -1 = 25 byte,若是是64 位虛擬機,64 - 4 - 2 - 1 = 57 byte,可是會有 25 字節未使用,因此64位的 hashcode 佔用 31 byte
  • hash_bits 是針對 64 位虛擬機來講,若是最大字節數大於 31,則取31,不然取真實的字節數
  • cms_bits 我以爲應該是否是64位虛擬機就佔用 0 byte,是64位就佔用 1byte
  • epoch_bits 就是 epoch 所佔用的字節大小,2字節。

Synchronized鎖

synchronized用的鎖記錄是存在Java對象頭裏的。

JVM基於進入和退出 Monitor 對象來實現方法同步和代碼塊同步。代碼塊同步是使用 monitorenter 和 monitorexit 指令實現的,monitorenter 指令是在編譯後插入到同步代碼塊的開始位置,而 monitorexit 是插入到方法結束處和異常處。任何對象都有一個 monitor 與之關聯,當且一個 monitor 被持有後,它將處於鎖定狀態。

根據虛擬機規範的要求,在執行 monitorenter 指令時,首先要去嘗試獲取對象的鎖,若是這個對象沒被鎖定,或者當前線程已經擁有了那個對象的鎖,把鎖的計數器加1,相應地,在執行 monitorexit 指令時會將鎖計數器減1,當計數器被減到0時,鎖就釋放了。若是獲取對象鎖失敗了,那當前線程就要阻塞等待,直到對象鎖被另外一個線程釋放爲止。

Monitor

Synchronized是經過對象內部的一個叫作監視器鎖(monitor)來實現的,監視器鎖本質又是依賴於底層的操做系統的 Mutex Lock(互斥鎖)來實現的。而操做系統實現線程之間的切換須要從用戶態轉換到核心態,這個成本很是高,狀態之間的轉換須要相對比較長的時間,這就是爲何 Synchronized 效率低的緣由。所以,這種依賴於操做系統 Mutex Lock 所實現的鎖咱們稱之爲重量級鎖

Java SE 1.6爲了減小得到鎖和釋放鎖帶來的性能消耗,引入了偏向鎖輕量級鎖:鎖一共有4種狀態,級別從低到高依次是:無鎖狀態、偏向鎖狀態、輕量級鎖狀態和重量級鎖狀態。鎖能夠升級但不能降級。

因此鎖的狀態總共有四種:無鎖狀態、偏向鎖、輕量級鎖和重量級鎖。隨着鎖的競爭,鎖能夠從偏向鎖升級到輕量級鎖,再升級的重量級鎖(可是鎖的升級是單向的,也就是說只能從低到高升級,不會出現鎖的降級)。JDK 1.6中默認是開啓偏向鎖和輕量級鎖的,咱們也能夠經過-XX:-UseBiasedLocking=false來禁用偏向鎖。

鎖的分類及其解釋

先來個大致的流程圖來感覺一下這個過程,而後下面咱們再分開來講

image.png

無鎖

無鎖狀態,無鎖即沒有對資源進行鎖定,全部的線程均可以對同一個資源進行訪問,可是隻有一個線程可以成功修改資源。

image.png

無鎖的特色就是在循環內進行修改操做,線程會不斷的嘗試修改共享資源,直到可以成功修改資源並退出,在此過程當中沒有出現衝突的發生,這很像咱們在以前文章中介紹的 CAS 實現,CAS 的原理和應用就是無鎖的實現。無鎖沒法全面代替有鎖,但無鎖在某些場合下的性能是很是高的。

偏向鎖

HotSpot 的做者通過研究發現,大多數狀況下,鎖不只不存在多線程競爭,還存在鎖由同一線程屢次得到的狀況,偏向鎖就是在這種狀況下出現的,它的出現是爲了解決只有在一個線程執行同步時提升性能。

image.png

能夠從對象頭的分配中看到,偏向鎖要比無鎖多了線程IDepoch,下面咱們就來描述一下偏向鎖的獲取過程

偏向鎖獲取過程

  1. 首先線程訪問同步代碼塊,會經過檢查對象頭 Mark Word 的鎖標誌位判斷目前鎖的狀態,若是是 01,說明就是無鎖或者偏向鎖,而後再根據是否偏向鎖 的標示判斷是無鎖仍是偏向鎖,若是是無鎖狀況下,執行下一步
  2. 線程使用 CAS 操做來嘗試對對象加鎖,若是使用 CAS 替換 ThreadID 成功,就說明是第一次上鎖,那麼當前線程就會得到對象的偏向鎖,此時會在對象頭的 Mark Word 中記錄當前線程 ID 和獲取鎖的時間 epoch 等信息,而後執行同步代碼塊。
全局安全點(Safe Point):全局安全點的理解會涉及到 C 語言底層的一些知識,這裏簡單理解 SafePoint 是 Java 代碼中的一個線程可能暫停執行的位置。

等到下一次線程在進入和退出同步代碼塊時就不須要進行 CAS 操做進行加鎖和解鎖,只須要簡單判斷一下對象頭的 Mark Word 中是否存儲着指向當前線程的線程ID,判斷的標誌固然是根據鎖的標誌位來判斷的。若是用流程圖來表示的話就是下面這樣
image.png

關閉偏向鎖

偏向鎖在Java 6 和Java 7 裏是默認啓用的。因爲偏向鎖是爲了在只有一個線程執行同步塊時提升性能,若是你肯定應用程序裏全部的鎖一般狀況下處於競爭狀態,能夠經過JVM參數關閉偏向鎖:-XX:-UseBiasedLocking=false,那麼程序默認會進入輕量級鎖狀態。

關於 epoch

偏向鎖的對象頭中有一個被稱爲 epoch 的值,它做爲誤差有效性的時間戳。

輕量級鎖

輕量級鎖是指當前鎖是偏向鎖的時候,資源被另外的線程所訪問,那麼偏向鎖就會升級爲輕量級鎖,其餘線程會經過自旋的形式嘗試獲取鎖,不會阻塞,從而提升性能,下面是詳細的獲取過程。

輕量級鎖加鎖過程

  1. 緊接着上一步,若是 CAS 操做替換 ThreadID 沒有獲取成功,執行下一步
  2. 若是使用 CAS 操做替換 ThreadID 失敗(這時候就切換到另一個線程的角度)說明該資源已被同步訪問過,這時候就會執行鎖的撤銷操做,撤銷偏向鎖,而後等原持有偏向鎖的線程到達全局安全點(SafePoint)時,會暫停原持有偏向鎖的線程,而後會檢查原持有偏向鎖的狀態,若是已經退出同步,就會喚醒持有偏向鎖的線程,執行下一步
  3. 檢查對象頭中的 Mark Word 記錄的是不是當前線程 ID,若是是,執行同步代碼,若是不是,執行偏向鎖獲取流程 的第2步。

若是用流程表示的話就是下面這樣(已經包含偏向鎖的獲取)

image.png

重量級鎖

重量級鎖的獲取流程比較複雜,小夥伴們作好準備,其實多看幾遍也沒那麼麻煩,呵呵。

重量級鎖的獲取流程

  1. 接着上面偏向鎖的獲取過程,由偏向鎖升級爲輕量級鎖,執行下一步
  2. 會在原持有偏向鎖的線程的棧中分配鎖記錄,將對象頭中的 Mark Word 拷貝到原持有偏向鎖線程的記錄中,而後原持有偏向鎖的線程得到輕量級鎖,而後喚醒原持有偏向鎖的線程,從安全點處繼續執行,執行完畢後,執行下一步,當前線程執行第4步
  3. 執行完畢後,開始輕量級解鎖操做,解鎖須要判斷兩個條件

    • 判斷對象頭中的 Mark Word 中鎖記錄指針是否指向當前棧中記錄的指針

image.png

  • 拷貝在當前線程鎖記錄的 Mark Word 信息是否與對象頭中的 Mark Word 一致。

若是上面兩個判斷條件都符合的話,就進行鎖釋放,若是其中一個條件不符合,就會釋放鎖,並喚起等待的線程,進行新一輪的鎖競爭。

  1. 在當前線程的棧中分配鎖記錄,拷貝對象頭中的 MarkWord 到當前線程的鎖記錄中,執行 CAS 加鎖操做,會把對象頭 Mark Word 中鎖記錄指針指向當前線程鎖記錄,若是成功,獲取輕量級鎖,執行同步代碼,而後執行第3步,若是不成功,執行下一步
  2. 當前線程沒有使用 CAS 成功獲取鎖,就會自旋一下子,再次嘗試獲取,若是在屢次自旋到達上限後尚未獲取到鎖,那麼輕量級鎖就會升級爲 重量級鎖

image.png

若是用流程圖表示是這樣的
image.png

鎖的公平性與非公平性

咱們知道,在併發環境中,多個線程須要對同一資源進行訪問,同一時刻只能有一個線程可以獲取到鎖並進行資源訪問,那麼剩下的這些線程怎麼辦呢?這就比如食堂排隊打飯的模型,最早到達食堂的人擁有最早買飯的權利,那麼剩下的人就須要在第一我的後面排隊,這是理想的狀況,即每一個人都可以買上飯。那麼現實狀況是,在你排隊的過程當中,就有個別不老實的人想走捷徑,插隊打飯,若是插隊的這我的後面沒有人制止他這種行爲,他就可以順利買上飯,若是有人制止,他就也得去隊伍後面排隊。

對於正常排隊的人來講,沒有人插隊,每一個人都在等待排隊打飯的機會,那麼這種方式對每一個人來講都是公平的,先來後到嘛。這種鎖也叫作公平鎖。

image.png

那麼假如插隊的這我的成功買上飯而且在買飯的過程無論有沒有人制止他,他的這種行爲對正常排隊的人來講都是不公平的,這在鎖的世界中也叫作非公平鎖。

image.png

image.png

那麼咱們根據上面的描述能夠得出下面的結論

公平鎖表示線程獲取鎖的順序是按照線程加鎖的順序來分配的,即先來先得的FIFO先進先出順序。而非公平鎖就是一種獲取鎖的搶佔機制,是隨機得到鎖的,和公平鎖不同的就是先來的不必定先獲得鎖,這個方式可能形成某些線程一直拿不到鎖,結果也就是不公平的了。

鎖公平性的實現

在 Java 中,咱們通常經過 ReetrantLock 來實現鎖的公平性

咱們分別經過兩個例子來說解一下鎖的公平性和非公平性

鎖的公平性

public class MyFairLock extends Thread{

    private ReentrantLock lock = new ReentrantLock(true);
    public void fairLock(){
        try {
            lock.lock();
            System.out.println(Thread.currentThread().getName()  + "正在持有鎖");
        }finally {
            System.out.println(Thread.currentThread().getName()  + "釋放了鎖");
            lock.unlock();
        }
    }

    public static void main(String[] args) {
        MyFairLock myFairLock = new MyFairLock();
        Runnable runnable = () -> {
            System.out.println(Thread.currentThread().getName() + "啓動");
            myFairLock.fairLock();
        };
        Thread[] thread = new Thread[10];
        for(int i = 0;i < 10;i++){
            thread[i] = new Thread(runnable);
        }
        for(int i = 0;i < 10;i++){
            thread[i].start();
        }
    }
}

咱們建立了一個 ReetrantLock,並給構造函數傳了一個 true,咱們能夠查看 ReetrantLock 的構造函數

public ReentrantLock(boolean fair) {
  sync = fair ? new FairSync() : new NonfairSync();
}

根據 JavaDoc 的註釋可知,若是是 true 的話,那麼就會建立一個 ReentrantLock 的公平鎖,而後並建立一個 FairSync ,FairSync 實際上是一個 Sync 的內部類,它的主要做用是同步對象以獲取公平鎖。

image.png

而 Sync 是 ReentrantLock 中的內部類,Sync 繼承 AbstractQueuedSynchronizer 類,AbstractQueuedSynchronizer 就是咱們常說的 AQS ,它是 JUC(java.util.concurrent) 中最重要的一個類,經過它來實現獨佔鎖和共享鎖。

abstract static class Sync extends AbstractQueuedSynchronizer {...}

也就是說,咱們把 fair 參數設置爲 true 以後,就能夠實現一個公平鎖了,是這樣嗎?咱們回到示例代碼,咱們能夠執行一下這段代碼,它的輸出是順序獲取的(礙於篇幅的緣由,這裏就暫不貼出了),也就是說咱們建立了一個公平鎖

鎖的非公平性

與公平性相對的就是非公平性,咱們經過設置 fair 參數爲 true,便實現了一個公平鎖,與之相對的,咱們把 fair 參數設置爲 false,是否是就是非公平鎖了?用事實證實一下

private ReentrantLock lock = new ReentrantLock(false);

其餘代碼不變,咱們執行一下看看輸出(部分輸出)

Thread-1啓動
Thread-4啓動
Thread-1正在持有鎖
Thread-1釋放了鎖
Thread-5啓動
Thread-6啓動
Thread-3啓動
Thread-7啓動
Thread-2啓動

能夠看到,線程的啓動並無按順序獲取,能夠看出非公平鎖對鎖的獲取是亂序的,即有一個搶佔鎖的過程。也就是說,咱們把 fair 參數設置爲 false 便實現了一個非公平鎖。

ReentrantLock 基本概述

ReentrantLock 是一把可重入鎖,也是一把互斥鎖,它具備與 synchronized 相同的方法和監視器鎖的語義,可是它比 synchronized 有更多可擴展的功能。

ReentrantLock 的可重入性是指它能夠由上次成功鎖定但還未解鎖的線程擁有。當只有一個線程嘗試加鎖時,該線程調用 lock() 方法會馬上返回成功並直接獲取鎖。若是當前線程已經擁有這把鎖,這個方法會馬上返回。可使用 isHeldByCurrentThreadgetHoldCount 進行檢查。

這個類的構造函數接受可選擇的 fairness 參數,當 fairness 設置爲 true 時,在多線程爭奪嘗試加鎖時,鎖傾向於對等待時間最長的線程訪問,這也是公平性的一種體現。不然,鎖不能保證每一個線程的訪問順序,也就是非公平鎖。與使用默認設置的程序相比,使用許多線程訪問的公平鎖的程序可能會顯示較低的整體吞吐量(即較慢;一般要慢得多)。可是獲取鎖並保證線程不會飢餓的次數比較小。不管如何請注意:鎖的公平性不能保證線程調度的公平性。所以,使用公平鎖的多線程之一可能會連續屢次得到它,而其餘活動線程沒有進行且當前未持有該鎖。這也是互斥性 的一種體現。

也要注意的 tryLock() 方法不支持公平性。若是鎖是能夠獲取的,那麼即便其餘線程等待,它仍然可以返回成功。

推薦使用下面的代碼來進行加鎖和解鎖

class MyFairLock {
  private final ReentrantLock lock = new ReentrantLock();

  public void m() {
    lock.lock();  
    try {
      // ... 
    } finally {
      lock.unlock()
    }
  }
}

ReentrantLock 鎖經過同一線程最多支持2147483647個遞歸鎖。 嘗試超過此限制會致使鎖定方法引起錯誤。

ReentrantLock 如何實現鎖公平性

咱們在上面的簡述中提到,ReentrantLock 是能夠實現鎖的公平性的,那麼原理是什麼呢?下面咱們經過其源碼來了解一下 ReentrantLock 是如何實現鎖的公平性的

跟蹤其源碼發現,調用 Lock.lock() 方法實際上是調用了 sync 的內部的方法

abstract void lock();

而 sync 是最基礎的同步控制 Lock 的類,它有公平鎖和非公平鎖的實現。它繼承 AbstractQueuedSynchronizer 即 使用 AQS 狀態表明鎖持有的數量。

lock 是抽象方法是須要被子類實現的,而繼承了 AQS 的類主要有

image.png

咱們能夠看到,全部實現了 AQS 的類都位於 JUC 包下,主要有五類:ReentrantLockReentrantReadWriteLockSemaphoreCountDownLatchThreadPoolExecutor,其中 ReentrantLock、ReentrantReadWriteLock、Semaphore 均可以實現公平鎖和非公平鎖。

下面是公平鎖 FairSync 的繼承關係

image.png

非公平鎖的NonFairSync 的繼承關係

image.png

由繼承圖能夠看到,兩個類的繼承關係都是相同的,咱們從源碼發現,公平鎖和非公平鎖的實現就是下面這段代碼的區別(下一篇文章咱們會從原理角度分析一下公平鎖和非公平鎖的實現)

image.png

經過上圖中的源代碼對比,咱們能夠明顯的看出公平鎖與非公平鎖的lock()方法惟一的區別就在於公平鎖在獲取同步狀態時多了一個限制條件:hasQueuedPredecessors()

hasQueuedPredecessors() 也是 AQS 中的方法,它主要是用來 查詢是否有任何線程在等待獲取鎖的時間比當前線程長,也就是說每一個等待線程都是在一個隊列中,此方法就是判斷隊列中在當前線程獲取鎖時,是否有等待鎖時間比本身還長的隊列,若是當前線程以前有排隊的線程,返回 true,若是當前線程位於隊列的開頭或隊列爲空,返回 false。

綜上,公平鎖就是經過同步隊列來實現多個線程按照申請鎖的順序來獲取鎖,從而實現公平的特性。非公平鎖加鎖時不考慮排隊等待問題,直接嘗試獲取鎖,因此存在後申請卻先得到鎖的狀況。

根據鎖是否可重入進行區分

可重入鎖

可重入鎖又稱爲遞歸鎖,是指在同一個線程在外層方法獲取鎖的時候,再進入該線程的內層方法會自動獲取鎖(前提鎖對象得是同一個對象或者class),不會由於以前已經獲取過還沒釋放而阻塞。Java 中 ReentrantLocksynchronized 都是可重入鎖,可重入鎖的一個優勢是在必定程度上能夠避免死鎖。

咱們先來看一段代碼來講明一下 synchronized 的可重入性

private synchronized void doSomething(){
  System.out.println("doSomething...");
  doSomethingElse();
}

private synchronized void doSomethingElse(){
  System.out.println("doSomethingElse...");
}

在上面這段代碼中,咱們對 doSomething()doSomethingElse() 分別使用了 synchronized 進行鎖定,doSomething() 方法中調用了 doSomethingElse() 方法,由於 synchronized 是可重入鎖,因此同一個線程在調用 doSomething() 方法時,也可以進入 doSomethingElse() 方法中。

不可重入鎖

若是 synchronized 是不可重入鎖的話,那麼在調用 doSomethingElse() 方法的時候,必須把 doSomething() 的鎖丟掉,實際上該對象鎖已被當前線程所持有,且沒法釋放。因此此時會出現死鎖。

也就是說,不可重入鎖會形成死鎖

多個線程可以共享同一把鎖

獨佔鎖和共享鎖

獨佔多和共享鎖通常對應 JDK 源碼的 ReentrantLock 和 ReentrantReadWriteLock 源碼來介紹獨佔鎖和共享鎖。

獨佔鎖又叫作排他鎖,是指鎖在同一時刻只能被一個線程擁有,其餘線程想要訪問資源,就會被阻塞。JDK 中 synchronized和 JUC 中 Lock 的實現類就是互斥鎖。

共享鎖指的是鎖可以被多個線程所擁有,若是某個線程對資源加上共享鎖後,則其餘線程只能對資源再加共享鎖,不能加排它鎖。得到共享鎖的線程只能讀數據,不能修改數據

image.png

咱們看到 ReentrantReadWriteLock 有兩把鎖:ReadLockWriteLock,也就是一個讀鎖一個寫鎖,合在一塊兒叫作讀寫鎖。再進一步觀察能夠發現 ReadLock 和 WriteLock 是靠內部類 Sync 實現的鎖。Sync 是繼承於 AQS 子類的,AQS 是併發的根本,這種結構在CountDownLatch、ReentrantLock、Semaphore裏面也都存在。

在 ReentrantReadWriteLock 裏面,讀鎖和寫鎖的鎖主體都是 Sync,但讀鎖和寫鎖的加鎖方式不同。讀鎖是共享鎖,寫鎖是獨享鎖。讀鎖的共享鎖可保證併發讀很是高效,而讀寫、寫讀、寫寫的過程互斥,由於讀鎖和寫鎖是分離的。因此ReentrantReadWriteLock的併發性相比通常的互斥鎖有了很大提高。

相關文章
相關標籤/搜索