聊聊 13 種鎖的實現(xiàn)方式
最近有很多小伙伴給我留言,分布式系統(tǒng)時代,線程并發(fā),資源搶占,"鎖" 慢慢變得很重要。那么常見的鎖都有哪些?
今天Tom哥就和大家簡單聊聊這個話題。
1、悲觀鎖
正如其名,它是指對數(shù)據(jù)修改時持保守態(tài)度,認(rèn)為其他人也會修改數(shù)據(jù)。因此在操作數(shù)據(jù)時,會把數(shù)據(jù)鎖住,直到操作完成。悲觀鎖大多數(shù)情況下依靠數(shù)據(jù)庫的鎖機制實現(xiàn),以保證操作最大程度的獨占性。如果加鎖的時間過長,其他用戶長時間無法訪問,影響程序的并發(fā)訪問性,同時這樣對數(shù)據(jù)庫性能開銷影響也很大,特別是長事務(wù)而言,這樣的開銷往往無法承受。
如果是單機系統(tǒng),我們可以采用 JAVA 自帶的 synchronized 關(guān)鍵字,通過添加到方法或同步塊上,鎖住資源 如果是分布式系統(tǒng),我們可以借助數(shù)據(jù)庫自身的鎖機制來實現(xiàn)。
select * from 表名 where id= #{id} for update
使用悲觀鎖的時候,我們要注意鎖的級別,MySQL innodb 在加鎖時,只有明確的指定主鍵或(索引字段)才會使用 行鎖;否則,會執(zhí)行 表鎖,將整個表鎖住,此時性能會很差。在使用悲觀鎖時,我們必須關(guān)閉 MySQL 數(shù)據(jù)庫的自動提交屬性,因為mysql默認(rèn)使用自動提交模式。悲觀鎖適用于寫多的場景,而且并發(fā)性能要求不高。
2、樂觀鎖
樂觀鎖,從字面意思也能猜到個大概,在操作數(shù)據(jù)時非常樂觀,認(rèn)為別人不會同時修改數(shù)據(jù),因此樂觀鎖不會上鎖 只是在 提交更新 時,才會正式對數(shù)據(jù)的沖突與否進行檢測。如果發(fā)現(xiàn)沖突了,則返回錯誤信息,讓用戶決定如何去做,fail-fast 機制 。否則,執(zhí)行本次操作。
分為三個階段:數(shù)據(jù)讀取、寫入校驗、數(shù)據(jù)寫入。
如果是單機系統(tǒng),我們可以基于JAVA 的 CAS來實現(xiàn),CAS 是一種原子操作,借助硬件的比較并交換來實現(xiàn)。
如果是分布式系統(tǒng),我們可以在數(shù)據(jù)庫表中增加一個 版本號 字段,如:version。
update 表
set ... , version = version +1
where id= #{id} and version = #{version}
操作前,先讀取記錄的版本號,更新時,通過SQL語句比較版本號是否一致。如果一致,則更新數(shù)據(jù)。否則會再次讀取版本,重試上面的操作。
3、分布式鎖
JAVA 中的 synchronized 、ReentrantLock 等,都是解決單體應(yīng)用單機部署的資源互斥問題。隨著業(yè)務(wù)快速發(fā)展,當(dāng)單體應(yīng)用演化為分布式集群后,多線程、多進程分布在不同的機器上,原來的單機并發(fā)控制鎖策略失效
此時我們需要引入 分布式鎖,解決跨機器的互斥機制來控制共享資源的訪問。
分布式鎖需要具備哪些條件:
- 與單機系統(tǒng)一樣的資源互斥功能,這是鎖的基礎(chǔ)
- 高性能獲取、釋放鎖
- 高可用
- 具備可重入性
- 有鎖失效機制,防止死鎖
- 非阻塞,不管是否獲得鎖,要能快速返回
實現(xiàn)方式多種多樣,基于 數(shù)據(jù)庫、Redis、以及 Zookeeper等,這里講下主流的基于Redis的實現(xiàn)方式:
加鎖:
SET key unique_value [EX seconds] [PX milliseconds] [NX|XX]
通過原子命令,如果執(zhí)行成功返回 1,則表示加鎖成功。注意:unique_value 是客戶端生成的唯一標(biāo)識,區(qū)分來自不同客戶端的鎖操作 解鎖要特別注意,先判斷 unique_value 是不是加鎖的客戶端,是的話才允許解鎖刪除。畢竟我們不能刪除其他客戶端加的鎖。
解鎖:解鎖有兩個命令操作,需要借助 Lua 腳本來保證原子性。
// 先比較 unique_value 是否相等,避免鎖的誤釋放
if redis.call("get",KEYS[1]) == ARGV[1] then
return redis.call("del",KEYS[1])
else
return 0
end
借助 Redis 的高性能,Redis 實現(xiàn)分布式鎖也是目前主流實現(xiàn)方式。但任何事情有利有弊,如果加鎖的服務(wù)器宕機了,當(dāng)slave 節(jié)點還沒來得及數(shù)據(jù)備份,那不是別的客戶端也可以獲得鎖。
為了解決這個問題,Redis 官方設(shè)計了一個分布式鎖 Redlock。
基本思路:讓客戶端與多個獨立的 Redis 節(jié)點并行請求申請加鎖,如果能在半數(shù)以上的節(jié)點成功地完成加鎖操作,那么我們就認(rèn)為,客戶端成功地獲得分布式鎖,否則加鎖失敗。
4、可重入鎖
可重入鎖,也叫做遞歸鎖,是指在同一個線程在調(diào)外層方法獲取鎖的時候,再進入內(nèi)層方法會自動獲取鎖。
對象鎖或類鎖內(nèi)部有計數(shù)器,一個線程每獲得一次鎖,計數(shù)器 +1;解鎖時,計數(shù)器 -1。
有多少次加鎖,就要對應(yīng)多少次解鎖,加鎖與解鎖成對出現(xiàn)。
Java 中的 ReentrantLock 和 synchronized 都是 可重入鎖??芍厝腈i的一個好處是可一定程度避免死鎖。
5、自旋鎖
自旋鎖是采用讓當(dāng)前線程不停地在循環(huán)體內(nèi)執(zhí)行,當(dāng)循環(huán)的條件被其他線程改變時才能進入臨界區(qū)。自旋鎖只是將當(dāng)前線程不停地執(zhí)行循環(huán)體,不進行線程狀態(tài)的改變,所以響應(yīng)速度更快。但當(dāng)線程數(shù)不斷增加時,性能下降明顯,因為每個線程都需要執(zhí)行,會占用CPU時間片。如果線程競爭不激烈,并且保持鎖的時間段。適合使用自旋鎖。
自旋鎖缺點:
- 可能引發(fā)死鎖。
- 可能占用 CPU 的時間過長。
我們可以設(shè)置一個 循環(huán)時間 或 循環(huán)次數(shù),超出閾值時,讓線程進入阻塞狀態(tài),防止線程長時間占用 CPU 資源。JUC 并發(fā)包中的 CAS 就是采用自旋鎖,compareAndSet 是CAS操作的核心,底層利用Unsafe對象實現(xiàn)的。
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
return var5;
}
如果內(nèi)存中 var1 對象的var2字段值等于預(yù)期的 var5,則將該位置更新為新值(var5 + var4),否則不進行任何操作,一直重試,直到操作成功為止。
CAS 包含了Compare和Swap 兩個操作,如何保證原子性呢?CAS 是由 CPU 支持的原子操作,其原子性是在硬件層面進行控制。
特別注意,CAS 可能導(dǎo)致 ABA 問題,我們可以引入遞增版本號來解決。
6、獨享鎖
獨享鎖,也有人叫它排他鎖。無論讀操作還是寫操作,只能有一個線程獲得鎖,其他線程處于阻塞狀態(tài)。
缺點:讀操作并不會修改數(shù)據(jù),而且大部分的系統(tǒng)都是 讀多寫少,如果讀讀之間互斥,大大降低系統(tǒng)的性能。下面的 共享鎖 會解決這個問題。
像Java中的 ReentrantLock 和 synchronized 都是獨享鎖。
7、共享鎖
共享鎖是指允許多個線程同時持有鎖,一般用在讀鎖上。讀鎖的共享鎖可保證并發(fā)讀是非常高效的。讀寫,寫讀 ,寫寫的則是互斥的。獨享鎖與共享鎖也是通過AQS來實現(xiàn)的,通過實現(xiàn)不同的方法,來實現(xiàn)獨享或者共享。
ReentrantReadWriteLock,其讀鎖是共享鎖,其寫鎖是獨享鎖。
8、讀鎖/寫鎖
如果對某個資源是讀操作,那多個線程之間并不會相互影響,可以通過添加讀鎖實現(xiàn)共享。如果有修改動作,為了保證數(shù)據(jù)的并發(fā)安全,此時只能有一個線程獲得鎖,我們稱之為 寫鎖。讀讀是共享的;而 讀寫、寫讀 、寫寫 則是互斥的。
像 Java中的 ReentrantReadWriteLock 就是一種 讀寫鎖。
9、公平鎖/非公平鎖
公平鎖:多個線程按照申請鎖的順序去獲得鎖,所有線程都在隊列里排隊,先來先獲取的公平性原則。
優(yōu)點:所有的線程都能得到資源,不會餓死在隊列中。
缺點:吞吐量會下降很多,隊列里面除了第一個線程,其他的線程都會阻塞,CPU 喚醒下一個阻塞線程有系統(tǒng)開銷。
非公平鎖:多個線程不按照申請鎖的順序去獲得鎖,而是同時以插隊方式直接嘗試獲取鎖,獲取不到(插隊失敗),會進入隊列等待(失敗則乖乖排隊),如果能獲取到(插隊成功),就直接獲取到鎖。
優(yōu)點:可以減少 CPU 喚醒線程的開銷,整體的吞吐效率會高點。
缺點:可能導(dǎo)致隊列中排隊的線程一直獲取不到鎖或者長時間獲取不到鎖,活活餓死。
Java 多線程并發(fā)操作,我們操作鎖大多時候都是基于 Sync 本身去實現(xiàn)的,而 Sync 本身卻是 ReentrantLock 的一個內(nèi)部類,Sync 繼承 AbstractQueuedSynchronizer。
像 ReentrantLock 默認(rèn)是非公平鎖,我們可以在構(gòu)造函數(shù)中傳入 true,來創(chuàng)建公平鎖。
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
10、可中斷鎖/不可中斷鎖
可中斷鎖:指一個線程因為沒有獲得鎖在阻塞等待過程中,可以中斷自己阻塞的狀態(tài)。不可中斷鎖:恰恰相反,如果鎖被其他線程獲取后,當(dāng)前線程只能阻塞等待。如果持有鎖的線程一直不釋放鎖,那其他想獲取鎖的線程就會一直阻塞。
內(nèi)置鎖 synchronized 是不可中斷鎖,而 ReentrantLock 是可中斷鎖。
ReentrantLock獲取鎖定有三種方式:
- lock(), 如果獲取了鎖立即返回,如果別的線程持有鎖,當(dāng)前線程則一直處于阻塞狀態(tài),直到該線程獲取鎖。
- tryLock(), 如果獲取了鎖立即返回true,如果別的線程正持有鎖,立即返回false。
- tryLock(long timeout,TimeUnit unit), 如果獲取了鎖定立即返回true,如果別的線程正持有鎖,會等待參數(shù)給定的時間,在等待的過程中,如果獲取了鎖定,就返回true,如果等待超時,返回false。
- lockInterruptibly(),如果獲取了鎖定立即返回;如果沒有獲取鎖,線程處于阻塞狀態(tài),直到獲取鎖或者線程被別的線程中斷。
更多:https://github.com/aalansehaiyang/p-java-proof/blob/master/resource/17.md。
11、分段鎖
分段鎖其實是一種鎖的設(shè)計,目的是細(xì)化鎖的粒度,并不是具體的一種鎖,對于ConcurrentHashMap 而言,其并發(fā)的實現(xiàn)就是通過分段鎖的形式來實現(xiàn)高效的并發(fā)操作。
ConcurrentHashMap中的分段鎖稱為Segment,它即類似于HashMap(JDK7 中HashMap的實現(xiàn))的結(jié)構(gòu),即內(nèi)部擁有一個Entry數(shù)組,數(shù)組中的每個元素又是一個鏈表;同時又是一個ReentrantLock(Segment繼承了ReentrantLock)。
當(dāng)需要put元素的時候,并不是對整個HashMap加鎖,而是先通過hashcode知道要放在哪一個分段中,然后對這個分段加鎖,所以當(dāng)多線程put時,只要不是放在同一個分段中,可支持并行插入。
12、鎖升級(無鎖|偏向鎖|輕量級鎖|重量級鎖)
JDK 1.6之前,synchronized 還是一個重量級鎖,效率比較低。但是在JDK 1.6后,JVM為了提高鎖的獲取與釋放效率對 synchronized 進行了優(yōu)化,引入了偏向鎖和輕量級鎖 ,從此以后鎖的狀態(tài)就有了四種:無鎖、偏向鎖、輕量級鎖、重量級鎖。這四種狀態(tài)會隨著競爭的情況逐漸升級,而且是不可降級。
無鎖
無鎖并不會對資源鎖定,所有的線程都可以訪問并修改同一個資源,但同時只有一個線程能修改成功。也就是我們常說的樂觀鎖。
偏向鎖
偏向于第一個訪問鎖的線程,初次執(zhí)行synchronized代碼塊時,通過 CAS 修改對象頭里的鎖標(biāo)志位,鎖對象變成偏向鎖。
當(dāng)一個線程訪問同步代碼塊并獲取鎖時,會在 Mark Word 里存儲鎖偏向的線程 ID。在線程進入和退出同步塊時不再通過 CAS 操作來加鎖和解鎖,而是檢測 Mark Word 里是否存儲著指向當(dāng)前線程的偏向鎖。輕量級鎖的獲取及釋放依賴多次 CAS 原子指令,而偏向鎖只需要在置換 ThreadID 的時候依賴一次 CAS 原子指令即可。
執(zhí)行完同步代碼塊后,線程并不會主動釋放偏向鎖。當(dāng)線程第二次再執(zhí)行同步代碼塊時,線程會判斷此時持有鎖的線程是否就是自己(持有鎖的線程ID也在對象頭里),如果是則正常往下執(zhí)行。由于之前沒有釋放鎖,這里不需要重新加鎖,偏向鎖幾乎沒有額外開銷,性能極高。
偏向鎖只有遇到其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放鎖,線程是不會主動釋放偏向鎖的。關(guān)于偏向鎖的撤銷,需要等待全局安全點,即在某個時間點上沒有字節(jié)碼正在執(zhí)行時,它會先暫停擁有偏向鎖的線程,然后判斷鎖對象是否處于被鎖定狀態(tài)。如果線程不處于活動狀態(tài),則將對象頭設(shè)置成無鎖狀態(tài),并撤銷偏向鎖,恢復(fù)到無鎖(標(biāo)志位為01)或輕量級鎖(標(biāo)志位為00)的狀態(tài)。
偏向鎖是指當(dāng)一段同步代碼一直被同一個線程所訪問時,即不存在多個線程的競爭時,那么該線程在后續(xù)訪問時便會自動獲得鎖,從而降低獲取鎖帶來的消耗。
輕量級鎖
當(dāng)前鎖是偏向鎖,此時有多個線程同時來競爭鎖,偏向鎖就會升級為輕量級鎖。輕量級鎖認(rèn)為雖然競爭是存在的,但是理想情況下競爭的程度很低,通過自旋方式來獲取鎖。
輕量級鎖的獲取有兩種情況:
- 當(dāng)關(guān)閉偏向鎖功能時。
- 多個線程競爭偏向鎖導(dǎo)致偏向鎖升級為輕量級鎖。一旦有第二個線程加入鎖競爭,偏向鎖就升級為輕量級鎖(自旋鎖)。
在輕量級鎖狀態(tài)下繼續(xù)鎖競爭,沒有搶到鎖的線程將自旋,不停地循環(huán)判斷鎖是否能夠被成功獲取。獲取鎖的操作,其實就是通過CAS修改對象頭里的鎖標(biāo)志位。先比較當(dāng)前鎖標(biāo)志位是否為“釋放”,如果是則將其設(shè)置為“鎖定”,此過程是原子性。如果搶到鎖,然后線程將當(dāng)前鎖的持有者信息修改為自己。
重量級鎖
如果線程的競爭很激勵,線程的自旋超過了一定次數(shù)(默認(rèn)循環(huán)10次,可以通過虛擬機參數(shù)更改),將輕量級鎖升級為重量級鎖(依然是 CAS 修改鎖標(biāo)志位,但不修改持有鎖的線程ID),當(dāng)后續(xù)線程嘗試獲取鎖時,發(fā)現(xiàn)被占用的鎖是重量級鎖,則直接將自己掛起(而不是忙等),等待將來被喚醒。
重量級鎖是指當(dāng)有一個線程獲取鎖之后,其余所有等待獲取該鎖的線程都會處于阻塞狀態(tài)。簡言之,就是所有的控制權(quán)都交給了操作系統(tǒng),由操作系統(tǒng)來負(fù)責(zé)線程間的調(diào)度和線程的狀態(tài)變更。而這樣會出現(xiàn)頻繁地對線程運行狀態(tài)的切換,線程的掛起和喚醒,從而消耗大量的系統(tǒng)資。
13、鎖優(yōu)化技術(shù)(鎖粗化、鎖消除)
鎖粗化就是告訴我們?nèi)魏问虑槎加袀€度,有些情況下我們反而希望把很多次鎖的請求合并成一個請求,以降低短時間內(nèi)大量鎖請求、同步、釋放帶來的性能損耗。
舉個例子:有個循環(huán)體,內(nèi)部。
for(int i=0;i<size;i++){
synchronized(lock){
...業(yè)務(wù)處理,省略
}
}
經(jīng)過鎖粗化的代碼如下:
synchronized(lock){
for(int i=0;i<size;i++){
...業(yè)務(wù)處理,省略
}
}
鎖消除指的是在某些情況下,JVM 虛擬機如果檢測不到某段代碼被共享和競爭的可能性,就會將這段代碼所屬的同步鎖消除掉,從而到底提高程序性能的目的。
鎖消除的依據(jù)是逃逸分析的數(shù)據(jù)支持,如 StringBuffer 的 append() 方法,或 Vector 的 add() 方法,在很多情況下是可以進行鎖消除的,比如以下這段代碼:
public String method() {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < 10; i++) {
sb.append("i:" + i);
}
return sb.toString();
}
以上代碼經(jīng)過編譯之后的字節(jié)碼如下:
從上述結(jié)果可以看出,之前我們寫的線程安全的加鎖的 StringBuffer 對象,在生成字節(jié)碼之后就被替換成了不加鎖不安全的 StringBuilder 對象了,原因是 StringBuffer 的變量屬于一個局部變量,并且不會從該方法中逃逸出去,所以我們可以使用鎖消除(不加鎖)來加速程序的運行。