Java併發——別再問 ReentrantLock 的原理了

說到併發,我們會馬上想到鎖,使用鎖來保證各線程之間能夠安全訪問臨界區以及非線程安全的數據。
那爲啥 Java 要提供另一種機制呢?難道 synchronized 關鍵字不香嗎?嗯,它確實在有些場景
不是那麼香,從而迫切需要提供一種更靈活,更易於控制的鎖機制。那在去了解 Doug Lea 大佬寫的
鎖機制原理之前,我們自己先想想應該怎麼去實現。

自實現思考

1、需要一個標誌是否可以訪問共享資源,如果直接使用 boolean 變量來做標記,可以控制。但是,
在如果在可以併發性訪問某個共享資源,那麼就不好做了,所以考慮可以使用 int 變量來表示資源數
2、當線程需要阻塞時,怎麼去阻塞它?另一個線程釋放鎖時,怎麼去通知阻塞中的線程?

僞代碼

class MyLock {
    int resources = 0;
    
    public void lock() {
        while (!compareAndSet(resources, 0, 1)) {
           sleep(10);
        }
            
        // 未被其它線程佔有
        setOwnerThread(Thread.currentThread);
    }
    
    public void unlock() {
        resouces = 0;
    }
}

僞代碼很簡單,只是簡單描述了思路,具體的東西都沒有展現出來,這些我們就看大神是怎麼思考以及實現的。

ReentrantLock

類圖

ReentrantLock 類圖

ReentrantLock 類定義了三個內部類,可以說 ReentrantLock 類的邏輯就是由這三個內部類來完成的。Sync 內部類是 FairSync 和 NonfairSync 類的父類。

非公平獨佔鎖

在瞭解 ReentrantLock 類的結構後,我們先看看它的加鎖邏輯。

public void lock() {
    // 直接調用內部類 Sync 的 lock() 方法
    sync.lock();
}

// Sync 類的 lock() 方法時抽象方法,這樣定義主要是更快速實現非公平鎖
final void lock() {
    // 非公平鎖會立即就去搶佔鎖,而公平鎖沒有這一步
    // compareAndSetState() 使用 CAS 機制來更新變量值
    if (compareAndSetState(0, 1)) 
        // 此方法不需要任何同步操作
        setExclusiveOwnerThread(Thread.currentThread());
    else
        // 說明已經有其他線程獲取到鎖,去嘗試獲取鎖
        // 核心
        acquire(1);
}

其流程圖如圖:
lock

AbstractQueuedSynchronizer

用於實現阻塞式鎖和相關同步器(信號量和事件等)的通用組件,它底層依賴FIFO
的等待隊列。

在上述過程中如果沒有搶到鎖,就會進入核心 acquire() 方法,我們看看這裏的邏輯:

// 入參 arg = 1
public final void acquire(int arg) {
    // ① tryAcquire(arg)
    // ② 獲取鎖失敗,先調用 addWaiter(Node.EXCLUSIVE) 方法,然後調用 acquireQueued() 方法加入同步隊列去排隊
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        // 中斷自己
        selfInterrupt();
}

AQS 中的 tryAcquire() 方法是個空實現,直接拋 UnsupportedOperationException,所以這個方法是留給子類去實現的,是不是想起了設計模式中的模板模式。既然 AQS 沒有提供默認實現,那我們進入 ReentrantLock 類的內部類 NonfairSync 類看看。

// 入參 acquires = 1
protected final boolean tryAcquire(int acquires) {
    // 調用其父類 Sync 的 nonfairTryAcquire() 方法
    return nonfairTryAcquire(acquires);
}

Sync 類的 nonfairTryAcquire() 方法:

final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    // 獲取資源狀態變量
    int c = getState();
    if (c == 0) { // 沒有被線程佔有,要去佔有它
        // CAS 設置 state 變量值,防止在更新 state 變量值時有其他
        // 線程也在更新,從而避免使用 JVM 同步機制
        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");
        // 這裏不需要使用 CAS,也不需要 JVM 同步機制,因爲此時肯定沒有
        // 其他線程能更改此變量
        setState(nextc);
        return true;
    }
    
    return false;
}

這裏邏輯不復雜,就兩步:
① 鎖是否有被其他線程佔用
② 鎖是否被同一線程所佔有

如果上述操作沒有獲取到鎖,則會執行 AQS#addWaiter() 方法。在說 addWaiter() 方法邏輯之前,我們先瞧瞧 AQS 的類結構。
AQS
我們在分別看看 AQS 和 Node 的數據結構。

  • AQS
    AQS

    從結構上看,AQS 是一個隊列,head 和 tail 分別指向隊列的頭尾節點,後綴爲 Offset 的變量是內存地址,
    CAS 操作需要用到。

  • Node
    Node
    它作爲隊列中的節點對象,從 Node 數據結構可以看出 AQS 是一個雙端隊列。其中核心字段是 waitStatus,它可以具有的值:

    • CANCELLED = 1
      超時或中斷,節點(線程)被刪除
    • SIGNAL = -1
      當前節點的後繼節點處於(或即將處於)阻塞狀態,因此當前在釋放鎖或被刪除時需要喚醒它的後繼節點
    • CONDITION = -2
      當前節點處於條件隊列中,而不是同步隊列。當它被轉移到同步隊列時,waitStatus = 0
    • PROPAGATE = -3
      在共享模式下,傳播

    waitStatue 的值非負數意味着它不需要被喚醒。它的值變化主要在於前繼節點

我們迴歸正題,繼續看 addWaiter() 方法的邏輯。

