首頁 > 軟體

一起聊聊Java中13種鎖的實現方式

2022-08-05 14:03:38

最近有很多小夥伴給我留言,分散式系統時代,執行緒並行,資源搶佔,"鎖" 慢慢變得很重要。那麼常見的鎖都有哪些?

今天Tom哥就和大家簡單聊聊這個話題。

1、悲觀鎖

正如其名,它是指對資料修改時持保守態度,認為其他人也會修改資料。因此在運算元據時,會把資料鎖住,直到操作完成。悲觀鎖大多數情況下依靠資料庫的鎖機制實現,以保證操作最大程度的獨佔性。如果加鎖的時間過長,其他使用者長時間無法存取,影響程式的並行存取性,同時這樣對資料庫效能開銷影響也很大,特別是長事務而言,這樣的開銷往往無法承受。

如果是單機系統,我們可以採用 JAVA 自帶的 synchronized 關鍵字,通過新增到方法或同步塊上,鎖住資源 如果是分散式系統,我們可以藉助資料庫自身的鎖機制來實現。

select * from 表名 where id= #{id} for update

使用悲觀鎖的時候,我們要注意鎖的級別,MySQL innodb 在加鎖時,只有明確的指定主鍵或(索引欄位)才會使用 行鎖​;否則,會執行 表鎖,將整個表鎖住,此時效能會很差。在使用悲觀鎖時,我們必須關閉 MySQL 資料庫的自動提交屬性,因為mysql預設使用自動提交模式。悲觀鎖適用於寫多的場景,而且並行效能要求不高。

2、樂觀鎖

樂觀鎖,從字面意思也能猜到個大概,在運算元據時非常樂觀,認為別人不會同時修改資料,因此樂觀鎖不會上鎖 只是在 提交更新​ 時,才會正式對資料的衝突與否進行檢測。如果發現衝突了,則返回錯誤資訊,讓使用者決定如何去做,fail-fast 機制 。否則,執行本次操作。

分為三個階段:資料讀取、寫入校驗、資料寫入。

如果是單機系統,我們可以基於JAVA 的 CAS來實現,CAS 是一種原子操作,藉助硬體的比較並交換來實現。

如果是分散式系統,我們可以在資料庫表中增加一個 版本號 欄位,如:version。

update 表 
set ... , version = version +1 
where id= #{id} and version = #{version}

操作前,先讀取記錄的版本號,更新時,通過SQL語句比較版本號是否一致。如果一致,則更新資料。否則會再次讀取版本,重試上面的操作。

3、分散式鎖

JAVA 中的 synchronized​ 、ReentrantLock 等,都是解決單體應用單機部署的資源互斥問題。隨著業務快速發展,當單體應用演化為分散式叢集後,多執行緒、多程序分佈在不同的機器上,原來的單機並行控制鎖策略失效

此時我們需要引入 分散式鎖,解決跨機器的互斥機制來控制共用資源的存取。

分散式鎖需要具備哪些條件:

  • 與單機系統一樣的資源互斥功能,這是鎖的基礎
  • 高效能獲取、釋放鎖
  • 高可用
  • 具備可重入性
  • 有鎖失效機制,防止死鎖
  • 非阻塞,不管是否獲得鎖,要能快速返回

實現方式多種多樣,基於 資料庫、Redis​、以及 Zookeeper等,這裡講下主流的基於Redis的實現方式:

加鎖

SET key unique_value  [EX seconds] [PX milliseconds] [NX|XX]

通過原子命令,如果執行成功返回 1,則表示加鎖成功。注意:unique_value 是使用者端生成的唯一標識,區分來自不同使用者端的鎖操作 解鎖要特別注意,先判斷 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 實現分散式鎖也是目前主流實現方式。但任何事情有利有弊,如果加鎖的伺服器宕機了,當slave 節點還沒來得及資料備份,那不是別的使用者端也可以獲得鎖。

為了解決這個問題,Redis 官方設計了一個分散式鎖 Redlock。

基本思路:讓使用者端與多個獨立的 Redis 節點並行請求申請加鎖,如果能在半數以上的節點成功地完成加鎖操作,那麼我們就認為,使用者端成功地獲得分散式鎖,否則加鎖失敗。

4、可重入鎖

