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