京東二面:Java 中一共有 N 種實(shí)現(xiàn)鎖的方式,你知道都有哪些嗎?
首先,我們先來看下線程安全性的定義,為什么需要鎖?
線程安全,即在多線程編程中,一個程序或者代碼段在并發(fā)訪問時,能夠正確地保持其預(yù)期的行為和狀態(tài),而不會出現(xiàn)意外的錯誤或者不一致的結(jié)果。
而解決線程安全問題,主要分為兩大類:1、無鎖;2、有鎖。
無鎖的方式有:
- 局部變量;
- 對象加 final 為不可變對象;
- 使用 ThreadLocal 作為線程副本對象;
- CAS,Compare-And-Swap 即比較并交換,是 Java 十分常見的無鎖實(shí)現(xiàn)方式。
小白:那有鎖的方式呢,怎么通過加鎖保證線程安全呢?
別急哈,下面聽我給你一一道來。
Java 有哪些鎖?
從加鎖的策略看,分為隱式鎖和顯示鎖。隱式鎖通過 Synchronized 實(shí)現(xiàn),顯示鎖通過 Lock 實(shí)現(xiàn)。
- 樂觀鎖:顧名思義,它是一種基于樂觀的思想,認(rèn)為讀取的數(shù)據(jù)一般不會沖突,不會對其加鎖,而是在最后提交數(shù)據(jù)更新時判斷數(shù)據(jù)是否被更新,如果沖突,則更新不成功。
- 悲觀鎖:它總是假設(shè)最壞的情況,每次讀取數(shù)據(jù)都認(rèn)為別人會更新,所以每次讀取數(shù)據(jù)的時候都會加鎖,這樣別人就得阻塞等待它處理完釋放鎖后才能去讀取。
樂觀鎖實(shí)現(xiàn):CAS,比較并交換,通常指的是這樣一種原子操作:針對一個變量,首先比較它的內(nèi)存值與某個期望值是否相同,如果相同,就給它賦一個新值。
但是,這一篇我們主要來看下悲觀鎖的一些常用實(shí)現(xiàn)。
syncroized 是什么?
syncronized 是 Java 中的一個關(guān)鍵字,用于控制對共享資源的并發(fā)訪問,從而防止多個線程同時訪問某個特定資源,這被稱為同步。這個關(guān)鍵字可以用來修飾方法或代碼塊。
syncronized 使用對象鎖保證臨界區(qū)內(nèi)代碼的原子性
圖片
小白:synchronized 的底層原理是什么呀,怎么自己就完成加鎖釋放鎖操作了?
其實(shí) synchronized 的原理也不難,主要有以下兩個關(guān)鍵點(diǎn)。
- synchronized 又被稱為監(jiān)視器鎖,基于 Monitor 機(jī)制實(shí)現(xiàn)的,主要依賴底層操作系統(tǒng)的互斥原語 Mutex(互斥量)。Monitor 類比加了鎖的房間,一次只能有一個線程進(jìn)入,進(jìn)入房間即持有 Monitor,退出后就釋放 Monitor。
- 另一個關(guān)鍵點(diǎn)是 Java 對象頭,在 JVM 虛擬機(jī)中,對象在內(nèi)存中的存儲結(jié)構(gòu)有三部分:對象頭;實(shí)例數(shù)據(jù);對齊填充。
對象頭主要包括標(biāo)記字段 Mark World,元數(shù)據(jù)指針,如果是數(shù)組對象的話,對象頭還必須存儲數(shù)組長度。
圖片
synchronized 也是基于此,通過鎖對象的 monitor 獲取和 monitor 釋放來實(shí)現(xiàn),對象頭標(biāo)記為存儲具體鎖狀態(tài),ThreadId 記錄持有偏向鎖的線程 ID。
這里,又引申另外出一個問題:你知道什么是偏向鎖呢?
小白:不知道,啥玩意?
synchronized 鎖升級過程
說到這里,那就不得不提及 synchronized 的鎖升級機(jī)制了,因?yàn)?synchronized 的加鎖釋放鎖操作會使得 CPU 在內(nèi)核態(tài)和戶態(tài)之間發(fā)生切換,有一定性能開銷。在 JDK1.5 版本以后,對 synchronized 做了鎖升級的優(yōu)化,主要利用輕量級鎖、偏向鎖、自適應(yīng)鎖等減少鎖操作帶來的開銷,對其性能做了很大提升。
圖片
- 無鎖:沒有對資源進(jìn)行加鎖
- 偏向鎖:在大部分情況下,只有一個線程訪問修改資源,該線程自動獲取鎖,降低了鎖操作的代價,這里就通過對象頭的 ThreadId 記錄線程 ID。
- 輕量級鎖:當(dāng)前持有偏向鎖,當(dāng)有另外的線程來訪問后,偏向鎖會升級為輕量級鎖,別的線程通過自旋形式嘗試獲取鎖,不會阻塞,以提高性能。
- 重量級鎖:在自旋次數(shù)或時間超過一定閾值時,最后會升級為重量級鎖。
小白:哦哦原來如此,那剛剛你說了 Java 除了隱式鎖之外,還有顯示鎖呢?
ReentrantLock 簡介
在 Java 中,除了對象鎖,還有顯示的加鎖的方式,比如 Lock 接口,用得比較多的就是 ReentrantLock。它的特性如下:
圖片
下面我們再來對比看下 ReentrantLock 和 synchronized 的區(qū)別
圖片
從這些對比就能看出 ReentrantLock 使用更加的靈活,特性更加豐富。
ReentrantLock 是一個悲觀鎖,即是同一個時刻,只允許一個線程訪問代碼塊,這一點(diǎn) synchronized 其實(shí)也一樣。
圖片
小白:這個是挺好用的,但是我們有一些讀多寫少的場景中比如緩存,大部分時間都是讀操作,這里每個操作都要加鎖,讀性能不是很差嗎,有沒有更好的方案實(shí)現(xiàn)這種場景呀?
當(dāng)然有的,比如 ReentrantReadWriteLock,讀寫鎖。
ReentrantReadWriteLock 介紹
針對上述場景,Java 提供了讀寫鎖 ReentrantReadWriteLock,它的內(nèi)部維護(hù)了一對相關(guān)的鎖,一個用于只讀操作,稱為讀鎖;一個用于寫入操作,稱為寫鎖。
/** Inner class providing readlock */
private final ReentrantReadWriteLock.ReadLock readerLock;
/** Inner class providing writelock */
private final ReentrantReadWriteLock.WriteLock writerLock;
/** Performs all synchronization mechanics */
final Sync sync;
使用核心代碼如下:
public class LocalCacheService {
static Map<String, Object> localCache = new HashMap<>();
static ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
static Lock readL = lock.readLock();
static Lock writeL = lock.writeLock();
public static Object read(String key) {
readL.lock();
try {
return localCache.get(key);
} finally {
readL.unlock();
}
}
public static Object save(String key, String value) {
writeL.lock();
try {
return localCache.put(key, value);
} finally {
writeL.unlock();
}
}
}
在 ReentrantReadWriteLock 中,多個線程可以同時讀取一個共享資源。
當(dāng)有其他線程的寫鎖時,讀線程會被阻塞,反之一樣。
圖片
讀寫鎖設(shè)計(jì)思路
這里有一個關(guān)鍵點(diǎn),就是在 ReentrantLock 中,使用 AQS 的 state 表示同步狀態(tài),表示鎖被一個線程重復(fù)獲取的次數(shù)。但是在讀寫鎖 ReentrantReadWriteLock 中,如何用一個變量維護(hù)這兩個狀態(tài)呢?
實(shí)際 ReentrantReadWriteLock 采用“高低位切割”的方式來維護(hù),將 state 切分為兩部分:高 16 位表示讀;低 16 位表示寫。
分割之后,通過位運(yùn)算,假設(shè)當(dāng)前狀態(tài)為 S,那么:
- 寫狀態(tài)=S&0x0000FFFF(將高 16 位全部移除),當(dāng)寫狀態(tài)需要加 1,S+1 再運(yùn)算即可。
- 讀狀態(tài)=S>>>16(無符號補(bǔ) 0 右移 16 位),當(dāng)讀狀態(tài)需要加 1,計(jì)算 S+(1<<16)。
圖片
這時,我們再來思考下,如果有線程正在讀,寫線程需要等待讀線程釋放鎖才能獲取鎖,也就是讀的時候不允許寫,那么有沒有更好的方式改進(jìn)呢?
小白:emm,這個真的難倒我了。。。。。。
什么是 StampedLock?
哈哈莫慌,Java8 已經(jīng)引入了新的讀寫鎖,StampedLock。它和 ReentrantReadWriteLock 相比,區(qū)別在于讀過程允許獲取寫鎖寫入,在原來讀寫鎖的基礎(chǔ)上加了一種樂觀鎖機(jī)制,該模式不會阻塞寫鎖,只是最后會對比原來的值,有著更高的并發(fā)性能。
StampedLock 三種模式如下:
- 獨(dú)占鎖:和 ReentrantReadWriteLock 一樣,同一時刻只能有一個寫線程獲取資源
圖片
- 悲觀讀鎖:允許多個線程獲取讀鎖,但是讀寫互斥。
圖片
- 樂觀讀:沒有加鎖,允許多個線程獲取樂觀讀和讀鎖,同時允許一個寫線程獲取寫鎖。
圖片
小白:那這里可以允許多個讀操作和也給寫線程同時進(jìn)入共享資源操作,那讀取的數(shù)據(jù)被改了怎么辦????
別擔(dān)心,樂觀讀不能保證讀到的數(shù)據(jù)是最新的,所以當(dāng)把數(shù)據(jù)讀取到局部變量的時候需要通過 lock.validate 方法來校驗(yàn)是否被修改過,如果是改過了那么就加上悲觀讀鎖,再重新讀取數(shù)據(jù)到局部變量。