併發編程之深入理解ReentrantLock和AQS原理

AQS(AbstractQueuedSynchronizer)在併發編程中佔有很重要的地位,可能很多人在平時的開發中並沒有看到過它的身影,但是當我們有看過concurrent包一些JDK併發編程的源碼的時候,就會發現很多地方都使用了AQS,今天我們一起來學習一下AQS的原理,本文會用通俗易懂的語言描述AQS的原理。當然如果你瞭解CAS操作、隊列、那麼我相信你學習起來會感到無比輕鬆。

我們會從鎖(ReentrantLock)的入口來學習AQS,當然AQS不僅僅只是實現了鎖,在很多的工具類中(如CountDownLatch、Semaphore),感興趣的可以去看看,當我們理解了AQS的原理,我們再過去看那些源碼真的可以說是so easy!我們開始吧

一、簡單思考鎖的實現原理

我們今天不討論synchronized的實現,因爲它是從jvm語言層面實現的鎖,我們也很難看到它的源碼,我們今天重點從JDK的ReentrantLock的實現着手。

鎖的實現原理,無非就是限制多個線程執行一段代碼塊時,每次允許一個線程執行一段代碼塊,那如果是你來實現鎖,你將會如何實現?

我這裏假設一下實現的步驟

1、定義一個int類型的state變量(volatile),當state=0(鎖沒有被線程持有),當state=1(鎖被其他線程持有)

2、當線程去搶鎖的時候,就是將state=0變成state=1,如果成功則搶到鎖

3、當線程釋放鎖的時候,就是將state=1變成state=0

4、當我們沒有沒有搶到鎖,就進行等待,加入一個隊列進行排隊

5、加入到隊列的線程一直監聽鎖的狀況,當有機會搶到鎖的時候,就嘗試去搶鎖

如果你跟我想的一樣,那麼恭喜你,實現鎖的主要的流程你基本上已經掌握了,JDK主要的思路也是這樣子,但是他們的思路比上面更加嚴謹,具體嚴謹在哪裏呢?我們接着往下看

1、當state=0變成state=1的過程的原子性(因爲這個操作類似i++,不是原子性的)

2、鎖的可重入性,比如遞歸調用

3、當沒有搶到鎖時加入到隊列的時候,也要保證原子性,意思就是如果threadA,threadB,threadC同時競爭鎖,只有threadA競爭到了,那麼要保證threadB和threadC能夠同時加入到隊列的尾部,不能出錯

4、如果處於隊列中等待的線程一直與循環監聽鎖,會不會導致性能下降?還是說當鎖釋放了,會進行通知喚醒隊列中的一個線程。

其實ReentrantLock的鎖基本上就很好的解決了上述的問題。

二、JDK中ReentrantLock的實現原理

1、ReentrantLock分公平鎖和非公平鎖

ReentrantLock通過構造函數中傳入boolean類型,用於創建公平鎖和非公平鎖( 默認是非公平鎖,因爲非公平鎖性能相對要高一點)

public ReentrantLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
}

爲什麼性能會高一點呢?因爲非公平鎖在調用lock的時候,首先就會去搶一次,如果搶到了就操作。有可能在線程上下文切換的過程中,一個很短的任務搶到鎖了剛好在該上下文切換的時間內執行完了任務。如果是公平鎖,就會加入到隊列的尾部,等待它前面的線程都執行完了,再執行

2、ReentrantLock內部結構

