從 synchronized 到 CAS 和 AQS - 徹底弄懂 Java 各種併發鎖
概述
Java 中的併發鎖大致分為隱式鎖和顯式鎖兩種。隱式鎖就是我們最常使用的 synchronized 關鍵字,顯式鎖主要包含兩個介面:Lock 和 ReadWriteLock,主要實現類分別為 ReentrantLock 和 ReentrantReadWriteLock,這兩個類都是基於 AQS(AbstractQueuedSynchronizer) 實現的。還有的地方將 CAS 也稱為一種鎖,在包括 AQS 在內的很多併發相關類中,CAS 都扮演了很重要的角色。
我們只需要弄清楚 synchronized 和 AQS 的原理,再去理解併發鎖的性質和侷限就很簡單了。因此這篇文章重點放在原理上,對於使用和特點不會過多涉及。
概念辨析
下面是關於鎖的一些概念解釋,這些都是一些關於鎖的性質的描述,並非具體實現。
悲觀鎖和樂觀鎖
悲觀鎖和獨佔鎖是一個意思,它假設一定會發生衝突,因此獲取到鎖之後會阻塞其他等待執行緒。這麼做的好處是簡單安全,但是掛起執行緒和恢復執行緒都需要轉入核心態進行,這樣做會帶來很大的效能開銷。悲觀鎖的代表是 synchronized。然而在真實環境中,大部分時候都不會產生衝突。悲觀鎖會造成很大的浪費。而樂觀鎖不一樣,它假設不會產生衝突,先去嘗試執行某項操作,失敗了再進行其他處理(一般都是不斷迴圈重試)。這種鎖不會阻塞其他的執行緒,也不涉及上下文切換,效能開銷小。代表實現是 CAS。
公平鎖和非公平鎖
公平鎖是指各個執行緒在加鎖前先檢查有無排隊的執行緒,按排隊順序去獲得鎖。 非公平鎖是指執行緒加鎖前不考慮排隊問題,直接嘗試獲取鎖,獲取不到再去隊尾排隊。值得注意的是,在 AQS 的實現中,一旦執行緒進入排隊佇列,即使是非公平鎖,執行緒也得乖乖排隊。
可重入鎖和不可重入鎖
如果一個執行緒已經獲取到了一個鎖,那麼它可以訪問被這個鎖鎖住的所有程式碼塊。不可重入鎖與之相反。
Synchronized 關鍵字
Synchronized 是一種獨佔鎖。在修飾靜態方法時,鎖的是類物件,如 Object.class。修飾非靜態方法時,鎖的是物件,即 this。修飾方法塊時,鎖的是括號裡的物件。 每個物件有一個鎖和一個等待佇列,鎖只能被一個執行緒持有,其他需要鎖的執行緒需要阻塞等待。鎖被釋放後,物件會從佇列中取出一個並喚醒,喚醒哪個執行緒是不確定的,不保證公平性。
類鎖與物件鎖
synchronized 修飾靜態方法時,鎖的是類物件,如 Object.class。修飾非靜態方法時,鎖的是物件,即 this。 多個執行緒是可以同時執行同一個synchronized例項方法的,只要它們訪問的物件是不同的。
synchronized 鎖住的是物件而非程式碼,只要訪問的是同一個物件的 synchronized 方法,即使是不同的程式碼,也會被同步順序訪問。
此外,需要說明的,synchronized方法不能防止非synchronized方法被同時執行,所以,一般在保護變數時,需要在所有訪問該變數的方法上加上synchronized。
實現原理
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 操作,成功了就結束迴圈返回,失敗了就重新從記憶體讀取值和計算新值,再呼叫 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 問題
上面提到,CAS 保證了比較和交換的原子性。但是從讀取到開始比較這段期間,其他核心仍然是可以修改這個值的。如果核心將 A 修改為 B,CAS 可以判斷出來。但是如果核心將 A 修改為 B 再修改回 A。那麼 CAS 會認為這個值並沒有被改變,從而繼續操作。這是和實際情況不符的。解決方案是加一個版本號。
可重入鎖 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 的原理。
請求鎖
請求鎖時有三種可能:
- 如果沒有執行緒持有鎖,則請求成功,當前執行緒直接獲取到鎖。
- 如果當前執行緒已經持有鎖,則使用 CAS 將 state 值加1,表示自己再次申請了鎖,釋放鎖時減1。這就是可重入性的實現。
- 如果由其他執行緒持有鎖,那麼將自己新增進等待佇列。
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread()); //沒有執行緒持有鎖時,直接獲取鎖,對應情況1
else
acquire(1);
}
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,值為1,表示取消狀態,就是說我不要這個鎖了,請你把我移出去。
- SINGAL,值為-1,表示下一個節點正在掛起等待,注意是下一個節點,不是當前節點。
同時,加到連結串列末尾的操作使用了 CAS+死迴圈的模式,很有代表性,拿出來看一看:
Node node = new Node(mode);
for (;;) {
Node oldTail = tail;
if (oldTail != null) {
U.putObject(node, Node.PREV, oldTail);
if (compareAndSetTail(oldTail, node)) {
oldTail.next = node;
return node;
}
} else {
initializeSyncQueue();
}
}
複製程式碼
可以看到,在死迴圈裡呼叫了 CAS 的方法。如果多個執行緒同時呼叫該方法,那麼每次迴圈都只有一個執行緒執行成功,其他執行緒進入下一次迴圈,重新呼叫。N個執行緒就會迴圈N次。這樣就在無鎖的模式下實現了併發模型。
掛起等待
- 如果此節點的上一個節點是頭部節點,則再次嘗試獲取鎖,獲取到了就移除並返回。獲取不到就進入下一步;
- 判斷前一個節點的 waitStatus,如果是 SINGAL,則返回 true,並呼叫 LockSupport.park() 將執行緒掛起;
- 如果是 CANCELLED,則將前一個節點移除;
- 如果是其他值,則將前一個節點的 waitStatus 標記為 SINGAL,進入下一次迴圈。
可以看到,一個執行緒最多有兩次機會,還競爭不到就去掛起等待。
final boolean acquireQueued(final Node node, int arg) {
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} catch (Throwable t) {
cancelAcquire(node);
throw t;
}
}
複製程式碼
釋放鎖
- 呼叫 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,則喚醒佇列中的下一個執行緒,否則不進行操作。