可重入鎖,也叫做遞迴鎖,是指在同一個執行緒在調外層方法獲取鎖的時候,再進入內層方法會自動獲取鎖。

物件鎖或類鎖內部有計數器,一個執行緒每獲得一次鎖,計數器 +1;解鎖時,計數器 -1。

有多少次加鎖,就要對應多少次解鎖,加鎖與解鎖成對出現。

Java 中的 ReentrantLock​ 和 synchronized 都是 可重入鎖。可重入鎖的一個好處是可一定程度避免死鎖。

5、自旋鎖

自旋鎖是採用讓當前執行緒不停地在迴圈體內執行,當迴圈的條件被其他執行緒改變時才能進入臨界區。自旋鎖只是將當前執行緒不停地執行迴圈體,不進行執行緒狀態的改變,所以響應速度更快。但當執行緒數不斷增加時,效能下降明顯,因為每個執行緒都需要執行,會佔用CPU時間片。如果執行緒競爭不激烈,並且保持鎖的時間段。適合使用自旋鎖。

自旋鎖缺點:

  • 可能引發死鎖。
  • 可能佔用 CPU 的時間過長。

我們可以設定一個 迴圈時間​ 或 迴圈次數​,超出閾值時,讓執行緒進入阻塞狀態,防止執行緒長時間佔用 CPU 資源。JUC 並行包中的 CAS 就是採用自旋鎖,compareAndSet 是CAS操作的核心,底層利用Unsafe物件實現的。

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;
}

如果記憶體中 var1 物件的var2欄位值等於預期的 var5,則將該位置更新為新值(var5 + var4),否則不進行任何操作,一直重試,直到操作成功為止。

CAS 包含了Compare和Swap 兩個操作,如何保證原子性呢?CAS 是由 CPU 支援的原子操作,其原子性是在硬體層面進行控制。

特別注意,CAS 可能導致 ABA 問題,我們可以引入遞增版本號來解決。

6、獨享鎖

獨享鎖,也有人叫它排他鎖。無論讀操作還是寫操作,只能有一個執行緒獲得鎖,其他執行緒處於阻塞狀態。

缺點:讀操作並不會修改資料,而且大部分的系統都是 讀多寫少​,如果讀讀之間互斥,大大降低系統的效能。下面的 共用鎖 會解決這個問題。

像Java中的 ReentrantLock​ 和 synchronized 都是獨享鎖。

7、共用鎖

共用鎖是指允許多個執行緒同時持有鎖,一般用在讀鎖上。讀鎖的共用鎖可保證並行讀是非常高效的。讀寫,寫讀 ,寫寫的則是互斥的。獨享鎖與共用鎖也是通過AQS來實現的,通過實現不同的方法,來實現獨享或者共用。

ReentrantReadWriteLock,其讀鎖是共用鎖,其寫鎖是獨享鎖。

8、讀鎖/寫鎖

如果對某個資源是讀操作,那多個執行緒之間並不會相互影響,可以通過新增讀鎖實現共用。如果有修改動作,為了保證資料的並行安全,此時只能有一個執行緒獲得鎖,我們稱之為 寫鎖。讀讀是共用的;而 讀寫、寫讀 、寫寫 則是互斥的。

像 Java中的 ReentrantReadWriteLock 就是一種 讀寫鎖。

9、公平鎖/非公平鎖

公平鎖:多個執行緒按照申請鎖的順序去獲得鎖,所有執行緒都在佇列裡排隊,先來先獲取的公平性原則。

優點:所有的執行緒都能得到資源,不會餓死在佇列中。

缺點:吞吐量會下降很多,佇列裡面除了第一個執行緒,其他的執行緒都會阻塞,CPU 喚醒下一個阻塞執行緒有系統開銷。

非公平鎖:多個執行緒不按照申請鎖的順序去獲得鎖,而是同時以插隊方式直接嘗試獲取鎖,獲取不到(插隊失敗),會進入佇列等待(失敗則乖乖排隊),如果能獲取到(插隊成功),就直接獲取到鎖。

優點:可以減少 CPU 喚醒執行緒的開銷,整體的吞吐效率會高點。

缺點:可能導致佇列中排隊的執行緒一直獲取不到鎖或者長時間獲取不到鎖,活活餓死。