// 入參 mode = Node.EXCLUSIVE
private Node addWaiter(Node mode) {
    // 當前線程封裝爲 Node 節點
    Node node = new Node(Thread.currentThread(), mode);
    // 判斷pred是否爲空,其實就是判斷對尾是否有節點,其實只要隊列被初始化了隊尾肯定不爲空
    // 假設隊列裏面只有一個元素,那麼對尾和對首都是這個元素
    // 
    Node pred = tail;
    if (pred != null) {
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    
    // 同步隊列沒有被初始化
    enq(node);
    return node;
}

private Node enq(final Node node) {
    for (;;) { // 循環入隊
        // 進入這個方法時,同步隊列還沒有被初始化
        // 故而第一次循環t==null(因爲是死循環,因此強調第一次,後面可能還有第二次、第三次,每次t的情況肯定不同)
        Node t = tail;
        if (t == null) { // 初始化,隊列有哨兵頭節點
            if (compareAndSetHead(new Node())) // CAS 設置頭節點
                tail = head;
        } else {
            // 這裏,表明同步已經被初始化
            node.prev = t;
            if (compareAndSetTail(t, node)) { // CAS 設置尾節點
                t.next = node;
                return t;
            }
        }
    }
}

上面操作只是把當前線程作爲節點入隊,邏輯不難,有一點可以稍加註意的是快速入隊操作。現在我們看看核心邏輯 acquireQueued() 方法。

// 非中斷狀態下
final boolean acquireQueued(final Node node, int arg) {
    // 標記
    boolean failed = true;
    try {
        boolean interrupted = false;
        // 兩種情況:1、上一個節點爲頭部;2上一個節點不爲頭部
        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);
    }
}

// 爲獲取鎖失敗的節點檢查且更新其狀態,如果線程需要阻塞,則方法返回 true
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL) // 前繼節點的 waitStatus = -1
        // 節點早已告知其前繼節點,我需要你 signal 我,因此節點可以被 阻塞
        return true;
    if (ws > 0) { // 前繼節點的 waitStatus = 1, 被刪除
        // 前繼節點被刪除,所以可以忽略,從新找出它的前繼節點
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        // 前繼節點的 waitStatus 肯定爲 0 或 PROPAGATE,此時需要 signal 信號,但還無需阻塞
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

假設當前 A 線程已獲取到鎖,沒有釋放,而此時 B 線程來獲取鎖資源,那此時同步隊列是這個樣子的:
初始同步隊列
所以註釋 ① 下的代碼就明白爲什麼這樣做了。雖然當時 B 線程沒有獲取到鎖,並且入了同步隊列,但可能 A 線程之後釋放了鎖,而現在 B 線程節點的前繼節點是 head 節點,所以 B 線程節點可以再嘗試獲取鎖,如果獲取到鎖,那麼把當前節點變成 head 節點,之前的 head 節點被垃圾回收。 如果 A 線程沒有釋放鎖,則會進入註釋 ②
的代碼,用於判斷需不需要馬上阻塞 B 線程以及更新其節點狀態。從代碼可知,節點不會馬上進入阻塞,而是再下一輪
確定後再進入阻塞。

假設現在 B 線程節點沒有獲取到鎖,那麼此時隊列是這樣子的:
初始隊列
現在假設 C 線程來獲取鎖,那麼此時隊列是這樣子的:
多個節點隊列
整個簡易流程如圖:
簡單lock流程圖

非公平釋放鎖

釋放鎖邏輯:

// 如果當前線程佔用鎖資源,那麼它持有的數量減 1,持有數量爲 0,則釋放鎖。
// 如果當前線程沒有佔用鎖資源,那麼拋出異常 IllegalMonitorStateException
public void unlock() {
    sync.release(1);
}

釋放鎖邏輯直接委派給其內部類 Sync,而 Sync 類沒有重寫 release() 方法,因此直接調用其父類 AQS 的 release() 方法。

// arg = 1
public final boolean release(int arg) {
    // 嘗試釋放鎖,只有當沒有任何線程佔有鎖的時候,tryRelease() 方法纔會返回 true
    if (tryRelease(arg)) {
        // 表示同步隊列其他線程可以競爭鎖
        Node h = head;
        if (h != null && h.waitStatus != 0)
            // 核心,喚醒後繼節點
            unparkSuccessor(h);
        return true;
    }

    return false;
}

tryRelease() 方法由 Sync 類實現:

// 嘗試釋放鎖,只有當沒有任何線程佔有鎖的時候,tryRelease() 方法纔會返回 true
protected final boolean tryRelease(int releases) {
    // 更新 state
    int c = getState() - releases;
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        free = true;
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

嘗試釋放鎖資源的邏輯還是比較簡單,然後我們再看看在釋放鎖成功後,喚醒後繼節點的邏輯。

// node = head
private void unparkSuccessor(Node node) {
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);
    
    Node s = node.next;
    // 後繼節點是否存在或後繼節點是否被刪除
    if (s == null || s.waitStatus > 0) {
        // 從後向前遍歷,找到其第一個真正意義上的後繼節點
        s = null;
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }

    if (s != null)
        // 喚醒
        LockSupport.unpark(s.thread);
}

公平獨佔鎖

公平獨佔鎖核心邏輯差不都,只是在嘗試獲取鎖時邏輯有點不一樣,即 tryAcquire() 方法。

// 可以跟 NonfairSync 類的 tryAcquire() 方法的邏輯比較下
protected final boolean tryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    int c = getState();
    if (c == 0) {
        // 前面增加了一個判斷條件 hasQueuedPredecessors() 方法
        // 檢查同步隊列是否有其他線程等待,有,不去獲取鎖,入隊
        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() 方法,一個上來直接去獲取鎖,一個上來先判斷同步隊列中有沒有其他線程在等待。之後後面的邏輯是一樣的。在分析邏輯時要注意兩點:

  • head 節點的線程已經拿到鎖
  • 不管是公平還是不公平,一朝入同步隊列,就只能乖乖的等待
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章