在計算機程式中鎖用於獨佔資源,獲取到鎖才可以操作對應的資源。
鎖在計算機底層的實現,依賴於CPU提供的CAS指令(compare and swsp),對於一個記憶體地址,會比較原值以及嘗試去修改的值,通過值是否修改成功,來表示是否強佔到了這個鎖。
jvm中,有2個常用的鎖
synchronized是java提供的關鍵字鎖,可以鎖物件,類,方法。
在JDK1.6以後,對synchronized進行了優化,增加了偏向鎖和輕量鎖模式,現在synchronized鎖的執行邏輯如下:
很顯然,偏向鎖設計的目的是「在Java官方看來,對同一個鎖的爭搶大部分都發生在同個執行緒上」。
輕量鎖設計的目的是「在短期內,鎖的爭搶通過自旋CAS就可以獲取到,短時間內的CPU自旋消耗小於執行緒掛起再喚醒的消耗」。
重量鎖就是最初優化前的synchronized的邏輯了。
說到ReentrantLock,就不得不說到JUC裡的AQS了。
AQS全稱AbstractQueueSynchronizer,幾乎JUC裡所有的工具類,都依賴AQS實現。
AQS在java裡,是一個抽象類,但是本質上是一種思路在java中的實現而已。
AQS的實現邏輯如下:
在synchronized鎖優化以後,AQS的本質與synchronized並沒有太大不同,兩者的效能也並沒有太大差距了,所以AQS現在的特點是:
到這裡你會發現,其實ReentrantLock可以說是synchronized在JavaApi層的實現。
這兩種鎖都包括行級鎖和表級鎖。
獲取共用鎖時,如果該資料被其他事務的排它鎖鎖住,則無法獲取,需要等待排它鎖釋放。
意向鎖為表鎖,在獲取表鎖之前,一定會檢查意向鎖。
意圖鎖定協定如下:
在事務獲得表中某行的共用鎖之前,它必須首先獲得表上的 IS 鎖或更強的鎖。
在事務獲得表中行的排他鎖之前,它必須首先獲得表的 IX 鎖。
在獲取任意表鎖的共用鎖或排它鎖之前,一定會檢查該表上的共用鎖。
表鎖以及意向鎖的互斥規則如下:
X IX S IS
X Conflict Conflict Conflict Conflict
IX Conflict Compatible Conflict Compatible
S Conflict Conflict Compatible Compatible
IS Conflict Compatible Compatible Compatible
意向鎖的作用在於:在獲取表鎖時,可以通過意向鎖來快速判斷能否獲取。
因為獲取行級鎖時,會先獲取對應的意向鎖,這樣另外的事務在獲取表鎖時就可以通過意向鎖快速的判斷,而不需要每行去掃描。
特別注意的是,意向鎖是可以疊加的,即會存在多個,如T1事務獲取了意向鎖IX1和行級鎖X1,T2事務依舊可以獲取意向鎖IX2和行級鎖X2,所以僅在獲取表級鎖之前,才會檢查意向鎖。
記錄鎖生效在索引上,用以在SELECT c1 FROM t WHERE c1 = 10 FOR UPDATE時保護該行資料不被其他事務更改。
記錄鎖在沒有索引時依舊會生效,因為innodb會為每張表建立一個隱藏的索引。
記錄鎖是最基本的行鎖。
間隙鎖生效在索引上,用於鎖定索引值後的行,防止插入,在select from table where index=? for update時會生效,例如index=1,則會鎖住index=1索引節點相關的行,防止其他事務插入資料。
但是並不會防止update語句,哪怕update的資料不存在。
這個鎖是記錄鎖和間隙鎖的組合,簡而言之在select from table where index=? for update時,既會有間隙鎖防止insert,也會有記錄鎖在index上防止這一條資料的update和delete。這個Next-key只是對這兩種鎖的一種概括,因為這兩種鎖在select for update時通常會一起出現。
插入意向鎖,和意向鎖類似。不過是特殊的間隙鎖,並不發生在select for update,而是在同時發生insert時產生,例如在兩個事務同時insert索引區間為[4,7]時,同時獲得該區間的意向鎖,此時事務不會阻塞,例如A:insert-5,B:insert-7,此時不會阻塞兩個事務。
插入意向鎖是一個特殊的間隙鎖,是為了防止正常間隙鎖鎖區間的情況下,insert頻繁阻塞而設計的,例如A:insert-5,B:insert-7,如果沒有插入意向鎖,那麼5和7都要去嘗試獲取間隙鎖,此時第二個事務就會被阻塞,但是通過插入意向鎖,第二個事務就不會被阻塞,只有到插入的行確實衝突,才會被阻塞。
自增鎖,這個鎖很明顯是表級insert鎖,為了保證自增主鍵的表的主鍵保持原子自增。
對於鎖這個東西,大家應該多去理解各種鎖設計執行的原理和模型,這樣在加深理解後,在使用起來才會更加深入和透徹。
眾所周知,mysql的事務對防止重複插入並沒有什麼卵用,唯一索引又存在很多缺點,業務上最好不要使用,所以一般來說防止重複插入的通用做法就是使用分散式鎖,這就有一種比較常用的寫法。
final WeekendNoticeReadCountDO weekendNoticeReadCountDO = weekendNoticeReadRepositoryService.selectByNoticeId(noticeRequestDTO.getNoticeId());
if (weekendNoticeReadCountDO == null) {
final String lockKey = RedisConstant.LOCK_WEEKEND_READ_COUNT_INSERT + ":" + noticeRequestDTO.getNoticeId();
ClusterLock lock = clusterLockFactory.getClusterLockRedis(
RedisConstant.REDIS_KEY_PREFIX,
lockKey
);
if (lock.acquire(RedisConstant.REDIS_LOCK_DEFAULT_TIMEOUT)) {
//double check
final WeekendNoticeReadCountDO weekendNoticeReadCountDO = weekendNoticeReadRepositoryService.selectByNoticeId(noticeRequestDTO.getNoticeId());
if (weekendNoticeReadCountDO == null) {
try {
lock.execute(() -> {
WeekendNoticeReadCountDO readCountDO = new WeekendNoticeReadCountDO();
readCountDO.setNoticeId(noticeRequestDTO.getNoticeId());
readCountDO.setReadCount(1L);
readCountDO.setCreateTime(new Date());
readCountDO.setUpdateTime(new Date());
weekendNoticeReadRepositoryService.insert(readCountDO);
return true;
});
} catch (ApiException err) {
throw err;
} catch (Exception e) {
log.error("插入", e);
throw new ApiException(ErrorEnum.SERVER_ERROR.getCode(), "伺服器端出錯");
}
} else {
weekendNoticeReadRepositoryService.noticeCountAdd(weekendNoticeReadCountDO);
}
} else {
log.warn("redis鎖獲取超時,key:{}", lockKey);
throw new ApiException(ErrorEnum.SERVER_ERROR.getCode(), "伺服器繁忙,請稍後重試");
}
}
在獲取到鎖之後,可能是經過等待才獲取到的鎖,此時上一個釋放鎖的執行緒可能已經插入了資料了,所以在鎖內部,依舊要再次校驗一下資料是否存在。
這種寫法適合大多數需要唯一性的寫場景。
如何避免死鎖?最簡單有效的方法就是:**不要在鎖裡再去獲取鎖,簡而言之就是鎖最好單獨使用,不要套娃。
也要注意一些隱性鎖,比如資料庫。
事務A:
此時在並行場景下,就可能會出現A持有了[5,7]的間隙鎖,在等待事務B[90,120]的間隙鎖,事務B也一樣,就死鎖了。
**
在寫業務程式碼,定義一些工具類或者快取類的時候,很容易疏忽而發生類似的問題。
比如構建一個static快取,沒有使用ConcurrentHashMap中的putIfAbsent等方法,也沒有加鎖去構建,導致上面的執行緒剛put了,下面的執行緒就刪掉了,或者重複構建2次快取。
這點在Redis鎖的範例程式碼也講到了。
執行緒A獲取到鎖,此時B,C在等待,然後A執行時間過長,導致鎖超時被自動釋放了,此時B獲取到了鎖,在快樂的執行,然後A執行完了之後,釋放鎖時沒有判斷是否還是自己持有,導致B持有的鎖被刪除了,此時C又獲取到了鎖,BC同時在執行。