【分布式鎖的演化】什么是鎖?


從本篇開始,我們來好好梳理一下Java開發中的鎖,通過一些具體簡單的例子來描述清楚從Java單體鎖到分布式鎖的演化流程。本篇我們先來看看什么是鎖,以下老貓會通過一些日常生活中的例子也說清楚鎖的概念。

描述

鎖在Java中是一個非常重要的概念,在當今的互聯網時代,尤其在各種高並發的情況下,我們更加離不開鎖。那么到底什么是鎖呢?在計算機中,鎖(lock)或者互斥(mutex)是一種同步機制,用於在有許多執行線程的環境中強制對資源的訪問限制。鎖可以強制實施排他互斥、並發控制策略。舉一個生活中的例子,大家都去超市買東西,如果我們帶了包的話,要放到儲物櫃。我們再把這個例子極端一下,假如櫃子只有一個,那么此時同時來了三個人A、B、C都要往這個櫃子里放東西。那么這個場景就是一個多線程,多線程自然也就離不開鎖。簡單示意圖如下

存儲櫃子模型

A、B、C都要往櫃子里面放東西,可是櫃子只能存放一個東西,那么怎么處理?這個時候我們就引出了鎖的概念,三個人中誰先搶到了櫃子的鎖,誰就可以使用這個櫃子,其他的人只能等待。比如C搶到了鎖,C就可以使用這個櫃子,A和B只能等待,等到C使用完畢之后,釋放了鎖,AB再進行搶鎖,誰先搶到了,誰就有使用櫃子的權利。

抽象成代碼

我們其實可以將以上場景抽象程相關的代碼模型,我們來看一下以下代碼的例子。

/**
 * @author kdaddy@163.com
 * @date 2020/11/2 23:13
 */
public class Cabinet {
    //表示櫃子中存放的數字
    private int storeNumber;

    public int getStoreNumber() {
        return storeNumber;
    }
    public void setStoreNumber(int storeNumber) {
        this.storeNumber = storeNumber;
    }
}

櫃子中存儲的是數字。

然后我們把3個用戶抽象成一個類,如下代碼

/**
 * @author kdaddy@163.com
 * @date 2020/11/7 22:03
 */
public class User {
    // 櫃子
    private Cabinet cabinet;
    // 存儲的數字
    private int storeNumber;

    public User(Cabinet cabinet, int storeNumber) {
        this.cabinet = cabinet;
        this.storeNumber = storeNumber;
    }
    // 表示使用櫃子
    public void useCabinet(){
        cabinet.setStoreNumber(storeNumber);
    }
}

在用戶的構造方法中,需要傳入兩個參數,一個是要使用的櫃子,另一個是要存儲的數字。以上我們把櫃子和用戶都已經抽象完畢,接下來我們再來寫一個啟動類,模擬一下3個用戶使用櫃子的場景。

/**
 * @author kdaddy@163.com
 * @date 2020/11/7 22:05
 */
public class Starter {
    public static void main(String[] args) {
        final Cabinet cabinet = new Cabinet();
        ExecutorService es = Executors.newFixedThreadPool(3);

        for(int i= 1; i < 4; i++){
            final int storeNumber = i;
            es.execute(()->{
                User user = new User(cabinet,storeNumber);
                user.useCabinet();
                System.out.println("我是用戶"+storeNumber+",我存儲的數字是:"+cabinet.getStoreNumber());
            });
        }
        es.shutdown();
    }
}

我們仔細的看一下這個main函數的過程

  • 首先創建一個櫃子的實例,由於場景中只有一個櫃子,所以我們只創建了一個櫃子實例。
  • 然后我們新建了一個線程池,線程池中一共有三個線程,每個線程執行一個用戶的操作。
  • 再來看看每個線程具體的執行過程,新建用戶實例,傳入的是用戶使用的櫃子,我們這里只有一個櫃子,所以傳入這個櫃子的實例,然后傳入這個用戶所需要存儲的數字,分別是1,2,3,也分別對應了用戶1,2,3。
  • 再調用使用櫃子的操作,也就是想櫃子中放入要存儲的數字,然后立刻從櫃子中取出數字,並打印出來。

我們運行一下main函數,看看得到的打印結果是什么?

我是用戶1,我存儲的數字是:3
我是用戶3,我存儲的數字是:3
我是用戶2,我存儲的數字是:2

從結果中,我們可以看出三個用戶在存儲數字的時候兩個都是3,一個是2。這是為什么呢?我們期待的應該是每個人都能獲取不同的數字才對。其實問題就是出在"user.useCabinet();"這個方法上,這是因為櫃子這個實例沒有加鎖的原因,三個用戶並行執行,向櫃子中存儲他們的數字,雖然3個用戶並行同時操作,但是在具體賦值的時候,也是有順序的,因為變量storeNumber只有一塊內存,storeNumber只存儲一個值,存儲最后的線程所設置的值。至於哪個線程排在最后,則完全不確定,賦值語句執行完成之后,進入打印語句,打印語句取storeNumber的值並打印,這時storeNumber存儲的是最后一個線程鎖所設置的值,3個線程取到的值有兩個是相同的,就像上面打印的結果一樣。

