首頁 >Java >java教程 >Java中鎖的實作方式有哪些

Java中鎖的實作方式有哪些

WBOY
WBOY轉載
2023-05-12 08:37:051637瀏覽

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 唤醒下一个阻塞线程有系统开销。

Java中鎖的實作方式有哪些

非公平锁:多个线程不按照申请锁的顺序去获得锁,而是同时以插队方式直接尝试获取锁,获取不到(插队失败),会进入队列等待(失败则乖乖排队),如果能获取到(插队成功),就直接获取到锁。

优点:可以减少 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(),如果取得了鎖定立即返回;如果沒有取得鎖定,則執行緒處於阻塞狀態,直到取得鎖定或執行緒被別的執行緒中斷。

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 進行了優化,引入了偏向鎖和輕量級鎖,從此以後鎖的狀態就有了四種:無鎖、偏向鎖、輕量級級鎖、重量級鎖。這四種狀態會隨著競爭的情況逐漸升級,而且是不可降級。

Java中鎖的實作方式有哪些

無鎖

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

偏向鎖定

偏向第一個存取鎖定的線程,初次執行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();
}

以上代码经过编译之后的字节码如下:

Java中鎖的實作方式有哪些

从上述结果可以看出,之前我们写的线程安全的加锁的 StringBuffer 对象,在生成字节码之后就被替换成了不加锁不安全的 StringBuilder 对象了,原因是 StringBuffer 的变量属于一个局部变量,并且不会从该方法中逃逸出去,所以我们可以使用锁消除(不加锁)来加速程序的运行。

以上是Java中鎖的實作方式有哪些的詳細內容。更多資訊請關注PHP中文網其他相關文章!

陳述:
本文轉載於:yisu.com。如有侵權,請聯絡admin@php.cn刪除