ReentrantLock內部的結構非常簡單,這是因爲複雜的邏輯封裝在了AbstractQueuedSynchronizer中(我們今天的重點,也是難點),下面類圖是ReentrantLock內部類的關係圖
在這裏插入圖片描述
這裏使用了模板設計模式,不瞭解的可以參考這篇文章(模板設計模式

我們開始先看下ReentrantLock內部實現上鎖和釋放鎖的邏輯,看看和我們前面自己思考的實現鎖的邏輯是不是一致,這裏我們以非公平鎖爲例,我相信非公平鎖理解了,公平鎖也是so easy的

3、ReentrantLock源碼

(1)NonfairSync.lock()

/**
 * Performs lock.  Try immediate barge, backing up to normal
 * acquire on failure.
 */
final void lock() {
    //cas的操作保證原子性
    if (compareAndSetState(0, 1))
        //設置當前搶到鎖的線程
        setExclusiveOwnerThread(Thread.currentThread());
    else
        acquire(1);
}

lock的代碼非常簡單,首先嚐試使用cas(compare and swap)嘗試獲取鎖,注意這裏使用cas沒有用到自旋(無限循環,這裏只嘗試了一次)。跟我們之前想的一樣,無非就是將state的值使用cas從0–>1,如果成功,則表示搶到了鎖,並且設置當前搶到鎖的線程(後面可重入或者釋放鎖的時候,都需要判斷該線程),如果沒有搶到就走else的邏輯

(2)AbstractQueuedSunchronizer.acquire()

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

這裏短路與,會再次嘗試一次獲取,如果沒有獲取到則加入隊列(將當前線程信息封裝成Node節點,使用cas加入到隊列尾部),我們先看tryAcquire(),加入隊列的邏輯到下面一節再說。

這裏使用了模板方法,其實調用到了ReentrantLock內部的NonfairSync的tryAcquire()

(3)tryAcquire()

protected final boolean tryAcquire(int acquires) {
    return nonfairTryAcquire(acquires);
}

(4)nonfairTryAcquire()

/**
 * Performs non-fair tryLock.  tryAcquire is implemented in
 * subclasses, but both need nonfair try for trylock method.
 */
final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    //獲取state值
    int c = getState();
    //state=0表示當前沒有線程持有鎖,則使用cas嘗試獲取
    if (c == 0) {
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            //搶到了就退出
            return true;
        }
    }
    //如果state>0則表示當前鎖被線程持有,則判斷是不是自己持有
    else if (current == getExclusiveOwnerThread()) {
        //如果是當前線程,則重入,state+1
        int nextc = c + acquires;
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        setState(nextc);
        return true;
    }
    return false;
}

這一段邏輯也非常的簡單,邏輯如下

1、獲取當前的state的值

2、如果state=0,表示當前沒線程持有鎖,則嘗試獲取鎖(將state=0使用cas修改成1,如果成功則設置當前線程,和上面的邏輯一致)

3、如果state>0表示當前鎖被線程持有,則判斷持有鎖的線程是不是當前線程,如果是當前線程,則state+1,這裏是實現可重入鎖的關鍵

4、否則返回false,則會將當前線程的信息生成Node節點,打入到等待隊列,後面會講

相信大家到這裏明白了ReentrantLock中lock的第一步了,其實和我們之前想的自己實現鎖的方式是一致的,下面我們開始看釋放鎖的邏輯

(1)unLock()

public void unlock() {
    sync.release(1);
}

(2)release()

public final boolean release(int arg) {
    //釋放鎖
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            unparkSuccessor(h);
        return true;
    }
    return false;
}

這裏我們先簡單的看tryRelease(),關於隊列的操作,後面會詳細單獨講,其實這裏就是釋放鎖,然後隊列的下一個節點由阻塞狀態變成非阻塞,從名字也能看出來。

這裏也是模板方法,進入了ReentrantLock的tryRelease

(3)tryRelease()

protected final boolean tryRelease(int releases) {
    //直接將state-releases(允許一次釋放多次,比如await方法,就會直接從state=n變成0)
    int c = getState() - releases;
    //當前釋放鎖的線程不是持有鎖的線程則拋異常
    if (Thread.currentThread() != getExclusiveOwnerThread())
        throw new IllegalMonitorStateException();
    boolean free = false;
    if (c == 0) {
        free = true;
        //將持有鎖的線程記錄變量置爲null
        setExclusiveOwnerThread(null);
    }
    setState(c);
    return free;
}

這裏的邏輯如下:

1、首先將state-releases,這裏如果是實現鎖的情況,releases的值一般是1,這裏我詳細解釋一下,假如線程A第一次獲取鎖則state=1,當線程A繼續獲取該鎖(重入)則state+1=2,以此類推,每重入一次則加1,當釋放鎖的時候,則進行相應的減1,只有當全部釋放完state=0時才返回true,但是如果當調用condition.await()方法則會直接將state減成0,因爲要全部釋放鎖

2、判斷當前釋放鎖的線程是不是持有鎖的線程,如果不是則拋異常,線程A不能釋放線程B持有的鎖

3、當全部釋放完,state=0,則將持有鎖的線程變量設置成null,表示當前沒有線程持有鎖

4、否則返回false

到這裏ReentrantLock上鎖和釋放鎖的邏輯基本上就結束了(還沒進入後面主題AQS)

總結:

1、ReentrantLock是通過一個int類型的state去控制鎖

2、當state=0表示當前鎖沒有被佔有,>0表示被線程佔有

3、搶鎖的過程其實就是使用cas嘗試講state=0修改成state=1,如果搶到鎖,需要記錄搶到鎖的線程

4、當一個線程多次獲取一個鎖時,是在state做累加,同時釋放的話就遞減、

5、釋放鎖就是將state=1(或者>1是遞減)變成state=0,此時不需要使用cas,因爲沒有競爭,鎖是被當前線程持有的,當鎖完全釋放,則設置當前持有鎖的那個變量設置爲null

三、AQS原理

到這裏纔算真正進入本片文章的主題,前面講到ReentrantLock是希望大家平滑過渡到AQS,不然直接進來說AQS會比較幹,不絲滑。前面我們簡單說過當使用cas嘗試獲取鎖時,如果失敗會使用cas將當前線程的信息封裝成Node節點加入到一個隊列的末尾,我們就從這裏作爲入口,深入AQS

我們先來看下數據結構、並且描述一下同步隊列的樣子、以及工作的流程、最後再來看代碼

1、Node結構

在這裏插入圖片描述
前面簡單的說過我們競爭鎖的線程信息會被封裝到Node中,這裏對Node詳細解析一下

thread:當前競爭鎖的線程

prev:前一個node節點,因爲同步隊列是一個雙向隊列

next:後一個node節點,因爲同步隊列是一個雙向隊列

waitStatus:當前線程的等待狀態,它的值一般就是裏面定義的CANCELLED(已經取消)、SIGNAL(準備就緒等待通知喚醒即可)、PROPAGATE(共享鎖SHARED用到)、CONDITION(在某個條件上等待)

nextWaiter:是condition的等待隊列中用到,下一個等待節點,因爲condition使用的等待隊列的Node數據結構和AQS同步隊列的Node數據結構是同一個

2、隊列的結構

在這裏插入圖片描述

一般來說head指向的節點是獲取了鎖的節點,當它釋放鎖後,會通知後一個節點(後面的節點可能是處理阻塞的狀態,則可能會被喚醒)

大家可以先結合圖來看下他們的流程,後面再去看源碼可能會輕鬆很多,搶鎖的邏輯大致如下:

(1)當搶鎖失敗的時候,會將當前的線程信息封裝成Node節點使用CAS加入到隊列的尾部(因爲可能有多個線程同時加入尾部)

(2)加入到隊列之後,當前線程會獲取前一個節點的信息,如果前一個節點是head節點,則會嘗試獲取鎖,獲取到了就會將自己設置成head節點,並且將之前隊列的head節點設置成null,讓垃圾回收器回收,從當前隊列移除;如果前一個節點不是head節點或者獲取鎖失敗則會判斷是否進行阻塞,一般會進行阻塞(防止自旋耗費性能)

