面試必問:樂觀鎖與悲觀鎖

前言

小孩子才做選擇,我全都要,今天寫一下面試必問的內容:樂觀鎖與悲觀鎖。主要從以下幾方面來說:

  • 何爲樂觀鎖
  • 何爲悲觀鎖
  • 樂觀鎖常用實現方式
  • 悲觀鎖常用實現方式
  • 樂觀鎖的缺點
  • 悲觀鎖的缺點

寫文章的時候突然收到朋友發來的消息,說烏茲退役了,LPL0006號選手斷開連接。願你鮮衣怒馬,一日看盡長安花,歷盡山河萬里,歸來仍是曾經那個少年。來,跟我一起喊一句:大道至簡-唯我自豪

1、何爲樂觀鎖

樂觀鎖總是假設事情向着好的方向發展,就比如有些人天生樂觀,向陽而生!

樂觀鎖總是假設最好的情況,每次去拿數據的時候都認爲別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據。樂觀鎖適用於多讀的應用類型,因爲樂觀鎖在讀取數據的時候不會去加鎖,這樣可以省去了鎖的開銷,加大了系統的整個吞吐量。即時偶爾有衝突,這也無傷大雅,要麼重新嘗試提交要麼返回給用戶說跟新失敗,當然,前提是偶爾發生衝突,但如果經常產生衝突,上層應用會不斷的進行自旋重試,這樣反倒是降低了性能,得不償失。

2、何爲悲觀鎖

悲觀鎖總是假設事情向着壞的方向發展,就比如有些人經歷了某些事情,可能不太相信別人,只信任自己,身在黑暗,腳踩光明!

悲觀鎖每次去拿數據的時候都認爲別人會修改,所以每次在拿數據的時候都會上鎖,這樣別人想拿這個數據就會阻塞住,直到我釋放了鎖,別人才能拿到鎖,這樣的話,數據只有本身一個線程在修改,就確保了數據的準確性。因此,悲觀鎖適用於多寫的應用類型。

3、樂觀鎖常用實現方式

3.1 版本號機制

版本號機制就是在表中增加一個字段,version,在修改記錄的時候,先查詢出記錄,再每次修改的時候給這個字段值加1,判斷條件就是你剛纔查詢出來的值。看下面流程就明白了:

  • 3.1.1 新增用戶信息表
