【分佈式鎖的演化】經常使用鎖的種類以及解決方案

前言

上一篇分佈式鎖的文章中,經過超市存放物品的例子和你們簡單分享了一下Java鎖。本篇文章咱們就來深刻探討一下Java鎖的種類,以及不一樣的鎖使用的場景,固然本篇只介紹咱們經常使用的鎖。咱們分爲兩大類,分別是樂觀鎖和悲觀鎖,公平鎖和非公平鎖。java

樂觀鎖和悲觀鎖

樂觀鎖

老貓相信,不少的技術人員首先接觸到的就是樂觀鎖和悲觀鎖。老貓記得那時候是在大學的時候接觸到,當時是上數據庫課程的時候。當時的應用場景主要是在更新數據的時候,固然多年工做以後,其實咱們也知道了更新數據也是使用鎖很是主要的場景之一。咱們來回顧一下通常更新的步驟:數據庫

  1. 檢索出須要更新的數據,提供給操做人查看。
  2. 操做人員更改須要修改的數值。
  3. 點擊保存,更新數據。

這個流程看似簡單,可是若是一旦多個線程同時操做的時候,就會發現其中隱藏的問題。咱們具體看一下:編程

  1. A檢索到數據;
  2. B檢索到數據;
  3. B修改了數據;
  4. A修改了數據,是否可以修改爲功呢?

上述第四點A是否可以修改爲功固然要看咱們的程序如何去實現。就從業務上來說,當A保存數據的時候,最好的方式應該系統給出提示說「當前您操做的數據已被其餘人修改,請從新查詢確認」。這種實際上是最合理的。安全

那麼這種方式咱們該如何實現呢?咱們看一下步驟:多線程

  1. 在檢索數據的時候,咱們將相關的數據的版本號(version)或者最後的更新時間一塊兒檢索出來。
  2. 當操做人員更改數據以後,點擊保存的時候在數據庫執行update操做。
  3. 當執行update操做的時候,用步驟1檢索出的版本號或者最後的更新時間和數據庫中的記錄作比較;
  4. 若是版本號或者最後更新時間一致,那麼就能夠更新。
  5. 若是不一致,咱們就拋出上述提示。

其實上述流程就是樂觀鎖的實現思路。在Java中樂觀鎖並無肯定的方法,或者關鍵字,它只是一個處理的流程、策略或者說是一種業務方案。看完這個以後咱們再看一下Java中的樂觀鎖。併發

樂觀鎖,它是假設一個線程在取數據的時候不會被其餘線程更改數據。就像上述描述相似,可是隻有在更新的時候纔會去校驗數據是否被修改過。其實這種就是咱們常常聽到的CAS機制,英文全稱(Compare And Swap),這是一種比較交換機制,一旦檢測到有衝突。它就會進行重試。直到最後沒有衝突爲止。分佈式

樂觀鎖機制圖示以下:
樂觀鎖
下面咱們來舉個例子,相信不少同窗都是C語言入門的編程,老貓也是,你們應該都接觸過i++,那麼如下咱們就用i++作例子,看看i++是不是線程安全的,多個線程併發執行的時候會存在什麼問題。咱們看一下下面的代碼:ui

/**
 * @author kdaddy@163.com
 * @date 2020/12/15 22:42
 */