Java 多執行緒並行操作,我們操作鎖大多時候都是基於 Sync​ 本身去實現的,而 Sync 本身卻是 ReentrantLock​ 的一個內部類,Sync 繼承 AbstractQueuedSynchronizer。

像 ReentrantLock 預設是非公平鎖,我們可以在建構函式中傳入 true,來建立公平鎖。

public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

10、可中斷鎖/不可中斷鎖

可中斷鎖:指一個執行緒因為沒有獲得鎖在阻塞等待過程中,可以中斷自己阻塞的狀態。不可中斷鎖:恰恰相反,如果鎖被其他執行緒獲取後,當前執行緒只能阻塞等待。如果持有鎖的執行緒一直不釋放鎖,那其他想獲取鎖的執行緒就會一直阻塞。

內建鎖 synchronized 是不可中斷鎖,而 ReentrantLock 是可中斷鎖。

ReentrantLock獲取鎖定有三種方式:

  • lock(), 如果獲取了鎖立即返回,如果別的執行緒持有鎖,當前執行緒則一直處於阻塞狀態,直到該執行緒獲取鎖。
  • tryLock(), 如果獲取了鎖立即返回true,如果別的執行緒正持有鎖,立即返回false。
  • tryLock(long timeout,TimeUnit unit), 如果獲取了鎖定立即返回true,如果別的執行緒正持有鎖,會等待引數給定的時間,在等待的過程中,如果獲取了鎖定,就返回true,如果等待超時,返回false。
  • lockInterruptibly(),如果獲取了鎖定立即返回;如果沒有獲取鎖,執行緒處於阻塞狀態,直到獲取鎖或者執行緒被別的執行緒中斷。

更多:https://github.com/aalansehaiyang/p-java-proof/blob/master/resource/17.md

11、分段鎖

分段鎖其實是一種鎖的設計,目的是細化鎖的粒度,並不是具體的一種鎖,對於ConcurrentHashMap 而言,其並行的實現就是通過分段鎖的形式來實現高效的並行操作。

ConcurrentHashMap中的分段鎖稱為Segment,它即類似於HashMap(JDK7 中HashMap的實現)的結構,即內部擁有一個Entry陣列,陣列中的每個元素又是一個連結串列;同時又是一個ReentrantLock(Segment繼承了ReentrantLock)。

當需要put元素的時候,並不是對整個HashMap加鎖,而是先通過hashcode知道要放在哪一個分段中,然後對這個分段加鎖,所以當多執行緒put時,只要不是放在同一個分段中,可支援並行插入。

12、鎖升級(無鎖|偏向鎖|輕量級鎖|重量級鎖)

JDK 1.6之前,synchronized 還是一個重量級鎖,效率比較低。但是在JDK 1.6後,JVM為了提高鎖的獲取與釋放效率對 synchronized 進行了優化,引入了偏向鎖和輕量級鎖 ,從此以後鎖的狀態就有了四種:無鎖、偏向鎖、輕量級鎖、重量級鎖。這四種狀態會隨著競爭的情況逐漸升級,而且是不可降級。

無鎖

無鎖並不會對資源鎖定,所有的執行緒都可以存取並修改同一個資源,但同時只有一個執行緒能修改成功。也就是我們常說的樂觀鎖。

偏向鎖

偏向於第一個存取鎖的執行緒,初次執行synchronized程式碼塊時,通過 CAS 修改物件頭裡的鎖標誌位,鎖物件變成偏向鎖。

當一個執行緒存取同步程式碼塊並獲取鎖時,會在 Mark Word​ 裡儲存鎖偏向的執行緒 ID。線上程進入和退出同步塊時不再通過 CAS 操作來加鎖和解鎖,而是檢測 Mark Word 裡是否儲存著指向當前執行緒的偏向鎖。輕量級鎖的獲取及釋放依賴多次 CAS 原子指令,而偏向鎖只需要在置換 ThreadID 的時候依賴一次 CAS 原子指令即可。

執行完同步程式碼塊後,執行緒並不會主動釋放偏向鎖。當執行緒第二次再執行同步程式碼塊時,執行緒會判斷此時持有鎖的執行緒是否就是自己(持有鎖的執行緒ID也在物件頭裡),如果是則正常往下執行。由於之前沒有釋放鎖,這裡不需要重新加鎖,偏向鎖幾乎沒有額外開銷,效能極高。

