導論
Java 中的併發鎖大致分為隱式鎖和顯式鎖兩種。
隱式鎖就是我們最常使用的 synchronized 關鍵字,顯式鎖主要包含兩個介面:Lock 和 ReadWriteLock,主要實現類分別為 ReentrantLock 和 ReentrantReadWriteLock,
這兩個類都是基於 AQS(AbstractQueuedSynchronizer) 實現的。還有的地方將 CAS 也稱為一種鎖,在包括 AQS 在內的很多併發相關類中,CAS 都扮演了很重要的角色。
就是一些名詞的概念,知道他們是什麼中文意思,知道理解意思之後在學習程式碼的實現就比較輕鬆了。
這個文章也算帶著看了 AbstractQueuedSynchronizer 和 ReentrantReadWriteLock 和 ReentrantLock 的原始碼。
相關閱讀:
CAS(https://www.cnblogs.com/zwtblog/p/15129821.html#cas)
悲觀鎖和樂觀鎖
悲觀鎖和獨佔鎖是一個意思,
它假設一定會發生衝突,因此獲取到鎖之後會阻塞其他等待執行緒。
這麼做的好處是簡單安全,但是掛起執行緒和恢復執行緒都需要轉入核心態進行,這樣做會帶來很大的效能開銷。
悲觀鎖的代表是 synchronized。
然而在真實環境中,大部分時候都不會產生衝突。悲觀鎖會造成很大的浪費。
而樂觀鎖不一樣,它假設不會產生衝突,先去嘗試執行某項操作,失敗了再進行其他處理(一般都是不斷迴圈重試)。
這種鎖不會阻塞其他的執行緒,也不涉及上下文切換,效能開銷小。代表實現是 CAS。
公平鎖和非公平鎖
公平鎖是指各個執行緒在加鎖前先檢查有無排隊的執行緒,按排隊順序去獲得鎖。
非公平鎖是指執行緒加鎖前不考慮排隊問題,直接嘗試獲取鎖,獲取不到再去隊尾排隊。
值得注意的是,在 AQS 的實現中,一旦執行緒進入排隊佇列,即使是非公平鎖,執行緒也得乖乖排隊。
可重入鎖和不可重入鎖
如果一個執行緒已經獲取到了一個鎖,那麼它可以訪問被這個鎖鎖住的所有程式碼塊。
不可重入鎖與之相反。
Synchronized 關鍵字
Synchronized 是一種獨佔鎖。
在修飾靜態方法時,鎖的是類,如 Object.class。
修飾非靜態方法時,鎖的是物件,即 this。修飾方法塊時,鎖的是括號裡的物件。
每個物件有一個鎖和一個等待佇列,鎖只能被一個執行緒持有,其他需要鎖的執行緒需要阻塞等待。
鎖被釋放後,物件會從佇列中取出一個並喚醒,喚醒哪個執行緒是不確定的,不保證公平性。
實現原理
synchronized 是基於 Java 物件頭和 Monitor 機制來實現的。
Java 物件頭
一個物件在記憶體中包含三部分:物件頭,例項資料和對齊填充。
其中 Java 物件頭包含兩部分:
- Class Metadata Address (型別指標)。儲存類的後設資料的指標。虛擬機器通過這個指標找到它是哪個類的例項。
- Mark Word(標記欄位)。存出一些物件自身執行時的資料。包括雜湊碼,GC 分代年齡,鎖狀態標誌等。
Monitor
Mark Word 有一個欄位指向 monitor 物件。
monitor 中記錄了鎖的持有執行緒,等待的執行緒佇列等資訊。
前面說的每個物件都有一個鎖和一個等待佇列,就是在這裡實現的。 monitor 物件由 C++ 實現。其中有三個關鍵欄位:
- _owner 記錄當前持有鎖的執行緒
- _EntryList 是一個佇列,記錄所有阻塞等待鎖的執行緒
- _WaitSet 也是一個佇列,記錄呼叫 wait() 方法並還未被通知的執行緒。
Monitor的操作機制如下:
- 多個執行緒競爭鎖時,會先進入 EntryList 佇列。競爭成功的執行緒被標記為 Owner。其他執行緒繼續在此佇列中阻塞等待。
- 如果 Owner 執行緒呼叫 wait() 方法,則其釋放物件鎖並進入 WaitSet 中等待被喚醒。Owner 被置空,EntryList 中的執行緒再次競爭鎖。
- 如果 Owner 執行緒執行完了,便會釋放鎖,Owner 被置空,EntryList 中的執行緒再次競爭鎖。
JVM 對 synchronized 的處理
上面瞭解了 monitor 的機制,那虛擬機器是如何將 synchronized 和 monitor 關聯起來的呢?
分兩種情況:
- 如果同步的是程式碼塊,編譯時會直接在同步程式碼塊前加上 monitorenter 指令,程式碼塊後加上 monitorexit 指令。這稱為顯示同步。
- 如果同步的是方法,虛擬機器會為方法設定 ACC_SYNCHRONIZED 標誌。呼叫的時候 JVM 根據這個標誌判斷是否是同步方法。
JVM 對 synchronized 的優化
synchronized 是重量級鎖,由於消耗太大,虛擬機器對其做了一些優化。
自旋鎖與自適應自旋
在許多應用中,鎖定狀態只會持續很短的時間,為了這麼一點時間去掛起恢復執行緒,不值得。
我們可以讓等待執行緒執行一定次數的迴圈,在迴圈中去獲取鎖。這項技術稱為自旋鎖,它可以節省系統切換執行緒的消耗,但仍然要佔用處理器。
在 JDK1.4.2 中,自選的次數可以通過引數來控制。 JDK 1.6又引入了自適應的自旋鎖,不再通過次數來限制,而是由前一次在同一個鎖上的自旋時間及鎖的擁有者的狀態來決定。
鎖消除
虛擬機器在執行時,如果發現一段被鎖住的程式碼中不可能存在共享資料,就會將這個鎖清除。
鎖粗化
當虛擬機器檢測到有一串零碎的操作都對同一個物件加鎖時,會把鎖擴充套件到整個操作序列外部。如 StringBuffer 的 append 操作。
輕量級鎖
對絕大部分的鎖來說,在整個同步週期內都不存在競爭。如果沒有競爭,輕量級鎖可以使用 CAS 操作避免使用互斥量的開銷。
偏向鎖
偏向鎖的核心思想是,如果一個執行緒獲得了鎖,那麼鎖就進入偏向模式,當這個執行緒再次請求鎖時,無需再做任何同步操作,即可獲取鎖。
CAS
操作模型
CAS 是 compare and swap 的簡寫,即比較並交換。
它是指一種操作機制,而不是某個具體的類或方法。
在 Java 平臺上對這種操作進行了包裝。在 Unsafe 類中,呼叫程式碼如下:
unsafe.compareAndSwapInt(this, valueOffset, expect, update);
它需要三個引數,分別是記憶體位置 V,舊的預期值 A 和新的值 B。
操作時,先從記憶體位置讀取到值,然後和預期值A比較。如果相等,則將此記憶體位置的值改為新值 B,返回 true。如果不相等,說明和其他執行緒衝突了,則不做任何改變,返回 false。
這種機制在不阻塞其他執行緒的情況下避免了併發衝突,比獨佔鎖的效能高很多。 CAS 在 Java 的原子類和併發包中有大量使用。
重試機制(迴圈 CAS)
第一,CAS 本身並未實現失敗後的處理機制,它只負責返回成功或失敗的布林值,後續由呼叫者自行處理。只不過我們最常用的處理方式是重試而已。
第二,這句話很容易理解錯,被理解成重新比較並交換。實際上失敗的時候,原值已經被修改,如果不更改期望值,再怎麼比較都會失敗。而新值同樣需要修改。
所以正確的方法是,使用一個死迴圈進行 CAS 操作,成功了就結束迴圈返回,失敗了就重新從記憶體讀取值和計算新值,再呼叫 CAS。看下 AtomicInteger 的原始碼就什麼都懂了:
public final int incrementAndGet () {
for (;;) {
int current = get();
int next = current + 1;
if (compareAndSet(current, next))
return next;
}
}
底層實現
CAS 主要分三步,讀取-比較-修改。
其中比較是在檢測是否有衝突,如果檢測到沒有衝突後,其他執行緒還能修改這個值,那麼 CAS 還是無法保證正確性。所以最關鍵的是要保證比較-修改這兩步操作的原子性。
CAS 底層是靠呼叫 CPU 指令集的 cmpxchg 完成的,它是 x86 和 Intel 架構中的 compare and exchange 指令。在多核的情況下,這個指令也不能保證原子性,需要在前面加上 lock 指令。
lock 指令可以保證一個 CPU 核心在操作期間獨佔一片記憶體區域。那麼 這又是如何實現的呢?
在處理器中,一般有兩種方式來實現上述效果:匯流排鎖和快取鎖。
在多核處理器的結構中,CPU 核心並不能直接訪問記憶體,而是統一通過一條匯流排訪問。
匯流排鎖就是鎖住這條匯流排,使其他核心無法訪問記憶體。這種方式代價太大了,會導致其他核心停止工作。
而快取鎖並不鎖定匯流排,只是鎖定某部分記憶體區域。當一個 CPU 核心將記憶體區域的資料讀取到自己的快取區後,它會鎖定快取對應的記憶體區域。鎖住期間,其他核心無法操作這塊記憶體區域。
CAS 就是通過這種方式實現比較和交換操作的原子性的。
值得注意的是, CAS 只是保證了操作的原子性,並不保證變數的可見性,因此變數需要加上 volatile 關鍵字。
ABA問題?
比如是有兩個執行緒A,B ,一個變數:蘋果
A執行緒期望拿到一個蘋果
B執行緒一進來把蘋果改成了梨子,但是在最後結束的時候又把梨子換成了蘋果
A執行緒在此期間是不知情的,以為自己拿到的蘋果還是原來的那一個,其實已經被換過了
如何解決ABA問題?
原子引用-解決ABA問題
AtomicStampedReference
類似於樂觀鎖,比較時會去對比版本號,確認變數是否被換過了。
可重入鎖 ReentrantLock
ReentrantLock 使用程式碼實現了和 synchronized 一樣的語義,包括可重入,保證記憶體可見性和解決競態條件問題等。相比 synchronized,它還有如下好處:
- 支援以非阻塞方式獲取鎖
- 可以響應中斷
- 可以限時
- 支援了公平鎖和非公平鎖
基本用法如下:
public class Counter {
private final Lock lock = new ReentrantLock();
private volatile int count;
public void incr() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
return count;
}
}
ReentrantLock 內部有兩個內部類,分別是 FairSync 和 NoFairSync,對應公平鎖和非公平鎖。他們都繼承自 Sync。Sync 又繼承自AQS。
AQS
AQS全稱AbstractQueuedSynchronizer
,即抽象的佇列同步器,是一種用來構建鎖和同步器的框架。
AQS 中有兩個重要的成員:
-
成員變數 state。用於表示鎖現在的狀態,用 volatile 修飾,保證記憶體一致性。
同時所用對 state 的操作都是使用 CAS 進行的。
state 為0表示沒有任何執行緒持有這個鎖,執行緒持有該鎖後將 state 加1,釋放時減1。
多次持有釋放則多次加減。
-
還有一個雙向連結串列,連結串列除了頭結點外,每一個節點都記錄了執行緒的資訊,代表一個等待執行緒。這是一個 FIFO 的連結串列。
下面以 ReentrantLock 非公平鎖的程式碼看看 AQS 的原理。
AQS中Node常量含義
CANCELLED
waitStatus值為1時表示該執行緒節點已釋放(超時、中斷),已取消的節點不會再阻塞。
SIGNAL
waitStatus為-1時表示該執行緒的後續執行緒需要阻塞,即只要前置節點釋放鎖,就會通知標識為 SIGNAL 狀態的後續節點的執行緒
CONDITION
waitStatus為-2時,表示該執行緒在condition佇列中阻塞(Condition有使用)
PROPAGATE
waitStatus為-3時,表示該執行緒以及後續執行緒進行無條件傳播(CountDownLatch中有使用)共享模式下, PROPAGATE 狀態的執行緒處於可執行狀態
Condition佇列
除了同步佇列之外,AQS中還存在Condition佇列,這是一個單向佇列。
呼叫ConditionObject.await()方法,能夠將當前執行緒封裝成Node加入到Condition佇列的末尾,
然後將獲取的同步狀態釋放(即修改同步狀態的值,喚醒在同步佇列中的執行緒)。
Condition佇列也是FIFO。呼叫ConditionObject.signal()方法,能夠喚醒firstWaiter節點,將其新增到同步佇列末尾。
獨佔模式下的AQS
所謂獨佔模式,即只允許一個執行緒獲取同步狀態,當這個執行緒還沒有釋放同步狀態時,其他執行緒是獲取不了的,只能加入到同步佇列,進行等待。
很明顯,我們可以將state的初始值設為0,表示空閒。
當一個執行緒獲取到同步狀態時,利用CAS操作讓state加1,表示非空閒,那麼其他執行緒就只能等待了。
釋放同步狀態時,不需要CAS操作,因為獨佔模式下只有一個執行緒能獲取到同步狀態。
ReentrantLock、CyclicBarrier正是基於此設計的。
例如,ReentrantLock,state初始化為0,表示未鎖定狀態。
A執行緒lock()時,會呼叫tryAcquire()獨佔該鎖並將state+1
獨佔模式下的AQS是不響應中斷的,指的是加入到同步佇列中的執行緒,如果因為中斷而被喚醒的話,
不會立即返回,並且丟擲InterruptedException。
而是再次去判斷其前驅節點是否為head節點,決定是否爭搶同步狀態。
如果其前驅節點不是head節點或者爭搶同步狀態失敗,那麼再次掛起。
請求鎖
請求鎖時有三種可能:
- 如果沒有執行緒持有鎖,則請求成功,當前執行緒直接獲取到鎖。
- 如果當前執行緒已經持有鎖,則使用 CAS 將 state 值加1,表示自己再次申請了鎖,釋放鎖時減1。這就是可重入性的實現。
- 如果由其他執行緒持有鎖,那麼將自己新增進等待佇列。
/**
* Sync object for non-fair locks
*/
static final class NonfairSync extends Sync {
private static final long serialVersionUID = 7316153563782823691L;
/**
* Performs lock. Try immediate barge, backing up to normal
* acquire on failure.
*/
final void lock() {
if (compareAndSetState(0, 1))
//沒有執行緒持有鎖時,直接獲取鎖,對應情況1
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
protected final boolean tryAcquire(int acquires) {
return nonfairTryAcquire(acquires);
}
}
/**以獨佔模式獲取,忽略中斷。通過至少呼叫一次 {@link tryAcquire} 實現,成功返回。
否則執行緒會排隊,可能會反覆阻塞和解除阻塞,呼叫 {@link tryAcquire} 直到成功。
該方法可用於實現方法{@link Locklock}。
@param arg 獲取引數。這個值被傳送到 {@link tryAcquire},但不會被解釋,可以代表任何你喜歡的東西。*/
public final void acquire(int arg) {
if (!tryAcquire(arg) &&//在此方法中會判斷當前持有執行緒是否等於自己,對應情況2
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))//將自己加入佇列中,對應情況3
selfInterrupt();
}
建立 Node 節點並加入連結串列
如果沒競爭到鎖,這時候就要進入等待佇列。
佇列是預設有一個 head 節點的,並且不包含執行緒資訊。
上面情況3中,addWaiter 會建立一個 Node,並新增到連結串列的末尾,Node 中持有當前執行緒的引用。同時還有一個成員變數 waitStatus,表示執行緒的等待狀態,初始值為0。我們還需要關注兩個值:
- CANCELLED,cancelled,值為1,表示取消狀態,就是說我不要這個鎖了,請你把我移出去。
- SINGAL,singal,值為-1,表示下一個節點正在掛起等待,注意是下一個節點,不是當前節點。
同時,加到連結串列末尾的操作使用了 CAS+死迴圈的模式,很有代表性,拿出來看一看:
/**
AbstractQueuedSynchronizer
為當前執行緒和給定模式建立和排隊節點。
@param mode Node.EXCLUSIVE 表示獨佔,Node.SHARED 表示共享 @return 新節點
*/
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
// Try the fast path of enq; backup to full enq on failure
Node pred = tail;
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
enq(node);
return node;
}
可以看到,在死迴圈裡呼叫了 CAS 的方法。
如果多個執行緒同時呼叫該方法,那麼每次迴圈都只有一個執行緒執行成功,其他執行緒進入下一次迴圈,重新呼叫。
N個執行緒就會迴圈N次。這樣就在無鎖的模式下實現了併發模型。
掛起等待
- 如果此節點的上一個節點是頭部節點,則再次嘗試獲取鎖,獲取到了就移除並返回。獲取不到就進入下一步;
- 判斷前一個節點的 waitStatus,如果是 SINGAL,則返回 true,並呼叫 LockSupport.park() 將執行緒掛起;
- 如果是 CANCELLED,則將前一個節點移除;
- 如果是其他值,則將前一個節點的 waitStatus 標記為 SINGAL,進入下一次迴圈。
可以看到,一個執行緒最多有兩次機會,還競爭不到就去掛起等待。
/**
以獨佔不間斷模式獲取已在佇列中的執行緒。
由條件等待方法以及獲取使用。
@param node 節點
@param arg 獲取引數
@return {@code true} 如果在等待時中斷
*/
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
釋放鎖
- 呼叫 tryRelease,此方法由子類實現。實現非常簡單,如果當前執行緒是持有鎖的執行緒,就將 state 減1。減完後如果 state 大於0,表示當前執行緒仍然持有鎖,返回 false。如果等於0,表示已經沒有執行緒持有鎖,返回 true,進入下一步;
- 如果頭部節點的 waitStatus 不等於0,則呼叫LockSupport.unpark()喚醒其下一個節點。頭部節點的下一個節點就是等待佇列中的第一個執行緒,這反映了 AQS 先進先出的特點。另外,即使是非公平鎖,進入佇列之後,還是得按順序來。
public final boolean release(int arg) {
if (tryRelease(arg)) { //將 state 減1
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
node.compareAndSetWaitStatus(ws, 0);
Node s = node.next;
if (s == null || s.waitStatus > 0) {
s = null;
for (Node p = tail; p != node && p != null; p = p.prev)
if (p.waitStatus <= 0)
s = p;
}
if (s != null) //喚醒第一個等待的執行緒
LockSupport.unpark(s.thread);
}
公平鎖如何實現
上面分析的是非公平鎖,那公平鎖呢?很簡單,在競爭鎖之前判斷一下等待佇列中有沒有執行緒在等待就行了。
protected final boolean tryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (!hasQueuedPredecessors() && //判斷等待佇列是否有節點
compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
......
return false;
}
可重入讀寫鎖 ReentrantReadWriteLock
讀寫鎖機制
理解 ReentrantLock 和 AQS 之後,再來理解讀寫鎖就很簡單了。
讀寫鎖有一個讀鎖和一個寫鎖,分別對應讀操作和鎖操作。
鎖的特性如下:
- 只有一個執行緒可以獲取到寫鎖。在獲取寫鎖時,只有沒有任何執行緒持有任何鎖才能獲取成功;
- 如果有執行緒正持有寫鎖,其他任何執行緒都獲取不到任何鎖;
- 沒有執行緒持有寫鎖時,可以有多個執行緒獲取到讀鎖。
實現原理
讀寫鎖雖然有兩個鎖,但實際上只有一個等待佇列。
- 獲取寫鎖時,要保證沒有任何執行緒持有鎖;
- 寫鎖釋放後,會喚醒佇列第一個執行緒,可能是讀鎖和寫鎖;
- 獲取讀鎖時,先判斷寫鎖有沒有被持有,沒有就可以獲取成功;
- 獲取讀鎖成功後,會將佇列中等待讀鎖的執行緒挨個喚醒,知道遇到等待寫鎖的執行緒位置;
- 釋放讀鎖時,要檢查讀鎖數,如果為0,則喚醒佇列中的下一個執行緒,否則不進行操作。