【本人禿頂程式設計師】Java併發系列 | ReentrantLock原始碼分析
←←←←←←←←←←←← 我都禿頂了,還不點關注!
在Java5.0之前,協調對共享物件的訪問可以使用的機制只有synchronized和volatile。我們知道synchronized關鍵字實現了內建鎖,而volatile關鍵字保證了多執行緒的記憶體可見性。
在大多數情況下,這些機制都能很好地完成工作,但卻無法實現一些更高階的功能,例如,無法中斷一個正在等待獲取鎖的執行緒,無法實現限定時間的獲取鎖機制,無法實現非阻塞結構的加鎖規則等。
而這些更靈活的加鎖機制通常都能夠提供更好的活躍性或效能。因此,在Java5.0中增加了一種新的機制:ReentrantLock。
ReentrantLock類實現了Lock介面,並提供了與synchronized相同的互斥性和記憶體可見性,它的底層是通過AQS來實現多執行緒同步的。
與內建鎖相比ReentrantLock不僅提供了更豐富的加鎖機制,而且在效能上也不遜色於內建鎖(在以前的版本中甚至優於內建鎖)。
說了ReentrantLock這麼多的優點,那麼下面我們就來揭開它的原始碼看看它的具體實現。
1、synchronized關鍵字的介紹
Java提供了內建鎖來支援多執行緒的同步,JVM根據synchronized關鍵字來標識同步程式碼塊,當執行緒進入同步程式碼塊時會自動獲取鎖,退出同步程式碼塊時會自動釋放鎖,一個執行緒獲得鎖後其他執行緒將會被阻塞。
每個Java物件都可以用做一個實現同步的鎖,synchronized關鍵字可以用來修飾物件方法,靜態方法和程式碼塊,當修飾物件方法和靜態方法時鎖分別是方法所在的物件和Class物件,當修飾程式碼塊時需提供額外的物件作為鎖。
每個Java物件之所以可以作為鎖,是因為在物件頭中關聯了一個monitor物件(管程),執行緒進入同步程式碼塊時會自動持有monitor物件,退出時會自動釋放monitor物件,當monitor物件被持有時其他執行緒將會被阻塞。
當然這些同步操作都由JVM底層幫你實現了,但以synchronized關鍵字修飾的方法和程式碼塊在底層實現上還是有些區別的。
synchronized關鍵字修飾的方法是隱式同步的,即無需通過位元組碼指令來控制的,JVM可以根據方法表中的ACC_SYNCHRONIZED訪問標誌來區分一個方法是否是同步方法;
而synchronized關鍵字修飾的程式碼塊是顯式同步的,它是通過monitorenter和monitorexit位元組碼指令來控制執行緒對管程的持有和釋放。
monitor物件內部持有_count欄位,_count等於0表示管程未被持有,_count大於0表示管程已被持有,每次持有執行緒重入時_count都會加1,每次持有執行緒退出時_count都會減1,這就是內建鎖重入性的實現原理。
另外,monitor物件內部還有兩條佇列_EntryList和_WaitSet,對應著AQS的同步佇列和條件佇列,當執行緒獲取鎖失敗時會到_EntryList中阻塞,當呼叫鎖物件的wait方法時執行緒將會進入_WaitSet中等待,這是內建鎖的執行緒同步和條件等待的實現原理。
2、ReentrantLock和Synchronized的比較
synchronized關鍵字是Java提供的內建鎖機制,其同步操作由底層JVM實現,而ReentrantLock是java.util.concurrent包提供的顯式鎖,其同步操作由AQS同步器提供支援。
ReentrantLock在加鎖和記憶體上提供的語義與內建鎖相同,此外它還提供了一些其他功能,包括定時的鎖等待,可中斷的鎖等待,公平鎖,以及實現非塊結構的加鎖。
另外,在早期的JDK版本中ReentrantLock在效能上還佔有一定的優勢,既然ReentrantLock擁有這麼多優勢,為什麼還要使用synchronized關鍵字呢?
事實上確實有許多人使用ReentrantLock來替代synchronized關鍵字的加鎖操作。但是內建鎖仍然有它特有的優勢,內建鎖為許多開發人員所熟悉,使用方式也更加的簡潔緊湊,因為顯式鎖必須手動在finally塊中呼叫unlock,所以使用內建鎖相對來說會更加安全些。
同時未來更加可能會去提升synchronized而不是ReentrantLock的效能。因為synchronized是JVM的內建屬性,它能執行一些優化,例如對執行緒封閉的鎖物件的鎖消除優化,通過增加鎖的粒度來消除內建鎖的同步,而如果通過基於類庫的鎖來實現這些功能,則可能性不大。
所以當需要一些高階功能時才應該使用ReentrantLock,這些功能包括:可定時的,可輪詢的與可中斷的鎖獲取操作,公平佇列,以及非塊結構的鎖。否則,還是應該優先使用synchronized。
3、獲取鎖和釋放鎖的操作
我們首先來看一下使用ReentrantLock加鎖的示例程式碼。
public void doSomething() {
//預設是獲取一個非公平鎖
ReentrantLock lock = new ReentrantLock();
try{
//執行前先加鎖
lock.lock();
//執行操作...
}finally{
//最後釋放鎖
lock.unlock();
}
}
以下是獲取鎖和釋放鎖這兩個操作的API。
//獲取鎖的操作
public void lock() {
sync.lock();
}
//釋放鎖的操作
public void unlock() {
sync.release(1);
}
可以看到獲取鎖和釋放鎖的操作分別委託給Sync物件的lock方法和release方法。
public class ReentrantLock implements Lock, java.io.Serializable {
private final Sync sync;
abstract static class Sync extends AbstractQueuedSynchronizer {
abstract void lock();
}
//實現非公平鎖的同步器
static final class NonfairSync extends Sync {
final void lock() {
...
}
}
//實現公平鎖的同步器
static final class FairSync extends Sync {
final void lock() {
...
}
}
}
每個ReentrantLock物件都持有一個Sync型別的引用,這個Sync類是一個抽象內部類它繼承自AbstractQueuedSynchronizer,它裡面的lock方法是一個抽象方法。ReentrantLock的成員變數sync是在構造時賦值的,下面我們看看ReentrantLock的兩個構造方法都做了些什麼?
//預設無參構造器
public ReentrantLock() {
sync = new NonfairSync();
}
//有參構造器
public ReentrantLock(boolean fair) {
sync = fair ? new FairSync() : new NonfairSync();
}
呼叫預設無參構造器會將NonfairSync例項賦值給sync,此時鎖是非公平鎖。有參構造器允許通過引數來指定是將FairSync例項還是NonfairSync例項賦值給sync。
NonfairSync和FairSync都是繼承自Sync類並重寫了lock()方法,所以公平鎖和非公平鎖在獲取鎖的方式上有些區別,這個我們下面會講到。
再來看看釋放鎖的操作,每次呼叫unlock()方法都只是去執行sync.release(1)操作,這步操作會呼叫AbstractQueuedSynchronizer類的release()方法,我們再來回顧一下。
//釋放鎖的操作(獨佔模式)
public final boolean release(int arg) {
//撥動密碼鎖, 看看是否能夠開鎖
if (tryRelease(arg)) {
//獲取head結點
Node h = head;
//如果head結點不為空並且等待狀態不等於0就去喚醒後繼結點
if (h != null && h.waitStatus != 0) {
//喚醒後繼結點
unparkSuccessor(h);
}
return true;
}
return false;
}
這個release方法是AQS提供的釋放鎖操作的API,它首先會去呼叫tryRelease方法去嘗試獲取鎖,tryRelease方法是抽象方法,它的實現邏輯在子類Sync裡面。
//嘗試釋放鎖
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
//如果持有鎖的執行緒不是當前執行緒就丟擲異常
if (Thread.currentThread() != getExclusiveOwnerThread()) {
throw new IllegalMonitorStateException();
}
boolean free = false;
//如果同步狀態為0則表明鎖被釋放
if (c == 0) {
//設定鎖被釋放的標誌為真
free = true;
//設定佔用執行緒為空
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
這個tryRelease方法首先會獲取當前同步狀態,並將當前同步狀態減去傳入的引數值得到新的同步狀態,然後判斷新的同步狀態是否等於0,如果等於0則表明當前鎖被釋放,然後先將鎖的釋放狀態置為真,再將當前佔有鎖的執行緒清空,最後呼叫setState方法設定新的同步狀態並返回鎖的釋放狀態。
4、公平鎖和非公平鎖
我們知道ReentrantLock是公平鎖還是非公平鎖是基於sync指向的是哪個具體例項。
在構造時會為成員變數sync賦值,如果賦值為NonfairSync例項則表明是非公平鎖,如果賦值為FairSync例項則表明為公平鎖。
如果是公平鎖,執行緒將按照它們發出請求的順序來獲得鎖,但在非公平鎖上,則允許插隊行為:當一個執行緒請求非公平的鎖時,如果在發出請求的同時該鎖的狀態變為可用,那麼這個執行緒將跳過佇列中所有等待的執行緒直接獲得這個鎖。
下面我們先看看非公平鎖的獲取方式。
//非公平同步器
static final class NonfairSync extends Sync {
//實現父類的抽象獲取鎖的方法
final void lock() {
//使用CAS方式設定同步狀態
if (compareAndSetState(0, 1)) {
//如果設定成功則表明鎖沒被佔用
setExclusiveOwnerThread(Thread.currentThread());
} else {
//否則表明鎖已經被佔用, 呼叫acquire讓執行緒去同步佇列排隊獲取
acquire(1);
}
}
//嘗試獲取鎖的方法
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
//以不可中斷模式獲取鎖(獨佔模式)
public final void acquire(int arg) {
if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) {
selfInterrupt();
}
}
可以看到在非公平鎖的lock方法中,執行緒第一步就會以CAS方式將同步狀態的值從0改為1。其實這步操作就等於去嘗試獲取鎖,如果更改成功則表明執行緒剛來就獲取了鎖,而不必再去同步佇列裡面排隊了。
如果更改失敗則表明執行緒剛來時鎖還未被釋放,所以接下來就呼叫acquire方法。
我們知道這個acquire方法是繼承自AbstractQueuedSynchronizer的方法,現在再來回顧一下該方法,執行緒進入acquire方法後首先去呼叫tryAcquire方法嘗試去獲取鎖,由於NonfairSync覆蓋了tryAcquire方法,並在方法中呼叫了父類Sync的nonfairTryAcquire方法,所以這裡會呼叫到nonfairTryAcquire方法去嘗試獲取鎖。
我們看看這個方法具體做了些什麼:
//非公平的獲取鎖
final boolean nonfairTryAcquire(int acquires) {
//獲取當前執行緒
final Thread current = Thread.currentThread();
//獲取當前同步狀態
int c = getState();
//如果同步狀態為0則表明鎖沒有被佔用
if (c == 0) {
//使用CAS更新同步狀態
if (compareAndSetState(0, acquires)) {
//設定目前佔用鎖的執行緒
setExclusiveOwnerThread(current);
return true;
}
//否則的話就判斷持有鎖的是否是當前執行緒
}else if (current == getExclusiveOwnerThread()) {
//如果鎖是被當前執行緒持有的, 就直接修改當前同步狀態
int nextc = c + acquires;
if (nextc < 0) {
throw new Error("Maximum lock count exceeded");
}
setState(nextc);
return true;
}
//如果持有鎖的不是當前執行緒則返回失敗標誌
return false;
}
nonfairTryAcquire方法是Sync的方法,我們可以看到執行緒進入此方法後首先去獲取同步狀態,如果同步狀態為0就使用CAS操作更改同步狀態,其實這又是獲取了一遍鎖。
如果同步狀態不為0表明鎖被佔用,此時會先去判斷持有鎖的執行緒是否是當前執行緒,如果是的話就將同步狀態加1,否則的話這次嘗試獲取鎖的操作宣告失敗。於是會呼叫addWaiter方法將執行緒新增到同步佇列。
綜上來看,在非公平鎖的模式下一個執行緒在進入同步佇列之前會嘗試獲取兩遍鎖,如果獲取成功則不進入同步佇列排隊,否則才進入同步佇列排隊。
接下來我們看看公平鎖的獲取方式:
//實現公平鎖的同步器
static final class FairSync extends Sync {
//實現父類的抽象獲取鎖的方法
final void lock() {
//呼叫acquire讓執行緒去同步佇列排隊獲取
acquire(1);
}
//嘗試獲取鎖的方法
protected final boolean tryAcquire(int acquires) {
//獲取當前執行緒
final Thread current = Thread.currentThread();
//獲取當前同步狀態
int c = getState();
//如果同步狀態0則表示鎖沒被佔用
if (c == 0) {
//判斷同步佇列是否有前繼結點
if (!hasQueuedPredecessors() && compareAndSetState(0, acquires)) {
//如果沒有前繼結點且設定同步狀態成功就表示獲取鎖成功
setExclusiveOwnerThread(current);
return true;
}
//否則判斷是否是當前執行緒持有鎖
}else if (current == getExclusiveOwnerThread()) {
//如果是當前執行緒持有鎖就直接修改同步狀態
int nextc = c + acquires;
if (nextc < 0) {
throw new Error("Maximum lock count exceeded");
}
setState(nextc);
return true;
}
//如果不是當前執行緒持有鎖則獲取失敗
return false;
}
}
呼叫公平鎖的lock方法時會直接呼叫acquire方法。同樣的,acquire方法首先會呼叫FairSync重寫的tryAcquire方法來嘗試獲取鎖。
在該方法中也是首先獲取同步狀態的值,如果同步狀態為0則表明此時鎖剛好被釋放,這時和非公平鎖不同的是它會先去呼叫hasQueuedPredecessors方法查詢同步佇列中是否有人在排隊,如果沒人在排隊才會去修改同步狀態的值,可以看到公平鎖在這裡採取禮讓的方式而不是自己馬上去獲取鎖。
除了這一步和非公平鎖不一樣之外,其他的操作都是一樣的。綜上所述,可以看到公平鎖在進入同步佇列之前只檢查了一遍鎖的狀態,即使是發現了鎖是開的也不會自己馬上去獲取,而是先讓同步佇列中的執行緒先獲取,所以可以保證在公平鎖下所有執行緒獲取鎖的順序都是先來後到的,這也保證了獲取鎖的公平性。
那麼我們為什麼不希望所有鎖都是公平的呢?
畢竟公平是一種好的行為,而不公平是一種不好的行為。由於執行緒的掛起和喚醒操作存在較大的開銷而影響系統性能,特別是在競爭激烈的情況下公平鎖將導致執行緒頻繁的掛起和喚醒操作,而非公平鎖可以減少這樣的操作,所以在效能上將會優於公平鎖。
另外,由於大部分執行緒使用鎖的時間都是非常短暫的,而執行緒的喚醒操作會存在延時情況,有可能在A執行緒被喚醒期間B執行緒馬上獲取了鎖並使用完釋放了鎖,這就導致了雙贏的局面,A執行緒獲取鎖的時刻並沒有推遲,但B執行緒提前使用了鎖,並且吞吐量也獲得了提高。
5、條件佇列的實現機制
內建條件佇列存在一些缺陷,每個內建鎖都只能有一個相關聯的條件佇列,這導致多個執行緒可能在同一個條件佇列上等待不同的條件謂詞,那麼每次呼叫notifyAll時都會將所有等待的執行緒喚醒,當執行緒醒來後發現並不是自己等待的條件謂詞,轉而又會被掛起。
這導致做了很多無用的執行緒喚醒和掛起操作,而這些操作將會大量浪費系統資源,降低系統的效能。
如果想編寫一個帶有多個條件謂詞的併發物件,或者想獲得除了條件佇列可見性之外的更多控制權,就需要使用顯式的Lock和Condition而不是內建鎖和條件佇列。
一個Condition和一個Lock關聯在一起,就像一個條件佇列和一個內建鎖相關聯一樣。要建立一個Condition,可以在相關聯的Lock上呼叫Lock.newCondition方法。我們先來看一個使用Condition的示例。
public class BoundedBuffer {
final Lock lock = new ReentrantLock();
final Condition notFull = lock.newCondition(); //條件謂詞:notFull
final Condition notEmpty = lock.newCondition(); //條件謂詞:notEmpty
final Object[] items = new Object[100];
int putptr, takeptr, count;
//生產方法
public void put(Object x) throws InterruptedException {
lock.lock();
try {
while (count == items.length)
notFull.await(); //佇列已滿, 執行緒在notFull佇列上等待
items[putptr] = x;
if (++putptr == items.length) putptr = 0;
++count;
notEmpty.signal(); //生產成功, 喚醒notEmpty佇列的結點
} finally {
lock.unlock();
}
}
//消費方法
public Object take() throws InterruptedException {
lock.lock();
try {
while (count == 0)
notEmpty.await(); //佇列為空, 執行緒在notEmpty佇列上等待
Object x = items[takeptr];
if (++takeptr == items.length) takeptr = 0;
--count;
notFull.signal(); //消費成功, 喚醒notFull佇列的結點
return x;
} finally {
lock.unlock();
}
}
}
一個lock物件可以產生多個條件佇列,這裡產生了兩個條件佇列notFull和notEmpty。當容器已滿時再呼叫put方法的執行緒需要進行阻塞,等待條件謂詞為真(容器不滿)才醒來繼續執行;
當容器為空時再呼叫take方法的執行緒也需要阻塞,等待條件謂詞為真(容器不空)才醒來繼續執行。
這兩類執行緒是根據不同的條件謂詞進行等待的,所以它們會進入兩個不同的條件佇列中阻塞,等到合適時機再通過呼叫Condition物件上的API進行喚醒。下面是newCondition方法的實現程式碼。
//建立條件佇列
public Condition newCondition() {
return sync.newCondition();
}
abstract static class Sync extends AbstractQueuedSynchronizer {
//新建Condition物件
final ConditionObject newCondition() {
return new ConditionObject();
}
}
ReentrantLock上的條件佇列的實現都是基於AbstractQueuedSynchronizer的,我們在呼叫newCondition方法時所獲得的Condition物件就是AQS的內部類ConditionObject的例項。
所有對條件佇列的操作都是通過呼叫ConditionObject對外提供的API來完成的。
寫在最後:
禿頂程式設計師的不易,看到這裡,點了關注吧!
點關注,不迷路,持續更新!!!