偏向鎖只有遇到其他執行緒嘗試競爭偏向鎖時,持有偏向鎖的執行緒才會釋放鎖,執行緒是不會主動釋放偏向鎖的。關於偏向鎖的復原,需要等待全域性安全點,即在某個時間點上沒有位元組碼正在執行時,它會先暫停擁有偏向鎖的執行緒,然後判斷鎖物件是否處於被鎖定狀態。如果執行緒不處於活動狀態,則將物件頭設定成無鎖狀態,並復原偏向鎖,恢復到無鎖(標誌位為01)或輕量級鎖(標誌位為00)的狀態。

偏向鎖是指當一段同步程式碼一直被同一個執行緒所存取時,即不存在多個執行緒的競爭時,那麼該執行緒在後續存取時便會自動獲得鎖,從而降低獲取鎖帶來的消耗。

輕量級鎖

當前鎖是偏向鎖,此時有多個執行緒同時來競爭鎖,偏向鎖就會升級為輕量級鎖。輕量級鎖認為雖然競爭是存在的,但是理想情況下競爭的程度很低,通過自旋方式來獲取鎖。

輕量級鎖的獲取有兩種情況:

  • 當關閉偏向鎖功能時。
  • 多個執行緒競爭偏向鎖導致偏向鎖升級為輕量級鎖。一旦有第二個執行緒加入鎖競爭,偏向鎖就升級為輕量級鎖(自旋鎖)。

在輕量級鎖狀態下繼續鎖競爭,沒有搶到鎖的執行緒將自旋,不停地迴圈判斷鎖是否能夠被成功獲取。獲取鎖的操作,其實就是通過CAS修改物件頭裡的鎖標誌位。先比較當前鎖標誌位是否為“釋放”,如果是則將其設定為“鎖定”,此過程是原子性。如果搶到鎖,然後執行緒將當前鎖的持有者資訊修改為自己。

重量級鎖

如果執行緒的競爭很激勵,執行緒的自旋超過了一定次數(預設迴圈10次,可以通過虛擬機器器引數更改),將輕量級鎖升級為重量級鎖(依然是 CAS  修改鎖標誌位,但不修改持有鎖的執行緒ID),當後續執行緒嘗試獲取鎖時,發現被佔用的鎖是重量級鎖,則直接將自己掛起(而不是忙等),等待將來被喚醒。

重量級鎖是指當有一個執行緒獲取鎖之後,其餘所有等待獲取該鎖的執行緒都會處於阻塞狀態。簡言之,就是所有的控制權都交給了作業系統,由作業系統來負責執行緒間的排程和執行緒的狀態變更。而這樣會出現頻繁地對執行緒執行狀態的切換,執行緒的掛起和喚醒,從而消耗大量的系統資。

13、鎖優化技術(鎖粗化、鎖消除)

鎖粗化就是告訴我們任何事情都有個度,有些情況下我們反而希望把很多次鎖的請求合併成一個請求,以降低短時間內大量鎖請求、同步、釋放帶來的效能損耗。

舉個例子:有個迴圈體,內部。

for(int i=0;i<size;i++){
    synchronized(lock){
        ...業務處理,省略
    }
}

經過鎖粗化的程式碼如下:

synchronized(lock){
    for(int i=0;i<size;i++){
        ...業務處理,省略
    }
}

鎖消除指的是在某些情況下,JVM 虛擬機器器如果檢測不到某段程式碼被共用和競爭的可能性,就會將這段程式碼所屬的同步鎖消除掉,從而到底提高程式效能的目的。

鎖消除的依據是逃逸分析的資料支援,如 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();
}

以上程式碼經過編譯之後的位元組碼如下:

從上述結果可以看出,之前我們寫的執行緒安全的加鎖的 StringBuffer​ 物件,在生成位元組碼之後就被替換成了不加鎖不安全的 StringBuilder​ 物件了,原因是 StringBuffer 的變數屬於一個區域性變數,並且不會從該方法中逃逸出去,所以我們可以使用鎖消除(不加鎖)來加速程式的執行。

到此這篇關於一起聊聊Java中13種鎖的實現方式的文章就介紹到這了,更多相關Java鎖內容請搜尋it145.com以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援it145.com!


IT145.com E-mail:sddin#qq.com