【分散式鎖的演化】什麼是鎖?

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

從本篇開始,我們來好好梳理一下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中都有哪些關於鎖的解決方案,以及專案中所用到的實戰。

相關文章