前言
小孩子才做選擇,我全都要
,今天寫一下面試必問的內容:樂觀鎖與悲觀鎖。主要從以下幾方面來說:
何爲樂觀鎖 何爲悲觀鎖 樂觀鎖常用實現方式 悲觀鎖常用實現方式 樂觀鎖的缺點 悲觀鎖的缺點
寫文章的時候突然收到朋友發來的消息,說烏茲退役了,LPL0006號選手斷開連接。願你鮮衣怒馬,一日看盡長安花,歷盡山河萬里,歸來仍是曾經那個少年。來,跟我一起喊一句:大道至簡-唯我自豪
1、何爲樂觀鎖
樂觀鎖總是假設事情向着好的方向發展,就比如有些人天生樂觀,向陽而生!
樂觀鎖總是假設最好的情況,每次去拿數據的時候都認爲別人不會修改,所以不會上鎖,但是在更新的時候會判斷一下在此期間別人有沒有去更新這個數據。樂觀鎖適用於多讀的應用類型,因爲樂觀鎖在讀取數據的時候不會去加鎖,這樣可以省去了鎖的開銷,加大了系統的整個吞吐量。即時偶爾有衝突,這也無傷大雅,要麼重新嘗試提交要麼返回給用戶說跟新失敗,當然,前提是偶爾發生衝突
,但如果經常產生衝突,上層應用會不斷的進行自旋重試,這樣反倒是降低了性能,得不償失。
2、何爲悲觀鎖
悲觀鎖總是假設事情向着壞的方向發展,就比如有些人經歷了某些事情,可能不太相信別人,只信任自己,身在黑暗,腳踩光明!
悲觀鎖每次去拿數據的時候都認爲別人會修改,所以每次在拿數據的時候都會上鎖,這樣別人想拿這個數據就會阻塞住,直到我釋放了鎖,別人才能拿到鎖,這樣的話,數據只有本身一個線程在修改,就確保了數據的準確性。因此,悲觀鎖適用於多寫的應用類型。
3、樂觀鎖常用實現方式
3.1 版本號機制
版本號機制就是在表中增加一個字段,version
,在修改記錄的時候,先查詢出記錄,再每次修改的時候給這個字段值加1,判斷條件就是你剛纔查詢出來的值。看下面流程就明白了:
3.1.1 新增用戶信息表
CREATE TABLE `user_info` (
`id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '主鍵ID',
`user_name` varchar(64) DEFAULT NULL COMMENT '用戶姓名',
`money` decimal(15,0) DEFAULT '0' COMMENT '剩餘金額(分)',
`version` bigint(20) DEFAULT '1' COMMENT '版本號',
PRIMARY KEY (`id`) USING BTREE
) ENGINE=InnoDB COMMENT='用戶信息表';
3.1.2 新增一條數據
INSERT INTO `user_info` (`user_name`, `money`, `version`) VALUES ('張三', 1000, 1);
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 算法涉及到三個操作數:
需要讀寫的內存值 V(主內存中的變量值) 進行比較的值 A(克隆下來線程本地內存中的變量值) 擬寫入的新值 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學習資源,期待與你共同進步)