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

前言

上一篇分布式鎖的文章中,通過超市存放物品的例子和大家簡單分享了一下Java鎖。本篇文章我們就來深入探討一下Java鎖的種類,以及不同的鎖使用的場景,當(dāng)然本篇只介紹我們常用的鎖。我們分為兩大類,分別是樂觀鎖和悲觀鎖,公平鎖和非公平鎖。

樂觀鎖和悲觀鎖

樂觀鎖

老貓相信,很多的技術(shù)人員首先接觸到的就是樂觀鎖和悲觀鎖。老貓記得那時候是在大學(xué)的時候接觸到,當(dāng)時是上數(shù)據(jù)庫課程的時候。當(dāng)時的應(yīng)用場景主要是在更新數(shù)據(jù)的時候,當(dāng)然多年工作之后,其實我們也知道了更新數(shù)據(jù)也是使用鎖非常主要的場景之一。我們來回顧一下一般更新的步驟:

  1. 檢索出需要更新的數(shù)據(jù),提供給操作人查看。

  2. 操作人員更改需要修改的數(shù)值。

  3. 點擊保存,更新數(shù)據(jù)。

這個流程看似簡單,但是如果一旦多個線程同時操作的時候,就會發(fā)現(xiàn)其中隱藏的問題。我們具體看一下:

  1. A檢索到數(shù)據(jù);
  2. B檢索到數(shù)據(jù);
  3. B修改了數(shù)據(jù);
  4. A修改了數(shù)據(jù),是否能夠修改成功呢?

上述第四點A是否能夠修改成功當(dāng)然要看我們的程序如何去實現(xiàn)。就從業(yè)務(wù)上來講,當(dāng)A保存數(shù)據(jù)的時候,最好的方式應(yīng)該系統(tǒng)給出提示說“當(dāng)前您操作的數(shù)據(jù)已被其他人修改,請重新查詢確認(rèn)”。這種其實是最合理的。

那么這種方式我們該如何實現(xiàn)呢?我們看一下步驟:

  1. 在檢索數(shù)據(jù)的時候,我們將相關(guān)的數(shù)據(jù)的版本號(version)或者最后的更新時間一起檢索出來。
  2. 當(dāng)操作人員更改數(shù)據(jù)之后,點擊保存的時候在數(shù)據(jù)庫執(zhí)行update操作。
  3. 當(dāng)執(zhí)行update操作的時候,用步驟1檢索出的版本號或者最后的更新時間和數(shù)據(jù)庫中的記錄做比較;
  4. 如果版本號或者最后更新時間一致,那么就可以更新。
  5. 如果不一致,我們就拋出上述提示。

其實上述流程就是樂觀鎖的實現(xiàn)思路。在Java中樂觀鎖并沒有確定的方法,或者關(guān)鍵字,它只是一個處理的流程、策略或者說是一種業(yè)務(wù)方案??赐赀@個之后我們再看一下Java中的樂觀鎖。

樂觀鎖,它是假設(shè)一個線程在取數(shù)據(jù)的時候不會被其他線程更改數(shù)據(jù)。就像上述描述類似,但是只有在更新的時候才會去校驗數(shù)據(jù)是否被修改過。其實這種就是我們經(jīng)常聽到的CAS機制,英文全稱(Compare And Swap),這是一種比較交換機制,一旦檢測到有沖突。它就會進(jìn)行重試。直到最后沒有沖突為止。

樂觀鎖機制圖示如下:


樂觀鎖

下面我們來舉個例子,相信很多同學(xué)都是C語言入門的編程,老貓也是,大家應(yīng)該都接觸過i++,那么以下我們就用i++做例子,看看i++是否是線程安全的,多個線程并發(fā)執(zhí)行的時候會存在什么問題。我們看一下下面的代碼:

/**
 * @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個任務(wù)執(zhí)行完成后,打印出執(zhí)行結(jié)果
            cdl.await();
            System.out.println("執(zhí)行完成后,i="+test.i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

上面的程序中,我們用50個線程同時執(zhí)行i++程序,總共執(zhí)行5000次,按照常規(guī)的理解,得到的應(yīng)該是5000,但是我們連續(xù)運行三次,得到的結(jié)果如下:

執(zhí)行完成后,i=4975
執(zhí)行完成后,i=4955
執(zhí)行完成后,i=4968

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

從上面的結(jié)果我們可以看到,每次結(jié)果都不同,反正也不是5000,那么這個是為什么呢?其實這就說明i++程序并不是一個原子性的,多線程的情況下存在線程安全性的問題。我們可以將詳細(xì)執(zhí)行步驟進(jìn)行一下拆分。

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

其實這個流程和我們之前說到的數(shù)據(jù)的流程是一樣的。只不過是介質(zhì)不同,一個是內(nèi)存,另一個是數(shù)據(jù)庫。在多個線程的情況下,我們想象一下,假如A線程和B線程同時同內(nèi)存中取出i的值,假如i的值都是50,然后兩個線程都同時進(jìn)行了+1的操作,然后在放入到內(nèi)存中,這時候內(nèi)存的值是51,但是我們期待的是52。這其實就是上述為什么一直無法達(dá)到5000的原因。那么我們?nèi)绾谓鉀Q這個問題?其實在Java1.5之后,JDK的官網(wǎng)提供了大量的原子類,這些類的內(nèi)部都是基于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個任務(wù)執(zhí)行完成后,打印出執(zhí)行結(jié)果
            cdl.await();
            System.out.println("執(zhí)行完成后,i="+test.i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

此時我們得到的結(jié)果如下,執(zhí)行三次:

執(zhí)行完成后,i=5000
執(zhí)行完成后,i=5000
執(zhí)行完成后,i=5000

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

我們對樂觀鎖進(jìn)行一下總結(jié),其實樂觀鎖就是在讀取數(shù)據(jù)的時候不加任何限制條件,但是在更新數(shù)據(jù)的時候,進(jìn)行數(shù)據(jù)的比較,保證數(shù)據(jù)版本的一致之后采取更新相關(guān)的數(shù)據(jù)信息。由于這個特點,所以我們很容易可以看出樂觀鎖比較試用于讀操作大于寫操作的場景中。

悲觀鎖

我們再一起看一下悲觀鎖,也是通過這個例子來說明一下。悲觀鎖其實和樂觀鎖不同,悲觀鎖從讀取數(shù)據(jù)的時候就顯示地去加鎖,直到數(shù)據(jù)最后更新完成之后,鎖才會被釋放。這個期間只能由一個線程去操作。其他線程只能等待。其實上一篇文章中我們就用到了 synchronized關(guān)鍵字 ,其實這個關(guān)鍵字就是悲觀鎖。與其相同的其實還有ReentrantLock類也可以實現(xiàn)悲觀鎖。那么以下我們再使用synchronized關(guān)鍵字 和 ReentrantLock進(jìn)行悲觀鎖的改造。具體代碼如下:

/**
 * @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個任務(wù)執(zhí)行完成后,打印出執(zhí)行結(jié)果
            cdl.await();
            System.out.println("執(zhí)行完成后,i="+test.i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

以上我們的改動就是新增了synchronized代碼塊,它鎖住了test的對象,在所有的線程中,誰獲取到了test的對象,誰就能執(zhí)行i++操作(此處鎖test是因為test只有一個)。這樣我們采用了悲觀鎖的方式我們的結(jié)果當(dāng)然也是OK的執(zhí)行完畢之后三次輸出如下:

執(zhí)行完成后,i=5000
執(zhí)行完成后,i=5000
執(zhí)行完成后,i=5000

再看一下ReentrantLock類實現(xiàn)悲觀鎖,代碼如下:

/**
 * @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個任務(wù)執(zhí)行完成后,打印出執(zhí)行結(jié)果
            cdl.await();
            System.out.println("執(zhí)行完成后,i="+test.i);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

用法如上,其實也不用太多介紹,小伙伴們看代碼即可,上述通過lock加鎖,通過unlock釋放鎖。當(dāng)然我們?nèi)螆?zhí)行完畢之后結(jié)果也是OK的。

執(zhí)行完成后,i=5000
執(zhí)行完成后,i=5000
執(zhí)行完成后,i=5000

三次執(zhí)行下來都是5000,完全沒有問題。

我們再來總結(jié)一下悲觀鎖,悲觀鎖其實就是從讀取數(shù)據(jù)的那一刻就加了鎖,而且在更新數(shù)據(jù)的時候,保證只有一個線程在執(zhí)行更新操作,并沒有如樂觀鎖那種進(jìn)行數(shù)據(jù)版本的比較。所以可想而知,悲觀鎖適用于讀取相對少,寫相對多的操作中。

公平鎖和非公平鎖

前面和小伙伴們分享了樂觀鎖和悲觀鎖,下面我們就來從另外一個維度去認(rèn)識一下鎖。公平鎖和非公平鎖。顧名思義,公平鎖在多線程的情況下,對待每個線程都是公平的,然而非公平鎖確是恰恰相反的。就光這么和小伙伴們同步,估計大家還會有點迷糊。我們還是以之前的儲物柜來說明,去超市買東西,儲物柜只有一個,正好有A、B、C三個人想要用柜子,這時候A來的比較早,所以B和C自覺進(jìn)行排隊,A用完之后,后面排著隊的B才會去使用,這就是公平鎖。在公平鎖中,所有的線程都會自覺排隊,一個線程執(zhí)行完畢之后,后續(xù)的線程在依次進(jìn)行執(zhí)行。

然而非公平鎖則不然,當(dāng)A使用完畢之后,A將鑰匙往后面的一群人中一丟,誰先搶到,誰就可以使用。我們大概可以用以下兩個示意圖來體現(xiàn),如下:


公平鎖

對應(yīng)的多線程中,線程A先搶到了鎖,A就可以執(zhí)行方法,其他的線程則在隊列中進(jìn)行排隊,A執(zhí)行完畢之后,會從隊列中獲取下一個B進(jìn)行執(zhí)行,依次類推,對于每個線程來說都是公平的,不存在后加入的線程先執(zhí)行的情況。


非公平鎖

多線程同時執(zhí)行方法的時候,線程A搶到了鎖,線程A先執(zhí)行方法,其他線程并沒有排隊。當(dāng)A執(zhí)行完畢之后,其他的線程誰搶到了鎖,誰就能執(zhí)行方法。這樣就可能存在后加入的線程,反而先拿到鎖。

關(guān)于公平鎖和非公平鎖,其實在我們的ReentrantLock類中就已經(jīng)給出了實現(xiàn),我們來看一下源碼:

 /**
     * 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();
    }

該類中有兩個構(gòu)造方法,從字面上來看默認(rèn)的構(gòu)造方法中 sync = new NonfairSync()是一個非公平鎖。再看看第二個構(gòu)造方法,需要傳入一個參數(shù),true是的時候是公平鎖,false的時候是非公平鎖。以上我們可以看到sync有兩個實現(xiàn)類,分別是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;
}

以上兩個方法,我們很容易就能發(fā)現(xiàn)唯一的不同點就是 !hasQueuedPredecessors() 這個方法,從名字上來看就知道這個是一個隊列,因此我們也就可以推斷,公平鎖是將所有的線程放到一個隊列中,一個線程執(zhí)行完成之后,從隊列中區(qū)所下一個線程。而非公平鎖則沒有這樣的隊列。這些就是公平鎖和非公平鎖的實現(xiàn)原理。這里也不去再深入去看源碼了,我們重點是了解公平鎖和非公平鎖的含義。我們在使用的時候傳入true或者false即可。

總結(jié)

其實在Java中鎖的種類非常的多,在此老貓只介紹了常用的幾種,有興趣的小伙伴其實還可以去鉆研一下獨享鎖、共享鎖、互斥鎖、讀寫鎖、可重入鎖、分段鎖等等。

樂觀鎖和非樂觀鎖是最基礎(chǔ)的,我們在工作中肯定接觸的也比較多。

從公平非公平鎖的角度,大家如果用到ReetrantLock其實默認(rèn)的就是用到了非公平鎖。那什么時候用到公平鎖呢?其實業(yè)務(wù)場景也是比較常見的,就是在電商秒殺的時候,公平鎖的模型就被套用上了。

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

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時請結(jié)合常識與多方信息審慎甄別。
平臺聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點,簡書系信息發(fā)布平臺,僅提供信息存儲服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

  • 如未做特殊說明,本文均為原創(chuàng),轉(zhuǎn)載請注明出處。 [TOC] 前言為什么要使用分布式鎖呢? 在Nginx實現(xiàn)負(fù)載均衡...
    小安的大情調(diào)閱讀 4,031評論 0 10
  • 前言 在上一節(jié)中,我們給大家介紹了什么是鎖,以及鎖的使用場景,我相信大家對鎖的定義,以及鎖的重要性都有了比較清晰的...
    牛初九閱讀 382評論 0 1
  • 我們知道分布式鎖的特性是排他、避免死鎖、高可用。分布式鎖的實現(xiàn)可以通過數(shù)據(jù)庫的樂觀鎖(通過版本號)或者悲觀鎖(通過...
    cmazxiaoma閱讀 34,268評論 7 29
  • 我們知道分布式鎖的特性是排他、避免死鎖、高可用。分布式鎖的實現(xiàn)可以通過數(shù)據(jù)庫的樂觀鎖(通過版本號)或者悲觀鎖(通過...
    Java大生閱讀 454評論 0 0
  • 夜鶯2517閱讀 128,155評論 1 9

友情鏈接更多精彩內(nèi)容