CREATE TABLE `user_info` (
  `id` bigint(20NOT NULL AUTO_INCREMENT COMMENT '主鍵ID',
  `user_name` varchar(64DEFAULT NULL COMMENT '用戶姓名',
  `money` decimal(15,0DEFAULT '0' COMMENT '剩餘金額(分)',
  `version` bigint(20DEFAULT '1' COMMENT '版本號',
  PRIMARY KEY (`id`USING BTREE
ENGINE=InnoDB COMMENT='用戶信息表';
  • 3.1.2 新增一條數據
INSERT INTO `user_info` (`user_name``money``version`VALUES ('張三'10001);
  • 3.1.3 操作步驟
步驟 線程A 線程B
1 查詢張三數據,獲得版本號爲1(SELECT * FROM user_info WHERE user_name = '張三';)
2 查詢張三數據,獲得版本號爲1(SELECT * FROM user_info WHERE user_name = '張三';)
3 修改張三金額,增加100,版本號+1(UPDATE user_info SET money = money + 100, version = version + 1 WHERE user_name = '張三' AND version = 1;),返回修改條數爲1
4 修改張三金額,增加200,版本號+1(UPDATE user_info SET money = money + 200, version = version + 1 WHERE user_name = '張三' AND version = 1;),返回修改條數爲0
5 判斷修改條數爲是否爲0,是返回失敗,否則返回成功
6 判斷修改條數爲是否爲0,是返回失敗,否則返回成功
7 返回成功
8 返回失敗

3.2 CAS算法

CAS即 compare and swap(比較與交換),是一種有名的無鎖算法。無鎖編程,即不使用鎖(沒有線程被阻塞)的情況下實現多線程之間的變量同步,所以也叫非阻塞同步(Non-blocking Synchronization)。CAS 算法涉及到三個操作數:

  1. 需要讀寫的內存值 V(主內存中的變量值)
  2. 進行比較的值 A(克隆下來線程本地內存中的變量值)
  3. 擬寫入的新值 B(要更新的新值)

當且僅當 V 的值等於 A 時,CAS 通過原子方式用新值 B 來更新 V 的值,否則不會執行任何操作(比較和替換是一個 native 原子操作)。一般情況下,這是一個自旋操作,即不斷的重試,看下面流程:

  • 3.2.1 CAS算法模擬數據庫更新數據(表還是剛纔那個表,用戶張三的金額初始值爲1000),給用戶張三的金額增加100:
private void updateMoney(String userName){
     // 死循環
     for (;;){
         // 獲取張三的金額
         BigDecimal money = this.userMapper.getMoneyByName(userName);
         User user = new User();
         user.setMoney(money);
         user.setUserName(userName);
         // 根據書名和版本號進行閱讀量更新
         Integer updateCount = this.userMapper.updateMoneyByNameAndMoney(user);
         if (updateCount != null && updateCount.equals(1)){
             // 如果更新成功就跳出循環
             break;
         }
     }
 }
  • 3.2.2 流程圖如下:
步驟 線程A 線程B
1 從表中查詢出張三的money=1000,設置進行比較的值爲1000,要寫入的新值爲money + 100 = 1100(V:1000--A:1000--B:1100)
2 從表中查詢出張三的money=1000,設置進行比較的值爲1000,要寫入的新值爲money + 100 = 1100(V:1000--A:1000--B:1100)
3 更新張三的金額(UPDATE user_info SET money = money + 100 WHERE user_name = '張三' AND money = 1000;),返回更新條數爲1
4 更新張三的金額(UPDATE user_info SET money = money + 100 WHERE user_name = '張三' AND money = 1000;),返回更新條數爲0
5 跳出循環,返回更新成功
6 自旋再次從表中查詢出張三的money=1100,設置進行比較的值爲1100,要寫入的新值爲money + 100 = 1200(V:1100--A:1100--B:1200)
7 更新張三的金額(UPDATE user_info SET money = money + 100 WHERE user_name = '張三' AND money = 1100;),返回更新條數爲1
8 跳出循環,返回更新成功

看到這裏,明眼人都發現了一些CAS更新的小問題,至於是什麼問題呢、怎麼解決呢,放在下面來講,要不然下面幾條就沒得寫了。。。。。。

注意,這裏的加版本號機制和CAS出現ABA問題加版本號解決機制不是同一個。

4、悲觀鎖常用實現方式

4.1 ReentrantLock

可重入鎖就是悲觀鎖的一種,如果你看過前兩篇文章,對可重入鎖的原理就很清楚了,不清楚的話就看下如下的流程:

  • 假設同步狀態值爲0表示未加鎖,爲1加鎖成功
步驟 線程A 線程B
1 從主內存中克隆出同步狀態值爲0,設置進行比較的值爲0,要寫入的新值爲1(V:0--A:0--B:1)
2 從主內存中克隆出同步狀態值爲0,設置進行比較的值爲0,要寫入的新值爲1(V:0--A:0--B:1)
3 更新主內存,用A和主內存的值比較,0 = 0,加鎖成功,此時主內存值爲1
4 更新主內存,用A和主內存的值比較,0 != 1,加鎖失敗。
5 返回加鎖成功
6 執行業務邏輯 自旋再次嘗試更新主內存,用A和主內存的值比較,0 != 1,加鎖失敗
7 自旋再次嘗試更新主內存,用A和主內存的值比較,0 != 1,加鎖失敗
8 自旋再次嘗試更新主內存,用A和主內存的值比較,0 != 1,加鎖失敗
9 釋放鎖,設置同步狀態值爲0
10 自旋再次嘗試更新主內存,用A和主內存的值比較,0 = 0,加鎖成功,此時主內存值爲1

可以看到,只要線程A獲取了鎖,還沒釋放的話,線程B是無法獲取鎖的,除非A釋放了鎖,B才能獲取到鎖,加鎖的方式都是通過CAS去比較再交換,B會一直自旋去CAS,除非線程中斷或者獲取到了鎖,要不然就一直在自旋,這也就說明了爲啥悲觀鎖比起樂觀鎖來說更加消耗性能。

4.2 synchronized

其實和上面差不多的,只不過上面自身維護了一個volatile int類型的變量,用來描述獲取鎖與釋放鎖,而synchronized是靠指令判斷加鎖與釋放鎖的,如下代碼:

public class synchronizedTest {
  
    。。。。。。

    public void synchronizedTest(){
        synchronized (this){
            mapper.updateMoneyByName("張三");
        }
    }
}

上面代碼對應的流程圖如下:

步驟 線程A 線程B
1 調用synchronizedTest()方法
2 調用synchronizedTest()方法
3 插入monitorenter指令
4 執行業務邏輯 嘗試獲取monitorenter指令的所有權
5 執行業務邏輯 嘗試獲取monitorenter指令的所有權
6 執行業務邏輯 嘗試獲取monitorenter指令的所有權
7 業務邏輯執行完畢,插入monitorexit指令 嘗試獲取monitorenter指令的所有權,獲取成功,插入monitorenter指令
8 執行業務邏輯
9 執行業務邏輯
10 業務邏輯執行完畢,插入monitorexit指令

如果在某個線程執行synchronizedTest()方法的過程中出現了異常,monitorexit指令會插入在異常處,ReentrantLock需要你手動去加鎖與釋放鎖,而synchronized是JVM來幫你加鎖和釋放鎖。

5、樂觀鎖的缺點

5.1.1 ABA 問題

上面在說樂觀鎖用CAS方式實現的時候有個問題,明眼人能發現的,不知道各位有沒有發現,問題如下:

步驟 線程A 線程B
1 從表中查詢出張三的money=1000,設置進行比較的值爲1000,要寫入的新值爲money + 100 = 1100(V:1000--A:1000--B:1100)
2 從表中查詢出張三的money=1000,設置進行比較的值爲1000,要寫入的新值爲money + 100 = 1100(V:1000--A:1000--B:1100)
3 更新張三的金額(UPDATE user_info SET money = money + 100 WHERE user_name = '張三' AND money = 1000;),返回更新條數爲1
4 更新張三的金額(UPDATE user_info SET money = money + 100 WHERE user_name = '張三' AND money = 1000;),返回更新條數爲0
5 跳出循環,返回更新成功
6 自旋再次從表中查詢出張三的money=1100,設置進行比較的值爲1100,要寫入的新值爲money + 100 = 1200(V:1100--A:1100--B:1200)
7 更新張三的金額(UPDATE user_info SET money = money + 100 WHERE user_name = '張三' AND money = 1100;),返回更新條數爲1(注意,問題在這裏,在步驟6,我們查詢到money=1100,而我們在這裏判斷的時候,能確定money沒有被別的線程修改過嗎?答案是並不能,有可線程能C加了100,線程D減了100,而這裏的money值仍然是1100,這個問題被稱爲CAS操作的 "ABA"問題
8 跳出循環,返回更新成功
  • 解決方案:

給表增加一個version字段,每修改一次值加1,這樣就能在寫入的時候判斷獲取到的值有沒有被修改過,流程圖如下:

步驟 線程A 線程B
1 從表中查詢出張三的money=1000,version=1
2 從表中查詢出張三的money=1000,version=1
3 更新張三的金額(UPDATE user_info SET money = money + 100, version = version + 1 WHERE user_name = '張三' AND money = 1000 AND version = 1;),返回更新條數爲1
4 更新張三的金額(UPDATE user_info SET money = money + 100, version = version + 1 WHERE user_name = '張三' AND money = 1000 AND version = 1;),返回更新條數爲0
5 跳出循環,返回更新成功
6 自旋再次從表中查詢出張三的money=1100,version = 2
7 更新張三的金額(UPDATE user_info SET money = money + 100, version = version + 1 WHERE user_name = '張三' AND money = 1100 AND version = 2;),返回更新條數爲1
8 跳出循環,返回更新成功

5.1.2 循環時間長開銷大

自旋CAS(也就是不成功就一直循環執行直到成功)如果長時間不成功,會給CPU帶來非常大的執行開銷。個人想法是在死循環添加嘗試次數,達到嘗試次數還沒成功的話就返回失敗。不確定有沒有什麼問題,歡迎指出。

5.1.3 只能保證一個共享變量的原子操作

CAS 只對單個共享變量有效,當操作涉及跨多個共享變量時 CAS 無效。但是從 JDK 1.5開始,提供了AtomicReference類來保證引用對象之間的原子性,你可以把多個變量放在一個對象裏來進行 CAS 操作。所以我們可以使用鎖或者利用AtomicReference類把多個共享變量合併成一個共享變量來操作。

6、悲觀鎖的缺點

6.1 synchronized

  • 鎖的釋放情況少,只在程序正常執行完成和拋出異常時釋放鎖;
  • 試圖獲得鎖是不能設置超時;
  • 不能中斷一個正在試圖獲得鎖的線程;
  • 無法知道是否成功獲取到鎖;

6.2 ReentrantLock

  • 需要使用import 引入相關的Class;
  • 不能忘記在finally 模塊釋放鎖,這個看起來比synchronized 醜陋;
  • synchronized可以放在方法的定義裏面, 而reentrantlock只能放在塊裏面. 比較起來, synchronized可以減少嵌套;

結尾

如果你覺得我的文章對你有幫助話,歡迎關注我的微信公衆號:"一個快樂又痛苦的程序員"(無廣告,單純分享原創文章、已pj的實用工具、各種Java學習資源,期待與你共同進步)

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章