面經手冊 · 第17篇《碼農會鎖,ReentrantLock之AQS原理分析和實踐使用》

小傅哥發表於2020-11-12


作者:小傅哥
部落格:https://bugstack.cn

沉澱、分享、成長,讓自己和他人都能有所收穫!?

一、前言

如果你相信你做什麼都能成,你會自信的多!

千萬不要總自我否定,尤其是職場的打工人。如果你經常感覺,這個做不好,那個學不會,別的也不懂,那麼久而久之會越來越缺乏自信。

一般說能成事的人都具有賭徒精神,在他們眼裡只要做這事那就一定能成,當然也有可能最後就沒成,但在整個過程中人的心態是良好的,每天都有一個飽滿的精神狀態,孜孜不倦的奮鬥著。最後也就是這樣的鬥志讓走在一個起點的小夥伴,有了差距。

二、面試題

謝飛機,小記,今天打工人呀,明天早上困呀,嘟嘟嘟,喂?誰呀,打農藥呢!?

謝飛機:哎呦,面試官大哥,咋了!

面試官:偷偷告訴你哈,你一面過了。

謝飛機:嘿嘿,真的呀!太好了!哈哈哈,那我還準備點什麼呢!?

面試官:二面會比較難嘍,嗯,我順便問你一個哈。AQS 你瞭解嗎,ReentrantLock 獲取鎖的過程是什麼樣的?什麼是 CAS?...

謝飛機:我我我,腦子還在後羿射箭裡,我一會就看看!!

面試官:好好準備下吧,打工人,打工魂!

三、ReentrantLock 和 AQS

1. ReentrantLock 知識鏈

ReentrantLock 可重入獨佔鎖涉及的知識點較多,為了更好的學習這些知識,在上一章節先分析原始碼和學習實現了公平鎖的幾種方案。包括:CLH、MCS、Ticket,通過這部分內容的學習,再來理解 ReentrantLock 中關於 CLH 的變體實現和相應的應用就比較容易了。

接下來沿著 ReentrantLock 的知識鏈,繼續分析 AQS 獨佔鎖的相關知識點,如圖 17-1

圖 17-1 ReentrantLock 的知識鏈

在這部分知識學習中,會主要圍繞 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 簡版,主要包括如下內容:

  1. Sync 類繼承 AbstractQueuedSynchronizer,並重寫方法:tryAcquire、tryRelease、isHeldExclusively。
  2. 這三個方法基本是必須重寫的,如果不重寫在使用的時候就會拋異常 UnsupportedOperationException
  3. 重寫的過程也比較簡單,主要是使用 AQS 提供的 CAS 方法。以預期值為 0,寫入更新值 1,寫入成功則獲取鎖成功。其實這個過程就是對 state 使用 unsafe 本地方法,傳遞偏移量 stateOffset 等引數,進行值交換操作。unsafe.compareAndSwapInt(this, stateOffset, expect, update)
  4. 最後提供 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 獲取鎖流程圖

圖 17-2 就是整個 ReentrantLock 中獲取鎖的核心流程,包括非公平鎖和公平鎖的一些交叉流程。接下來我們就以此按照此流程來講解相應的原始碼部分。

4.2 lock

圖 17-3 lock -> CAS

ReentrantLock 實現了非公平鎖和公平鎖,所以在呼叫 lock.lock(); 時,會有不同的實現類:

  1. 非公平鎖,會直接使用 CAS 進行搶佔,修改變數 state 值。如果成功則直接把自己的執行緒設定到 exclusiveOwnerThread,也就是獲得鎖成功。不成功後續分析
  2. 公平鎖,則不會進行搶佔,而是規規矩矩的進行排隊。老實人

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();
}

整個這塊程式碼裡面包含了四個方法的呼叫,如下:

  1. tryAcquire,分別由繼承 AQS 的公平鎖(FairSync)、非公平鎖(NonfairSync)實現。
  2. addWaiter,該方法是 AQS 的私有方法,主要用途是方法 tryAcquire 返回 false 以後,也就是獲取鎖失敗以後,把當前請求鎖的執行緒新增到佇列中,並返回 Node 節點。
  3. acquireQueued,負責把 addWaiter 返回的 Node 節點新增到佇列結尾,並會執行獲取鎖操作以及判斷是否把當前執行緒掛起。
  4. 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;
}

這部分獲取鎖的邏輯比較簡單,主要包括兩部分:

  1. 如果 c == 0,鎖沒有被佔用,嘗試使用 CAS 方式獲取鎖,並返回 true。
  2. 如果 current == getExclusiveOwnerThread(),也就是當前執行緒持有鎖,則需要呼叫 setState 進行鎖重入操作。setState 不需要加鎖,因為是在自己的當前執行緒下。
  3. 最後如果兩種都不滿足?,則返回 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實現時類似

注意,從尾節點逆向遍歷

  1. 首先這裡的節點連線操作並不是原子,也就是說在多執行緒併發的情況下,可能會出現個別節點並沒有設定 next 值,就失敗了。
  2. 但這些節點的 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 ,這四種狀態,在這個方法中用到了兩種如下:

  1. CANCELLED,取消排隊,放棄獲取鎖。
  2. SIGNAL,標識當前節點的下一個節點狀態已經被掛起,意思就是大家一起排隊上廁所,隊伍太長了,後面的謝飛機說,我去買個油條哈,一會到我了,你微信我哈。其實就是當前執行緒執行完畢後,需要額外執行喚醒後繼節點操作。

那麼,以上這段程式碼主要的執行內容包括:

  1. 如果前一個節點狀態是 SIGNAL,則返回 true。安心睡覺?等著被叫醒
  2. 如果前一個節點狀態是 CANCELLED,就是它放棄了,則繼續向前尋找其他節點。
  3. 最後如果什麼都沒找到,就給前一個節點設定個鬧鐘 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 的時候,就是兩個佇列的互相移動。這句話可以細細體會。可能文中會有一些不準確或者錯字,歡迎留言,我會不斷的更新部落格。

五、系列推薦

相關文章