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


前言

上一篇分布式鎖的文章中,通過超市存放物品的例子和大家簡單分享了一下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其實默認的就是用到了非公平鎖。那什么時候用到公平鎖呢?其實業務場景也是比較常見的,就是在電商秒殺的時候,公平鎖的模型就被套用上了。

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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM