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

程式設計師老貓發表於2020-12-17

前言

上一篇分散式鎖的文章中,通過超市存放物品的例子和大家簡單分享了一下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++是否是執行緒安全的,多個執行緒併發執行的時候會存在什麼問題。我們看一下下面的程式碼:

/**
 * @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,但是我們連續執行三次,得到的結果如下:

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

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

從上面的結果我們可以看到,每次結果都不同,反正也不是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其實預設的就是用到了非公平鎖。那什麼時候用到公平鎖呢?其實業務場景也是比較常見的,就是在電商秒殺的時候,公平鎖的模型就被套用上了。

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

相關文章