public class NumCountTest {
    private int i=0;
    public static void main(String[] args) {
        NumCountTest test = new NumCountTest();
        //線程池:50個線程
        ExecutorService es = Executors.newFixedThreadPool(50);
        //閉鎖
        CountDownLatch cdl = new CountDownLatch(5000);
        for (int i = 0;i < 5000; i++){
            es.execute(()->{
                test.i++;
                cdl.countDown();
            });
        }
        es.shutdown();
        try {
            //等待5000個任務執行完成後,打印出執行結果
            cdl.await();
            System.out.println("執行完成後,i="+test.i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

上面的程序中,咱們用50個線程同時執行i++程序,總共執行5000次,按照常規的理解,獲得的應該是5000,可是咱們連續運行三次,獲得的結果以下:this

執行完成後,i=4975
執行完成後,i=4955
執行完成後,i=4968

(注:可能有小夥伴不清楚CountDownLatch,簡單說明一下,該類其實就是一個計數器,初始化的時候構造器傳了5000表示會執行5000次, 這個類使一個線程等待其餘線程各自執行完畢後再執行,cdl.countDown()這個方法指的就是將構造器參數減一。具體的能夠自行問度娘,在此老貓也是展開 )spa

從上面的結果咱們能夠看到,每次結果都不一樣,反正也不是5000,那麼這個是爲何呢?其實這就說明i++程序並非一個原子性的,多線程的狀況下存在線程安全性的問題。咱們能夠將詳細執行步驟進行一下拆分。

  1. 從內存中取出i的值
  2. 將i的值+1
  3. 將計算完畢的i從新放入到內存中

其實這個流程和咱們以前說到的數據的流程是同樣的。只不過是介質不一樣,一個是內存,另外一個是數據庫。在多個線程的狀況下,咱們想象一下,假如A線程和B線程同時同內存中取出i的值,假如i的值都是50,而後兩個線程都同時進行了+1的操做,而後在放入到內存中,這時候內存的值是51,可是咱們期待的是52。這其實就是上述爲何一直沒法達到5000的緣由。那麼咱們如何解決這個問題?其實在Java1.5以後,JDK的官網提供了大量的原子類,這些類的內部都是基於CAS機制的,也就是說使用了樂觀鎖。咱們更改一下代碼,以下:

/**
 * @author kdaddy@163.com
 * @date 2020/12/15 22:42
 */
public class NumCountTest {
    private AtomicInteger i= new AtomicInteger(0);
    public static void main(String[] args) {
        NumCountTest test = new NumCountTest();
        //線程池:50個線程
        ExecutorService es = Executors.newFixedThreadPool(50);
        //閉鎖
        CountDownLatch cdl = new CountDownLatch(5000);
        for (int i = 0;i < 5000; i++){
            es.execute(()->{
                test.i.incrementAndGet();
                cdl.countDown();
            });
        }
        es.shutdown();
        try {
            //等待5000個任務執行完成後,打印出執行結果
            cdl.await();
            System.out.println("執行完成後,i="+test.i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

此時咱們獲得的結果以下,執行三次:

執行完成後,i=5000
執行完成後,i=5000
執行完成後,i=5000

結果看來是咱們所期待的,以上的改造咱們能夠看到,咱們將原來int類型的變量更改爲了 AtomicInteger,該類是一個原子類屬於concurrent包(有興趣的小夥伴能夠研究一下這個包下面的一些類)咱們將原來的i++的地方改爲了test.i.incrementAndGet(),incrementAndGet這個方法採用得了CAS機制。也就是說採用了樂觀鎖,因此咱們以上的結果是正確的。

咱們對樂觀鎖進行一下總結,其實樂觀鎖就是在讀取數據的時候不加任何限制條件,可是在更新數據的時候,進行數據的比較,保證數據版本的一致以後採起更新相關的數據信息。因爲這個特色,因此咱們很容易能夠看出樂觀鎖比較試用於讀操做大於寫操做的場景中。

悲觀鎖

咱們再一塊兒看一下悲觀鎖,也是經過這個例子來講明一下。悲觀鎖其實和樂觀鎖不一樣,悲觀鎖從讀取數據的時候就顯示地去加鎖,直到數據最後更新完成以後,鎖纔會被釋放。這個期間只能由一個線程去操做。其餘線程只能等待。其實上一篇文章中咱們就用到了 synchronized關鍵字 ,其實這個關鍵字就是悲觀鎖。與其相同的其實還有ReentrantLock類也能夠實現悲觀鎖。那麼如下咱們再使用synchronized關鍵字 和 ReentrantLock進行悲觀鎖的改造。具體代碼以下:

/**
 * @author kdaddy@163.com
 * @date 2020/12/15 22:42
 */
public class NumCountTest {
    private int i= 0;
    public static void main(String[] args) {
        NumCountTest test = new NumCountTest();
        //線程池:50個線程
        ExecutorService es = Executors.newFixedThreadPool(50);
        //閉鎖
        CountDownLatch cdl = new CountDownLatch(5000);
        for (int i = 0;i < 5000; i++){
            es.execute(()->{
                synchronized (test){
                     test.i++;
                }
                cdl.countDown();
            });
        }
        es.shutdown();
        try {
            //等待5000個任務執行完成後,打印出執行結果
            cdl.await();
            System.out.println("執行完成後,i="+test.i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

以上咱們的改動就是新增了synchronized代碼塊,它鎖住了test的對象,在全部的線程中,誰獲取到了test的對象,誰就能執行i++操做(此處鎖test是由於test只有一個)。這樣咱們採用了悲觀鎖的方式咱們的結果固然也是OK的執行完畢以後三次輸出以下:

執行完成後,i=5000
執行完成後,i=5000
執行完成後,i=5000

再看一下ReentrantLock類實現悲觀鎖,代碼以下:

/**
 * @author kdaddy@163.com
 * @date 2020/12/15 22:42
 */
public class NumCountTest {
    private int i= 0;
    Lock lock = new ReentrantLock();
    public static void main(String[] args) {
        NumCountTest test = new NumCountTest();
        //線程池:50個線程
        ExecutorService es = Executors.newFixedThreadPool(50);
        //閉鎖
        CountDownLatch cdl = new CountDownLatch(5000);
        for (int i = 0;i < 5000; i++){
            es.execute(()->{
                test.lock.lock();
                test.i++;
                test.lock.unlock();
                cdl.countDown();
            });
        }
        es.shutdown();
        try {
            //等待5000個任務執行完成後,打印出執行結果
            cdl.await();
            System.out.println("執行完成後,i="+test.i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

用法如上,其實也不用太多介紹,小夥伴們看代碼便可,上述經過lock加鎖,經過unlock釋放鎖。固然咱們三次執行完畢以後結果也是OK的。

執行完成後,i=5000
執行完成後,i=5000
執行完成後,i=5000

三次執行下來都是5000,徹底沒有問題。

咱們再來總結一下悲觀鎖,悲觀鎖其實就是從讀取數據的那一刻就加了鎖,並且在更新數據的時候,保證只有一個線程在執行更新操做,並無如樂觀鎖那種進行數據版本的比較。因此可想而知,悲觀鎖適用於讀取相對少,寫相對多的操做中。

公平鎖和非公平鎖

前面和小夥伴們分享了樂觀鎖和悲觀鎖,下面咱們就來從另一個維度去認識一下鎖。公平鎖和非公平鎖。顧名思義,公平鎖在多線程的狀況下,對待每一個線程都是公平的,然而非公平鎖確是偏偏相反的。就光這麼和小夥伴們同步,估計你們還會有點迷糊。咱們仍是以以前的儲物櫃來講明,去超市買東西,儲物櫃只有一個,正好有A、B、C三我的想要用櫃子,這時候A來的比較早,因此B和C自覺進行排隊,A用完以後,後面排着隊的B纔會去使用,這就是公平鎖。在公平鎖中,全部的線程都會自覺排隊,一個線程執行完畢以後,後續的線程在依次進行執行。

然而非公平鎖則否則,當A使用完畢以後,A將鑰匙日後面的一羣人中一丟,誰先搶到,誰就可使用。咱們大概能夠用如下兩個示意圖來體現,以下:
公平鎖
對應的多線程中,線程A先搶到了鎖,A就能夠執行方法,其餘的線程則在隊列中進行排隊,A執行完畢以後,會從隊列中獲取下一個B進行執行,依次類推,對於每一個線程來講都是公平的,不存在後加入的線程先執行的狀況。
非公平鎖
多線程同時執行方法的時候,線程A搶到了鎖,線程A先執行方法,其餘線程並無排隊。當A執行完畢以後,其餘的線程誰搶到了鎖,誰就能執行方法。這樣就可能存在後加入的線程,反而先拿到鎖。

關於公平鎖和非公平鎖,其實在咱們的ReentrantLock類中就已經給出了實現,咱們來看一下源碼:

/**
     * Creates an instance of {@code ReentrantLock}.
     * This is equivalent to using {@code ReentrantLock(false)}.
     */
    public ReentrantLock() {
        sync = new NonfairSync();
    }

    /**
     * Creates an instance of {@code ReentrantLock} with the
     * given fairness policy.
     *
     * @param fair {@code true} if this lock should use a fair ordering policy
     */
    public ReentrantLock(boolean fair) {
        sync = fair ? new FairSync() : new NonfairSync();
    }

該類中有兩個構造方法,從字面上來看默認的構造方法中 sync = new NonfairSync()是一個非公平鎖。再看看第二個構造方法,須要傳入一個參數,true是的時候是公平鎖,false的時候是非公平鎖。以上咱們能夠看到sync有兩個實現類,分別是FairSync以及NonfairSync,咱們再來看一下獲取鎖的核心方法。

獲取公平鎖:

@ReservedStackAccess
protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (!hasQueuedPredecessors() &&
            compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0)
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

非公平鎖:

@ReservedStackAccess
final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    else if (current == getExclusiveOwnerThread()) {
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

以上兩個方法,咱們很容易就能發現惟一的不一樣點就是 !hasQueuedPredecessors() 這個方法,從名字上來看就知道這個是一個隊列,所以咱們也就能夠推斷,公平鎖是將全部的線程放到一個隊列中,一個線程執行完成以後,從隊列中區所下一個線程。而非公平鎖則沒有這樣的隊列。這些就是公平鎖和非公平鎖的實現原理。這裏也不去再深刻去看源碼了,咱們重點是瞭解公平鎖和非公平鎖的含義。咱們在使用的時候傳入true或者false便可。

總結

其實在Java中鎖的種類很是的多,在此老貓只介紹了經常使用的幾種,有興趣的小夥伴其實還能夠去鑽研一下獨享鎖、共享鎖、互斥鎖、讀寫鎖、可重入鎖、分段鎖等等。

樂觀鎖和非樂觀鎖是最基礎的,咱們在工做中確定接觸的也比較多。

從公平非公平鎖的角度,你們若是用到ReetrantLock其實默認的就是用到了非公平鎖。那何時用到公平鎖呢?其實業務場景也是比較常見的,就是在電商秒殺的時候,公平鎖的模型就被套用上了。

再往下寫估計你們就不想看了,因此此篇幅到此結束了,後續陸陸續續會和你們分享分佈式鎖的演化過程,以及分佈式鎖的實現,敬請期待。

相關文章
相關標籤/搜索