(3)當head釋放鎖的時候,會喚醒head的後一個阻塞的節點,此時被喚醒後的節點進入自旋嘗試獲取鎖(因爲這個時候並不能保證一定會獲取鎖,比如前面講的剛創建的線程會先嚐試能不能獲取鎖,就會產生競爭,這也是爲什麼非公平鎖比公平鎖性能好的原因),如果沒有獲取到則又會進入阻塞等待喚醒

3、深入源碼分析

相信結合上面的圖,以及上述邏輯的描述,大家已經對整體的邏輯有一定的把握,再來看看源碼

先從獲取鎖失敗加入到隊列的尾部的源碼開始

(1)acquire()

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}

我們在上一節只分析了tryAcqure(arg)沒有分析後面,今天我們從這裏開始分析。我們先看addWaiter(Node.EXCLUSIVE)後面再看acquireQueued()

(2)addWaiter()

/**
 * Creates and enqueues node for current thread and given mode.
 *
 * @param mode Node.EXCLUSIVE for exclusive, Node.SHARED for shared
 * @return the new node
 */
private Node addWaiter(Node mode) {
    //封裝線程信息,並且mode爲獨佔鎖,ReentrantLock本來就是獨佔鎖
    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;
        //cas設置隊尾
        if (compareAndSetTail(pred, node)) {
            pred.next = node;
            return node;
        }
    }
    //自旋cas加入隊列
    enq(node);
    return node;
}

這段代碼也比較簡單,將當前線程信息封裝成Node,這裏的mode是共享模式還是獨佔模式(SHARED、EXCLUSIVE),在Node裏面能看得到,我們這裏先看獨佔模式EXCLUSIVE。這裏首先會嘗試使用cas加入到隊列的尾部,如果成功則return退出,否則調用enq(node)

(2)enq(node)

/**
 * Inserts node into queue, initializing if necessary. See picture above.
 * @param node the node to insert
 * @return node's predecessor
 */
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;
            }
        }
    }
}

這裏就用了自旋(死循環,直至成功)cas將node加入到隊列的尾部,當前前面有一個初始化的判斷,如果隊列沒有初始化,則會初始化,到這裏沒有搶到鎖的Node已經成功加入到同步隊列的尾部了,後面就是如何讓他知道什麼時候應該可以去搶鎖了。我們接着看acquireQueued(addWaiter(Node.EXCLUSIVE), arg)),上面已經分析了addWaiter方法,現在分析acquireQueued()

(3)acquireQueued()

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;
            }
            //如果上面獲取失敗的話,這裏就會判斷是否需要阻塞,
            //主要是防止cpu無限調度這一塊自旋代碼,降低性能,從而使用通知的模式
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}

這裏的代碼看着也不難理解,很多時候我們可以從方法名就能看到方法的主要意圖,上面的註釋基本上描述了主要的邏輯,這裏就不在繼續描述了,我們看一下里面的阻塞的邏輯shouldParkAfterFailedAcquire()

(4)shouldParkAfterFailedAcquire(prev,node)

/**
 * Checks and updates status for a node that failed to acquire.
 * Returns true if thread should block. This is the main signal
 * control in all acquire loops.  Requires that pred == node.prev.
 *
 * @param pred node's predecessor holding status
 * @param node the node
 * @return {@code true} if thread should block
 */
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)
        /*
         * This node has already set status asking a release
         * to signal it, so it can safely park.
         */
        return true;
    if (ws > 0) {
        /*
         * Predecessor was cancelled. Skip over predecessors and
         * indicate retry.
         */
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        /*
         * waitStatus must be 0 or PROPAGATE.  Indicate that we
         * need a signal, but don't park yet.  Caller will need to
         * retry to make sure it cannot acquire before parking.
         */
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

這段邏輯主要的目的就是去除node節點的所有的狀態爲CANCELED的節點,CANCELED表示取消,不再獲取鎖,否則就阻塞(這裏要注意了,當返回false時下次for循環進入到這裏時依然會阻塞),阻塞之後就不會調用自旋的for循環耗費cpu了,而是等待前面的Node節點釋放鎖之後通知喚醒它。到這裏獲取鎖失敗,並且加入隊列阻塞等待已經分析完了,後面我們分析當前面的Node釋放鎖時,通知阻塞的Node節點吧。我們直接從release()方法開始吧,release方法是由unlock裏面調用的

(5)release()

public final boolean release(int arg) {
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            //喚醒阻塞的後繼者
            unparkSuccessor(h);
        return true;
    }
    return false;
}

