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