作者:小傅哥
部落格:https://bugstack.cn
沉澱、分享、成長,讓自己和他人都能有所收穫!?
一、前言
如果你相信你做什麼都能成,你會自信的多!
千萬不要總自我否定,尤其是職場的打工人。如果你經常感覺,這個做不好,那個學不會,別的也不懂,那麼久而久之會越來越缺乏自信。
一般說能成事的人都具有賭徒
精神,在他們眼裡只要做這事那就一定能成,當然也有可能最後就沒成,但在整個過程中人的心態是良好的,每天都有一個飽滿的精神狀態,孜孜不倦的奮鬥著。最後也就是這樣的鬥志讓走在一個起點的小夥伴,有了差距。
二、面試題
謝飛機,小記
,今天打工人呀,明天早上困呀,嘟嘟嘟,喂?誰呀,打農藥呢!?
謝飛機:哎呦,面試官大哥,咋了!
面試官:偷偷告訴你哈,你一面過了。
謝飛機:嘿嘿,真的呀!太好了!哈哈哈,那我還準備點什麼呢!?
面試官:二面會比較難嘍,嗯,我順便問你一個哈。AQS 你瞭解嗎,ReentrantLock 獲取鎖的過程是什麼樣的?什麼是 CAS?...
謝飛機:我我我,腦子還在後羿射箭裡,我一會就看看!!
面試官:好好準備下吧,打工人,打工魂!
三、ReentrantLock 和 AQS
1. ReentrantLock 知識鏈
ReentrantLock 可重入獨佔鎖涉及的知識點較多,為了更好的學習這些知識,在上一章節先分析原始碼和學習實現了公平鎖的幾種方案。包括:CLH、MCS、Ticket,通過這部分內容的學習,再來理解 ReentrantLock 中關於 CLH 的變體實現和相應的應用就比較容易了。
接下來沿著 ReentrantLock 的知識鏈,繼續分析 AQS 獨佔鎖的相關知識點,如圖 17-1
在這部分知識學習中,會主要圍繞 ReentrantLock 中關於 AQS 的使用進行展開,逐步分析原始碼瞭解原理。
AQS 是 AbstractQueuedSynchronizer 的縮寫,幾乎所有 Lock 都是基於 AQS 來實現了,其底層大量使用 CAS 提供樂觀鎖服務,在衝突時採用自旋方式進行重試,以此實現輕量級和高效的獲取鎖。
另外 AbstractQueuedSynchronizer 是一個抽象類,但並沒有定義相應的抽象方法,而是提供了可以被字類繼承時覆蓋的 protected 的方法,這樣就可以非常方便的支援繼承類的使用。
2. 寫一個簡單的 AQS 同步類
在學習 ReentrantLock 中應用的 AQS 之前,先實現一個簡單的同步類,來體會下 AQS 的作用。
2.1 程式碼實現
public class SyncLock {
private final Sync sync;
public SyncLock() {
sync = new Sync();
}
public void lock() {
sync.acquire(1);
}
public void unlock() {
sync.release(1);
}
private static class Sync extends AbstractQueuedSynchronizer {
@Override
protected boolean tryAcquire(int arg) {
return compareAndSetState(0, 1);
}
@Override
protected boolean tryRelease(int arg) {
setState(0);
return true;
}
// 該執行緒是否正在獨佔資源,只有用到 Condition 才需要去實現
@Override
protected boolean isHeldExclusively() {
return getState() == 1;
}
}
}
這個實現的過程屬於 ReentrantLock 簡版,主要包括如下內容:
- Sync 類繼承 AbstractQueuedSynchronizer,並重寫方法:tryAcquire、tryRelease、isHeldExclusively。
- 這三個方法基本是必須重寫的,如果不重寫在使用的時候就會拋異常
UnsupportedOperationException
。 - 重寫的過程也比較簡單,主要是使用 AQS 提供的 CAS 方法。以預期值為 0,寫入更新值 1,寫入成功則獲取鎖成功。其實這個過程就是對 state 使用
unsafe
本地方法,傳遞偏移量 stateOffset 等引數,進行值交換操作。unsafe.compareAndSwapInt(this, stateOffset, expect, update)
- 最後提供 lock、unlock 兩個方法,實際的類中會實現 Lock 介面中的相應方法,這裡為了簡化直接自定義這樣兩個方法。
2.2 單元測試
@Test
public void test_SyncLock() throws InterruptedException {
final SyncLock lock = new SyncLock();
for (int i = 0; i < 10; i++) {
Thread.sleep(200);
new Thread(new TestLock(lock), String.valueOf(i)).start();
}
Thread.sleep(100000);
}
static class TestLock implements Runnable {
private SyncLock lock;
public TestLock(SyncLock lock) throws InterruptedException {
this.lock = lock;
}
@Override
public void run() {
try {
lock.lock();
Thread.sleep(1000);
System.out.println(String.format("Thread %s Completed", Thread.currentThread().getName()));
} catch (Exception e) {
e.printStackTrace();
} finally {
lock.unlock();
}
}
}
- 以上這個單元測試和我們在上一章節介紹公平鎖時是一樣的,驗證順序輸出。當然你也可以選擇多執行緒操作一個方法進行加和運算。
- 在測試的過程中可以嘗試把加鎖程式碼註釋掉,進行比對。如果可以順序輸出,那麼就是預期結果。
測試結果
Thread 0 Completed
Thread 1 Completed
Thread 2 Completed
Thread 3 Completed
Thread 4 Completed
Thread 5 Completed
Thread 6 Completed
Thread 7 Completed
Thread 8 Completed
Thread 9 Completed
- 從測試結果看,以上 AQS 實現的同步類,滿足預期效果。
- 有了這段程式碼的概念結構,接下來在分析 ReentrantLock 中的 AQS 使用就有一定的感覺了!
3. CAS 介紹
CAS 是 compareAndSet 的縮寫,它的應用場景就是對一個變數進行值變更,在變更時會傳入兩個引數:一個是預期值、另外一個是更新值。如果被更新的變數預期值與傳入值一致,則可以變更。
CAS 的具體操作使用到了 unsafe
類,底層用到了本地方法 unsafe.compareAndSwapInt
比較交換方法。
CAS 是一種無鎖演算法,這種操作是 CPU 指令集操作,只有一步原子操作,速度非常快。而且 CAS 避免了請求作業系統來裁定鎖問題,直接由 CPU 搞定,但也不是沒有開銷,比如 Cache Miss,感興趣的小夥伴可以自行了解 CPU 硬體相關知識。
4. AQS 核心原始碼分析
4.1 獲取鎖流程圖
圖 17-2 就是整個 ReentrantLock 中獲取鎖的核心流程,包括非公平鎖和公平鎖的一些交叉流程。接下來我們就以此按照此流程來講解相應的原始碼部分。
4.2 lock
ReentrantLock 實現了非公平鎖和公平鎖,所以在呼叫 lock.lock();
時,會有不同的實現類:
- 非公平鎖,會直接使用 CAS 進行搶佔,修改變數 state 值。如果成功則直接把自己的執行緒設定到 exclusiveOwnerThread,也就是獲得鎖成功。不成功後續分析
- 公平鎖,則不會進行搶佔,而是規規矩矩的進行排隊。老實人
4.3 compareAndSetState
final void lock() {
if (compareAndSetState(0, 1))
setExclusiveOwnerThread(Thread.currentThread());
else
acquire(1);
}
在非公平鎖的實現類裡,獲取鎖的過程,有這樣一段 CAS 操作的程式碼。compareAndSetState
賦值成功則獲取鎖。那麼 CAS 這裡面做了什麼操作?
protected final boolean compareAndSetState(int expect, int update) {
// See below for intrinsics setup to support this
return unsafe.compareAndSwapInt(this, stateOffset, expect, update);
}
往下翻我們看到這樣一段程式碼,這裡是 unsafe 功能類的使用,兩個引數到這裡變成四個引數。多了 this、stateOffset。this 是物件本身,那麼 stateOffset 是什麼?
stateOffset = unsafe.objectFieldOffset
(AbstractQueuedSynchronizer.class.getDeclaredField("state"));
再往下看我們找到,stateOffset 是偏移量值,偏移量是一個固定的值。接下來我們就看看這個值到底是多少!
引用POM jol-cli
<!-- https://mvnrepository.com/artifact/org.openjdk.jol/jol-cli -->
<dependency>
<groupId>org.openjdk.jol</groupId>
<artifactId>jol-cli</artifactId>
<version>0.14</version>
</dependency>
單元測試
@Test
public void test_stateOffset() throws Exception {
Unsafe unsafe = getUnsafeInstance();
long state = unsafe.objectFieldOffset
(AbstractQueuedSynchronizer.class.getDeclaredField("state"));
System.out.println(state);
}
// 16
- 通過 getUnsafeInstance 方法獲取 Unsafe,這是一個固定的方法。
- 在獲取 AQS 類中的屬性欄位 state 的偏移量,16。
- 除了這個屬性外你還可以拿到:headOffset、tailOffset、waitStatusOffset、nextOffset,的值,最終自旋來變更這些變數的值。
4.4 (AQS)acquire
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
整個這塊程式碼裡面包含了四個方法的呼叫,如下:
- tryAcquire,分別由繼承 AQS 的公平鎖(FairSync)、非公平鎖(NonfairSync)實現。
- addWaiter,該方法是 AQS 的私有方法,主要用途是方法 tryAcquire 返回 false 以後,也就是獲取鎖失敗以後,把當前請求鎖的執行緒新增到佇列中,並返回 Node 節點。
- acquireQueued,負責把 addWaiter 返回的 Node 節點新增到佇列結尾,並會執行獲取鎖操作以及判斷是否把當前執行緒掛起。
- selfInterrupt,是 AQS 中的
Thread.currentThread().interrupt()
方法呼叫,它的主要作用是在執行完 acquire 之前自己執行中斷操作。
4.5 tryAcquire
final boolean nonfairTryAcquire(int acquires) {
final Thread current = Thread.currentThread();
int c = getState();
if (c == 0) {
if (compareAndSetState(0, acquires)) {
setExclusiveOwnerThread(current);
return true;
}
}
else if (current == getExclusiveOwnerThread()) {
int nextc = c + acquires;
if (nextc < 0) // overflow
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
這部分獲取鎖的邏輯比較簡單,主要包括兩部分:
- 如果
c == 0
,鎖沒有被佔用,嘗試使用 CAS 方式獲取鎖,並返回 true。 - 如果
current == getExclusiveOwnerThread()
,也就是當前執行緒持有鎖,則需要呼叫setState
進行鎖重入操作。setState 不需要加鎖,因為是在自己的當前執行緒下。 - 最後如果兩種都不滿足?,則返回 false。
4.6 addWaiter
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode);
Node pred = tail;
// 如果佇列不為空, 使用 CAS 方式將當前節點設為尾節點
if (pred != null) {
node.prev = pred;
if (compareAndSetTail(pred, node)) {
pred.next = node;
return node;
}
}
// 佇列為空、CAS失敗,將節點插入佇列
enq(node);
return node;
}
- 當執行方法
addWaiter
,那麼就是!tryAcquire = true
,也就是 tryAcquire 獲取鎖失敗了。 - 接下來就是把當前執行緒封裝到 Node 節點中,加入到 FIFO 佇列中。因為先進先出,所以後來的佇列加入到隊尾
compareAndSetTail
不一定一定成功,因為在併發場景下,可能會出現操作失敗。那麼失敗後,則需要呼叫 enq 方法,該方法會自旋操作,把節點入佇列。
enq
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Must initialize
if (compareAndSetHead(new Node()))
tail = head;
} else {
node.prev = t;
if (compareAndSetTail(t, node)) {
t.next = node;
return t;
}
}
}
}
- 自旋轉
for迴圈
+ CAS 入佇列。 - 當佇列為空時,則會新建立一個節點,把尾節點指向頭節點,然後繼續迴圈。
- 第二次迴圈時,則會把當前執行緒的節點新增到隊尾。head 節是一個無用節點,這和我們做CLH實現時類似
注意,從尾節點逆向遍歷
- 首先這裡的節點連線操作並不是原子,也就是說在多執行緒併發的情況下,可能會出現個別節點並沒有設定 next 值,就失敗了。
- 但這些節點的 prev 是有值的,所以需要逆向遍歷,讓 prev 屬性重新指向新的尾節點,直至全部自旋入佇列。
4.7 acquireQueued
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) {
final Node p = node.predecessor();
// 當前節點的前驅就是head節點時, 再次嘗試獲取鎖
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);
}
}
當獲取鎖流程走到這,說明節點已經加入佇列完成。看原始碼中接下來就是讓該方法再次嘗試獲取鎖,如果獲取鎖失敗會判斷是否把執行緒掛起。
setHead
private void setHead(Node node) {
head = node;
node.thread = null;
node.prev = null;
}
在學習 CLH 公平鎖資料結構中講到Head節點是一個虛節點,如果當前節點的前驅節點是Head節點,那麼說明此時Node節點排在佇列最前面,可以嘗試獲取鎖。
獲取鎖後設定Head節點,這個過程就是一個出佇列過程,原來節點設定Null方便GC。
shouldParkAfterFailedAcquire
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL)
// SIGNAL 設定了前一個節點完結喚醒,安心幹別的去了,這裡是睡。
return true;
if (ws > 0) {
do {
node.prev = pred = pred.prev;
} while (pred.waitStatus > 0);
pred.next = node;
} else {
compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
}
return false;
}
你是否還CANCELLED、SIGNAL、CONDITION 、PROPAGATE ,這四種狀態,在這個方法中用到了兩種如下:
- CANCELLED,取消排隊,放棄獲取鎖。
- SIGNAL,標識當前節點的下一個節點狀態已經被掛起,意思就是大家一起排隊上廁所,隊伍太長了,後面的謝飛機說,我去買個油條哈,一會到我了,你微信我哈。其實就是當前執行緒執行完畢後,需要額外執行喚醒後繼節點操作。
那麼,以上這段程式碼主要的執行內容包括:
- 如果前一個節點狀態是
SIGNAL
,則返回 true。安心睡覺?等著被叫醒 - 如果前一個節點狀態是
CANCELLED
,就是它放棄了,則繼續向前尋找其他節點。 - 最後如果什麼都沒找到,就給前一個節點設定個鬧鐘
SIGNAL
,等著被通知。
4.8 parkAndCheckInterrupt
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
// 執行緒掛起等待被喚醒
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this);
return Thread.interrupted();
}
- 當方法
shouldParkAfterFailedAcquire
返回 false 時,則執行 parkAndCheckInterrupt() 方法。 - 那麼,這一段程式碼就是對執行緒的掛起操作,
LockSupport.park(this);
。 Thread.interrupted()
檢查當前執行緒的中斷標識。
四、總結
- ReentrantLock 的知識比較多,涉及的程式碼邏輯也比較複雜,在學習的過程中需要對照原始碼和相關併發書籍和資料一起學習,以及最好的是自身實踐。
- AQS 的實現部分涉及的內容較多,例如:state 屬性使用 unsafe 提供的本地方法進行 CAS 操作,把初始值 0 改為 1,則獲得了鎖。addWaiter、acquireQueued、shouldParkAfterFailedAcquire、parkAndCheckInterrupt等,可以細緻總結。
- 所有的 Lock 都是基於 AQS 來實現了。AQS 和 Condition 各自維護了不同的佇列,在使用 Lock 和 Condition 的時候,就是兩個佇列的互相移動。這句話可以細細體會。可能文中會有一些不準確或者錯字,歡迎留言,我會不斷的更新部落格。