那么如何才能解決這個問題?這就需要我們用到鎖。我們再賦值語句上加鎖,這樣當多個線程(此處表示用戶)同時賦值的時候,誰能優先搶到這把鎖,誰才能夠賦值,這樣保證同一個時刻只能有一個線程進行賦值操作,避免了之前的混亂的情況。

那么在程序中,我們如何加鎖呢?

下面我們介紹一下Java中的一個關鍵字synchronized。關於這個關鍵字,其實有兩種用法。

  • synchronized方法,顧名思義就是把synchronize的關鍵字寫在方法上,它表示這個方法是加了鎖的,當多個線程同時調用這個方法的時候,只有獲得鎖的線程才能夠執行,具體如下:

    public synchronized String getTicket(){
            return "xxx";
        }
    

    以上我們可以看到getTicket()方法加了鎖,當多個線程並發執行的時候,只有獲得鎖的線程才可以執行,其他的線程只能夠等待。

  • synchronized代碼塊。如下:

    synchronized (對象鎖){
        ……
    }
    

    我們將需要加鎖的語句都寫在代碼塊中,而在對象鎖的位置,需要填寫加鎖的對象,它的含義是,當多個線程並發執行的時候,只有獲得你寫的這個對象的鎖,才能夠執行后面的語句,其他的線程只能等待。synchronized塊通常的寫法是synchronized(this),這個this是當前類的實例,也就是說獲得當前這個類的對象的鎖,才能夠執行這個方法,此寫法等同於synchronized方法。

回到剛才的例子中,我們又是如何解決storeNumber混亂的問題呢?咱們試着在方法上加上鎖,這樣保證同時只有一個線程能調用這個方法,具體如下。

/**
 * @author kdaddy@163.com
 * @date 2020/12/2 23:13
 */
public class Cabinet {
    //表示櫃子中存放的數字
    private int storeNumber;

    public int getStoreNumber() {
        return storeNumber;
    }

    public synchronized void setStoreNumber(int storeNumber) {
        this.storeNumber = storeNumber;
    }
}

我們運行一下代碼,結果如下

我是用戶2,我存儲的數字是:2
我是用戶3,我存儲的數字是:2
我是用戶1,我存儲的數字是:1

我們發現結果還是混亂的,並沒有解決問題。我們檢查一下代碼

 es.execute(()->{
                User user = new User(cabinet,storeNumber);
                user.useCabinet();
                System.out.println("我是用戶"+storeNumber+",我存儲的數是:"+cabinet.getStoreNumber());
            });

我們可以看到在useCabinet和打印的方法是兩個語句,並沒有保持原子性,雖然在set方法上加了鎖,但是在打印的時候又存在了並發,打印語句是有鎖的,但是不能確定哪個線程去執行。所以這里,我們要保證useCabinet和打印的方法的原子性,我們使用synchronized塊,但是synchronized塊里的對象我們使用誰的?這又是一個問題,user還是cabinet?回答當然是cabinet,因為每個線程都初始化了user,總共有3個User對象,而cabinet對象只有一個,所以synchronized要用cabine對象,具體代碼如下

/**
 * @author kdaddy@163.com
 * @date 2020/12/7 22:05
 */
public class Starter {
    public static void main(String[] args) {
        final Cabinet cabinet = new Cabinet();
        ExecutorService es = Executors.newFixedThreadPool(3);

        for(int i= 1; i < 4; i++){
            final int storeNumber = i;
            es.execute(()->{
                User user = new User(cabinet,storeNumber);
                synchronized (cabinet){
                    user.useCabinet();
                    System.out.println("我是用戶"+storeNumber+",我存儲的數字是:"+cabinet.getStoreNumber());
                }
            });
        }
        es.shutdown();
    }
}

此時我們再去運行一下:

我是用戶3,我存儲的數字是:3
我是用戶2,我存儲的數字是:2
我是用戶1,我存儲的數字是:1

由於我們加了synchronized塊,保證了存儲和取出的原子性,這樣用戶存儲的數字和取出的數字就對應上了,不會造成混亂,最后我們用圖來表示一下上面例子的整體情況。
最終模型

如上圖所示,線程A,線程B,線程C同時調用Cabinet類的setStoreNumber方法,線程B獲得了鎖,所以線程B可以執行setStore的方法,線程A和線程C只能等待。

總結

通過上面的場景以及例子,我們可以了解多線程情況下,造成的變量值前后不一致的問題,以及鎖的作用,在使用了鎖以后,可以避免這種混亂的現象,后續,老貓會和大家介紹一個Java中都有哪些關於鎖的解決方案,以及項目中所用到的實戰。


免責聲明!

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



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