tryRelease()前面已經分析過了,這裏不繼續分析,如果tryRelease已經完成成功釋放鎖了(state=0)返回true,

則會喚醒阻塞的後一個節點

(6)unparkSuccessor()

/**
 * Wakes up node's successor, if one exists.
 *
 * @param node the node
 */
private void unparkSuccessor(Node node) {
    /*
     * If status is negative (i.e., possibly needing signal) try
     * to clear in anticipation of signalling.  It is OK if this
     * fails or if status is changed by waiting thread.
     */
    int ws = node.waitStatus;
    if (ws < 0)
        compareAndSetWaitStatus(node, ws, 0);

    /*
     * Thread to unpark is held in successor, which is normally
     * just the next node.  But if cancelled or apparently null,
     * traverse backwards from tail to find the actual
     * non-cancelled successor.
     */
    Node s = node.next;
    //如果存在後繼節點,或者後繼節點的狀態爲CANCELLED
    if (s == null || s.waitStatus > 0) {
        s = null;
        //從尾部開始取需要被喚醒的節點Node
        for (Node t = tail; t != null && t != node; t = t.prev)
            if (t.waitStatus <= 0)
                s = t;
    }
    //存在需要喚醒的節點,則喚醒它
    if (s != null)
        LockSupport.unpark(s.thread);
}

最上面的判斷是清除當前節點的狀態,我們重點看下面一部分的邏輯,已經寫上了註釋,如果下一個節點爲null或者爲CANCELLED則會從隊尾開始找一個可以喚醒的Node進行喚醒。至於爲什麼從隊尾開始尋找,我也不是特別清楚,可能是爲了提高一點性能吧(因爲如果head的下一個Node狀態是CANCELLED,可能它已經等待了很長時間,被用戶設置了CANCELLED狀態,那麼jdk開發人員可能猜測它後面的幾個Node的狀態可能都是CANCELLED,所以從隊尾拿到一個可喚醒的Node遍歷的次數可能會少一點)。好了到這裏一個Node就已經被喚醒了,這個時候被喚醒的Node會繼續執行它的自旋獲取鎖的邏輯(它阻塞的地方開始繼續執行),會繼續執行下面的代碼的for循環

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

代碼會走到這裏,然後的分析流程就跟上面是一致的…到這裏使用AQS隊列同步器實現互斥鎖(EXCLUSIVE)的邏輯已經全部分析完了,對於共享鎖(SHARED)大家可以自行分析,現在接着總結一下AQS實現互斥鎖的邏輯

總結:

1、當線程獲取鎖失敗後,會通過CAS加入到同步隊列的尾部

2、加入隊列的尾部之後,每個隊列會做自旋操作,判斷前一個Node是不是頭節點,如果是則嘗試獲取鎖,否則會進行阻塞,知道它的前一個節點釋放鎖後喚醒它

3、線程釋放鎖時會找到它後面的一個可以被喚醒的Node節點,可能從隊列head下一個節點,也可能從隊尾開始,上面已經說的比較清楚

3、喚醒後的節點會繼續從阻塞處進行自行自旋操作,嘗試獲取鎖

本片文章到這裏就結束了,希望對大家有點幫助,同時如果哪裏寫的有問題,歡迎大家指正!

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章