Java鎖Lock的種類
我們平時聽到用到的鎖有很多種:公平鎖/非公平鎖、可重入鎖/不可重入鎖、共享鎖/排他鎖、樂觀鎖/悲觀鎖、分段鎖、偏向鎖/輕量級鎖/重量級鎖、自旋鎖。其實這些都是在不同維度或者鎖優化角度對鎖的一種叫法,我們在程序中用到的也就那么幾種,比如synchronized,ReentrantLock,ReentrantReadWriteLock。
ReentrantLock類
Jdk1.5新增的ReentrantLock類和synchronized關鍵字一樣可以實現線程間同步互斥,但是它在拓展功能上更加強大,比如嗅探鎖定、多路分支等功能,使用的時候也比synchronized更加靈活。
使用Condition對象可以實現類似synchronized的wait()/notify()/notifyAll()同樣的功能。Condition有更好的靈活性,比如可以實現多路通知功能,也就是在一個Lock對象中創建多個Condition(即對象監視器)實例,線程對象可以注冊在指定的Condition中,從而可以有選擇性的進行線程通知,在調度線程上更加靈活。
在使用notify()/notifyAll()方法進行通知時,被通知的線程是由JVM隨機選擇的,但是使用ReentrantLock和Condition可以實現“選擇性通知”,這個功能是非常重要的,而且在Condition類時默認提供的。
而synchronized就相當於整個Lock對象中只有一個單一的Condition對象,所有的線程都注冊在它一個對象身上,線程開始notifyAll時,需要通知所有的waiting線程,沒有選擇權,會出現相當大的效率問題。
signal()和signalAll()區別
signalAll通知所有使用了同一個Condition對象的線程。signal()通知所有使用了Condition對象的某一個線程,通過源碼可以看到通知的線程是位於隊首的那個。
鎖的類型
鎖/類型 | 公平/非公平鎖 | 可重入/不可重入鎖 | 共享/獨享鎖 | 樂觀/悲觀鎖 |
---|---|---|---|---|
synchronized | 非公平鎖 | 可重入鎖 | 獨享鎖 | 悲觀鎖 |
ReentrantLock | 都支持 | 可重入鎖 | 獨享鎖 | 悲觀鎖 |
ReentrantReadWriteLock | 都支持 | 可重入鎖 | 讀鎖-共享,寫鎖-獨享 | 悲觀鎖 |
一. 公平鎖和非公平鎖
公平鎖表示線程獲取鎖順序是按照線程加鎖的順序來分配的,即FIFO順序。而非公平鎖就是一種獲取鎖的搶占機制,是隨機獲得鎖的。有可能后申請的線程比先申請的線程優先獲取鎖,可能會造成優先級反轉或者飢餓現象。
在公平的鎖中,如果有另一個線程持有鎖或者有其他線程在等待隊列中等待這個所,那么新發出的請求的線程將被放入到隊列中。而非公平鎖上,只有當鎖被某個線程持有時,新發出請求的線程才會被放入隊列中。
對於ReentrantLock而言,通過構造函數指定該鎖是否是公平鎖,默認是非公平鎖。非公平鎖的優點在於吞吐量比公平鎖大。
// ReentrantLock的構造器
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
對於Synchronized而言,也是一種非公平鎖。由於其並不像ReentrantLock是通過AQS的來實現線程調度,所以並沒有任何辦法使其變成公平鎖。
參考:
二. 可重入鎖和不可重入鎖
可重入鎖又名遞歸鎖,直指同一個線程在外層方法獲得鎖之后,在進入內層方法時,會自動獲得鎖。ReentrantLock和Synchronized都是可重入鎖。可重入鎖的好處之一就是在一定程度上避免死鎖。下面通過構建可重入鎖和不可重入鎖來詳細的了解一下。
首先看一個類的定義:
package com.wangjun.thread.IsReentrantLock;
public class Test {
Lock1 lock = new Lock1();
public static void main(String[] args) throws InterruptedException {
Test t = new Test();
t.test1();
}
public void test1() throws InterruptedException {
lock.lock();
System.out.println("test1方法執行...調用test2方法");
test2();
lock.unLock();
}
public void test2() throws InterruptedException {
lock.lock();
System.out.println("test2方法執行...");
lock.unLock();
}
}
如果Lock1是一個不可重入鎖,那么test1執行的時候已經拿到了鎖,再調用test2,由於test2一直獲取不到鎖,因此會進入死鎖狀態。
我們來看一下將Lock1實現為不可重入鎖:
package com.wangjun.thread.IsReentrantLock;
/*
* 不可重入鎖設計
*/
public class Lock1 {
private boolean lock = false;
public synchronized void lock() throws InterruptedException {
while(lock) {
wait();
}
lock = true;
}
public synchronized void unLock() {
lock = false;
notify();
}
}
不可重入鎖的弊端可以清晰的看到,那么如何構造一個可重入鎖呢?我們來看一下可重入鎖Lock2的設計:
package com.wangjun.thread.IsReentrantLock;
public class Lock2 {
private boolean lock = false; //記錄是否有線程獲得鎖
private Thread curThread = null; //記錄獲得鎖的線程
private int lockCount = 0; //記錄加鎖次數
public synchronized void lock() throws InterruptedException {
Thread thread = Thread.currentThread();
//如果已經加鎖並且不是等當前線程,那么就等待
while(lock && thread != curThread) {
wait();
}
lock = true; //線程獲得鎖
lockCount++; //加鎖次數+1
curThread = thread; //獲得鎖的線程等於當前線程
}
public synchronized void unLock() {
Thread thread = Thread.currentThread();
// 如果是獲得鎖的線程調用unLock,那么加鎖次數減一
if(thread == curThread) {
lockCount--;
//所有的加鎖都釋放,通知其他線程可以獲得鎖了
if(lockCount == 0) {
notify();
}
}
}
}
將測試類的:
Lock1 lock = new Lock1();
換成
Lock2 lock = new Lock2();
可以看到線程運行正常,不再造成死鎖。在test1加鎖后調用test2方法時,由於是同一個線程,所以test2種也會順利拿到鎖,並繼續執行。
可重入鎖就是要保證:線程可以進入任何一個它已經擁有鎖所同步着的代碼塊。
參考:
三. 共享鎖和獨享鎖
共享鎖也叫S鎖,讀鎖,該鎖可以被多個線程持有;
獨享鎖也叫X鎖,寫鎖,排他鎖,該鎖只能被一個線程持有。
共享鎖【S鎖】
若事務T對數據對象A加上S鎖,則事務T可以讀A但不能修改A,其他事務只能再對A加S鎖,而不能加X鎖,直到T釋放A上的S鎖。這保證了其他事務可以讀A,但在T釋放A上的S鎖之前不能對A做任何修改。
排他鎖【X鎖】
又稱寫鎖。若事務T對數據對象A加上X鎖,事務T可以讀A也可以修改A,其他事務不能再對A加任何鎖,直到T釋放A上的鎖。這保證了其他事務在T釋放A上的鎖之前不能再讀取和修改A。
對於ReentrantLock和Synchronized而言,是獨享鎖,讀讀、讀寫、寫寫的過程都是互斥的。對於ReadWriteLock而言,讀鎖是共享鎖,寫鎖是獨享鎖,讀鎖的共享鎖可以保證並發讀是非常高效的,在讀寫鎖中,讀讀不互斥、讀寫、寫寫的過程是互斥的。獨享鎖與共享鎖也是通過AQS來實現的,通過實現不同的方法,來實現獨享或者共享。
讀寫鎖ReentrantReadWriteLock的應用場景:
讀寫鎖:分為讀鎖和寫鎖,多個讀鎖不互斥,讀鎖與寫鎖互斥,這是由jvm自己控制的,我們只要上好相應的鎖即可。如果你的代碼只讀數據,可以很多人同時讀,但不能同時寫,那就上讀鎖;如果你的代碼修改數據,只能有一個人在寫,且不能同時讀取,那就上寫鎖。總之,讀的時候上讀鎖,寫的時候上寫鎖!
ReentrantReadWriteLock會使用兩把鎖來解決問題,一個讀鎖,一個寫鎖。
線程進入讀鎖的前提條件:
- 沒有其他線程的寫鎖
進入寫鎖的前提條件:
1. 沒有其他線程的讀鎖
2. 沒有其他線程的寫鎖
ReentrantReadWriteLock的javaodoc文檔中提供給我們的一個很好的Cache實例代碼案例:
class CachedData {
Object data; //緩存的數據
volatile boolean cacheValid; //緩存是否有效
final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
public void processCachedData() {
rwl.readLock().lock();
if (!cacheValid) {
// 再加寫鎖之前必須先釋放讀鎖,因為進入寫鎖的條件是沒有其他線程的讀鎖和寫鎖
rwl.readLock().unlock();
rwl.writeLock().lock();
try {
// 類似單例模式的DCL雙重檢查,防止其他線程先拿到寫鎖對數據進行了緩存,因此要再判斷一次
if (!cacheValid) {
data = ...
cacheValid = true;
}
// 在釋放寫鎖之前通過獲取讀鎖降級寫鎖,防止釋放寫鎖后立即被其他線程加上寫鎖,導致讀取臟數據
rwl.readLock().lock();
} finally {
rwl.writeLock().unlock(); // 釋放寫鎖而此時已經持有讀鎖
}
}
try {
use(data);
} finally {
rwl.readLock().unlock();
}
}
}
參考:
https://www.cnblogs.com/liang1101/p/6475555.html?utm_source=itdadao&utm_medium=referral
四. 樂觀鎖和悲觀鎖
悲觀鎖:
總是假設最壞的情況,每次拿數據的時候都認為別人會修改,所以每次在拿數據的時候都會上鎖,這樣別人想要拿到它的數據就會被一直阻塞直到它拿到鎖,傳統的關系型數據庫里面就用到了很多這種鎖機制,比如行鎖、表鎖、讀鎖、寫鎖等,都是在操作之前先上鎖。再比如java里面的synchronized關鍵字的實現也是悲觀鎖。
樂觀鎖:
顧名思義,很樂觀,每次拿數據的時候都認為別人不會修改,所以不會上鎖,但是在更新的時候會去判斷一下別人有沒有修改這個數據,可以使用版本號等機制。樂觀鎖適用於多讀的應用場景,這樣可以提高吞吐量,像數據庫提供的類似於write_condition機制就是提供的樂觀鎖。在Java中java.util.concurrent.atomic包下面的原子變量類就是使用了樂觀鎖的一種實現方式CAS實現的。
4.1 悲觀鎖的缺點
悲觀鎖通過加鎖的方式限制其他人對數據的操作,而樂觀鎖不會加鎖,也就放寬了別人對數據的訪問。使用悲觀鎖會引發一些問題:
- 在多線程競爭下,加鎖、釋放鎖會造成比較多的上下文切換和調度延時,引起性能問題;
- 一個線程持有鎖,會導致其他所有需要此鎖的線程掛起;
- 如果一個優先級高的線程等待一個優先級底的線程的鎖,會導致優先級倒置,引起性能風險。
對比於悲觀鎖的這些問題,一個有效的方式就是樂觀鎖。其實樂觀鎖就是:每次不加鎖而是假設沒有並發沖突而去完成某項操作,如果因為並發沖突失敗就重試,直到成功為止。
4.2 樂觀鎖的一種實現方式:CAS
CAS的全稱是Compare And Swap,比較和替換。CAS操作包括三個操作數內存位置(V)、進行比較的預期原值(A)和擬寫入的新值(B)。如果內存位置V的值與預期原值A相匹配,那么處理器會自動將該位置值更新為新值B。否則處理器不做任何操作。一般配合死循環來不斷嘗試更新值,直到成功。
相對於synchronized這種阻塞算法,CAS是一種非阻塞算法的常用實現。
CAS實現的過程是:調用java的JNI接口--> 調用c接口 --> 匯編語言調用CPU指令(關鍵指令:cmpxchg)
CAS的缺點
- ABA問題:意思是說當一個線程獲取當前的值是A,此時另一個線程先將A變成B,再變成A,之前的線程繼續執行,發現值沒變還是A,就繼續執行更新操作。這樣可能會引發一些潛在問題,問題實例可以參考引用。通常各種樂觀鎖的實現用版本戳來對記錄或者對象進行標記,來避免ABA問題,比如可以使用時間戳。JDK的atomic包里提供了一個類AtomicStampedReference來解決ABA問題。
- 循環時間開銷大:不成功就會一直循環直到成功,如果長時間不成功會給CPU帶來非常大的執行開銷。如果JVM支持pause指令那么可以一定程度上減少開銷。
- 只能保證一個共享變量的原子操作:當對一個共享變量執行操作時,我們可以使用循環CAS的方式來保證原子操作,但是對多個共享變量操作時,循環CAS就無法保證操作的原子性,這個時候就可以用鎖,或者有一個取巧的辦法,就是把多個共享變量合並成一個共享變量來操作。比如有兩個共享變量i=2,j=a,合並一下ij=2a,然后用CAS來操作ij。從Java1.5開始JDK提供了AtomicReference類來保證引用對象之間的原子性,你可以把多個變量放在一個對象里來進行CAS操作。
CAS和synchronized的使用場景:
- 對於資源競爭較少(線程沖突較輕)的情況,使用synchronized同步鎖進行線程阻塞和喚醒切換以及用戶態內核態間的切換操作額外浪費消耗cpu資源;而CAS基於硬件實現,不需要進入內核,不需要切換線程,操作自旋幾率較少,因此可以獲得更高的性能。
- 對於資源競爭嚴重(線程沖突嚴重)的情況,CAS自旋的概率會比較大,從而浪費更多的CPU資源,效率低於synchronized。
補充: synchronized在jdk1.6之后,已經改進優化。synchronized的底層實現主要依靠Lock-Free的隊列,基本思路是自旋后阻塞,競爭切換后繼續競爭鎖,稍微犧牲了公平性,但獲得了高吞吐量。在線程沖突較少的情況下,可以獲得和CAS類似的性能;而線程沖突嚴重的情況下,性能遠高於CAS。
總結一下就是:線程沖突小的情況下使用CAS,線程沖突多的情況下使用synchronized。
參考:
五. 分段鎖
分段鎖是對鎖的一種優化,就是將鎖細分的粒度更多,比如將一個數組的每個位置當做單獨的鎖。JDK8以前ConcurrentHashMap就使用了鎖分段技術,它將散列數組分成多個Segment,每個Segment存儲了實際的數據,訪問數據的時候只需要對數據所在的Segment加鎖就行。
六. 偏向鎖/輕量級鎖/重量級鎖 /自旋鎖
重量級鎖是悲觀鎖的一種,自旋鎖,輕量級鎖和偏向鎖屬於樂觀鎖。
6.1 自旋鎖
在Java中,自旋鎖是指嘗試獲取鎖的線程不會立即阻塞,而是采用循環的方式去嘗試獲取鎖,這樣的好處是減少線程上下文切換的消耗,缺點是循環會消耗CPU。
自旋鎖的目的是為了占着CPU的資源不釋放,等到獲得鎖立即處理,但是如何選擇自選的執行時間呢?如果自選執行的時間太長,會有大量的線程處於自旋狀態占用CPU資源,進而會影響整體系統的性能,因此自旋的周期選擇額外重要。
JVM對於自旋周期的選擇,JDK1.5這個限度是寫死的,1.6引入了適應性自旋鎖,適應性自旋鎖意味着自旋的時間不再固定,而是由前一次在同一個鎖上的自旋時間以及鎖的擁有者的狀態決定,基本認為一個線程上下文切換的時間是最佳的一個時間,同時JVM還針對當前CPU的負載情況做了較多的優化:
-
如果平均負載小於CPUs則一直自旋
-
如果有超過(CPUs/2)個線程正在自旋,則后來線程直接阻塞
-
如果正在自旋的線程發現Owner發生了變化則延遲自旋時間(自旋計數)或進入阻塞
-
如果CPU處於節電模式則停止自旋
-
自旋時間的最壞情況的CPU的存儲延遲(CPU a存儲了一個數據,到CPU b得知這個數據直接的時間差)
-
自旋時會適當放棄線程優先級之間的差異
自旋鎖的開啟
JDK1.6中-XX:+UseSpinning開啟;
-XX:PreBlockSpin=10 為自旋次數;
JDK1.7后,去掉此參數,由jvm控制;
6.2 重量級鎖
重量級鎖的代表就是Synchronized。但是不能單純的說Synchronized就是重量級鎖,JDK1.6對Synchronized做了優化,Synchronized鎖有一個升級的過程,升級到最后才會變成重量級鎖。
6.3 偏向鎖
Java偏向鎖是java6引入的一項多線程優化。偏向鎖,顧名思義,它會偏向第一個訪問鎖的線程,如果在運行過程中,同步鎖只有一個線程訪問,不存在多線程爭用的情況,則線程是不需要觸發同步的,這種情況下,就會給線程加一個偏向鎖,它通過消除資源無競爭情況下的同步原語,進一步提高了程序的運行性能。如果在運行過程中,遇到了其他線程搶占鎖,則持有偏向鎖的線程會被掛起,JVM就會消除它身上的偏向鎖,將鎖恢復到標准的輕量級鎖。
偏向鎖的實現
偏向鎖的獲取:
- 訪問Mark Word中偏向鎖的標志是否設置為1,鎖標志位是否是01,確認為可偏向狀態。
- 如果為可偏向狀態,則測試線程id是否指向當前線程,如果是,進入步驟5,否則進入步驟3。
- 線程id並為指向當前線程,則通過CAS操作競爭鎖。如果競爭成功,則將Mark Word中線程id設置為當前線程id,然后執行5,如果執行失敗,執行4;
- 如果CAS獲取偏向鎖失敗,則表示有競爭。當到達全局安全點時獲得偏向鎖的線程被掛起,偏向鎖升級為輕量級鎖,然后被阻塞在安全點的線程繼續往下執行同步代碼;(撤銷偏向鎖的時候會導致stop the world)
- 執行同步代碼。
偏向鎖的釋放:
偏向鎖只有遇到其他線程嘗試競爭偏向鎖時,持有偏向鎖的線程才會釋放鎖,線程不會主動釋放偏向鎖,偏向鎖的撤銷,需要等待全局安全點(在這個時間點上沒有字節碼正在執行),它會首先暫停擁有偏向鎖的線程,判斷鎖對象是否處於被鎖定狀態,撤銷偏向鎖后恢復到未鎖定(標志位為01)或輕量級鎖(標志位為00)的狀態。
偏向鎖的適用場景
始終只有一個線程在執行同步塊,在它沒有執行完釋放鎖之前,沒有其它線程去執行同步塊,在鎖無競爭的情況下使用,一旦有了競爭就升級為輕量級鎖,升級為輕量級鎖的時候需要撤銷偏向鎖,撤銷偏向鎖的時候會導致stop the word操作;
在有鎖的競爭時,偏向鎖會多做很多額外操作,尤其是撤銷偏向所的時候會導致進入安全點,安全點會導致stw,導致性能下降,這種情況下應當禁用。
jvm開啟/關閉偏向鎖
開啟偏向鎖:-XX:+UseBiasedLocking -XX:BiasedLockingStartupDelay=0
關閉偏向鎖:-XX:-UseBiasedLocking
-XX:BiasedLockingStartupDelay=0 表示程序啟動0毫秒后激活,一般jVM默認會在程序啟動后4秒鍾之后才激活偏向鎖
6.4 輕量級鎖
加鎖過程
- 在代碼進入同步塊的時候,如果同步對象鎖狀態為無鎖狀態(鎖標志位為01,是否為偏向鎖為0),虛擬機首先將在當前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於存儲鎖對象目前的Mark Word的拷貝,官方稱之為Displaced Mark Word;
- 拷貝對象頭的Mark Word到鎖記錄中;
- 拷貝成功后,虛擬機將使用CAS操作嘗試將對象的Mark Word更新為指向Lock Record的指針,並將Lock Record里的owner指針指向Mark Word。如果更新成功,執行4,否則執行5;
- 更新成功后,那么這個線程就擁有了該對象的鎖,並且對象Mark Word的標志位設置為00,即表示此對象處於輕量級鎖定狀態;
- 如果更新失敗,虛擬機首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是就說明當前線程已經擁有了這個對象的鎖,那就直接進入同步代碼塊繼續執行,否則說明多個線程競爭鎖,輕量級鎖就要膨脹為重量級鎖,鎖標志的狀態值變為10,Mark Word中存儲的就是指向重量級鎖(互斥量)的指針,后面等待鎖的線程也要進入阻塞狀態。而當前線程便嘗試使用自旋來獲取鎖。
6.5 總結
優缺點對比
鎖 | 優點 | 缺點 | 適用場景 |
---|---|---|---|
偏向鎖 | 加鎖和解鎖不需要額外的消耗,和執行非同步方法相比僅存在納秒級的差距 | 如果線程間存在鎖競爭,會帶來額外的鎖撤銷的消耗 | 適用於只有一個線程訪問同步塊的場景 |
輕量級鎖 | 競爭的線程的不會阻塞,提高了程序的響應速度 | 如果始終得不到鎖競爭的線程,使用自旋會消耗CPU | 追求響應時間,同步塊執行速度非常快 |
重量級鎖 | 線程競爭不使用自旋,不會消耗CPU | 線程阻塞,響應時間慢 | 追求吞吐量,同步塊執行速度較長 |
參考: