聊聊ReentrantLock的鎖設計

前言

之前看過美團的一篇不可不說的Java“鎖”事,對java鎖的概念做了一次梳理,其實在java類中,ReentrantLock算是一個對鎖概念運用的典範,看懂它的源碼對鎖的理解很有幫助。我也以ReentrantLock爲原型,略加改動使之能在分佈式環境中運行。

幕後功臣AQS

當我們看第一眼ReentrantLock源碼,裏面有一個Sync對象,它繼承AbstractQueuedSynchronizer。

AbstractQueuedSynchronizer是一個抽象的隊列式的同步框架,AQS定義了一套多線程訪問共享資源的同步器框架,許多同步類實現都依賴於它,如常用的ReentrantLock,CountDownLatch

下面代碼複寫了tryRelease方法,其餘的一些方法是自定義方法,我先刪除了。其實還有一個tryAcquire方法,當開發複寫這兩個方法,就可以完成一個鎖的編碼設計。

  • tryAcquire 嘗試獲取鎖
  • tryRelease 嘗試釋放鎖

是不是感覺有了這個萬能的框架後,寫一個鎖很簡單了?AbstractQueuedSynchronizer遵循 模板設計 模式,主骨架已經給你搭好,爲了能串聯起整個功能,你必須要複寫必要的方法,不然直接調用AQS的方法,會拋出UnsupportedOperationException異常。

所以對ReentrantLock源碼的研究也是對AQS的研究。

private final Sync sync;

    /**
     * Base of synchronization control for this lock. Subclassed
     * into fair and nonfair versions below. Uses AQS state to
     * represent the number of holds on the lock.
     */
    abstract static class Sync extends AbstractQueuedSynchronizer {
        private static final long serialVersionUID = -5179523762034025860L;
        
        protected final boolean tryRelease(int releases) {
            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;
        }
    }

ReentrantLock的大致流程

ReentrantLock的獲取鎖流程

上鎖的流程可以直接看AQS的acquire方法

  • tryAcquire: 不用多說了,先去嘗試獲取鎖
  • addWaiter: 如果獲取鎖不成功,便將當前線程包裝成Node對象,加入到FIFO隊列。
  • acquireQueued: 這裏是比較重要的邏輯,線程是否休眠的判斷邏輯,線程休眠(wait)的邏輯,線程在隊列裏位置調整的邏輯。這個方法最重要的還是讓線程休眠,等待喚醒。
public final void acquire(int arg) {
        if (!tryAcquire(arg) &&
            acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
            selfInterrupt();
    }

然後根據 && 的短路特性,我們把上面的步驟總結一下:

先嚐試獲取鎖(tryAcquire),發現獲取失敗,則加入等待隊列(addWaiter),並且判斷是否需要休眠,是的話則休眠,不是則重試獲取鎖(acquireQueued)。

ReentrantLock的釋放鎖流程

  • tryRelease 釋放鎖的方法
  • unparkSuccessor 如果存在的話,喚醒後繼節點
    public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }

釋放鎖的步驟簡單:先嚐試釋放鎖,成功的話喚醒head節點的後繼節點.

詳解ReentrantLock

我想根據鎖的特性(可重入,公平鎖,自旋)來 拆解 講解ReentrantLock,可以加深對鎖的理解。

樂觀 or 悲觀 ?

樂觀鎖與悲觀鎖是一種廣義上的概念,在ReentrantLock的實現中,我們要從整體和部分兩部分來說。

從整體來看,它是一個悲觀鎖,因爲它是一個獨佔鎖,當一個線程持有它並釋放它前,其他線程是不能讀或寫共享資源的。

但從部分來看,ReentrantLock的實現方式有用到樂觀鎖(CAS)。

在AQS中,它維護了一個state變量(代表共享資源),ReentrantLock利用state變量來維護鎖的獲取狀態,看代碼

        protected final boolean tryAcquire(int acquires) {
            final Thread current = Thread.currentThread();
            //初始狀態下,state爲0,代表鎖還沒有線程獲取。
            int c = getState();
            if (c == 0) {
                if (!hasQueuedPredecessors() &&
                //當鎖獲取成功時,state變成1(acquires爲1)
                    compareAndSetState(0, acquires)) {
                    setExclusiveOwnerThread(current);
                    return true;
                }

			。。。。。

上圖的部分代碼可以知道,state初始化爲0,表示未鎖定狀態。線程
調用時,會調用tryAcquire()獨佔該鎖並將state設爲acquires值(默認爲1)。此後,其他線程再tryAcquire()時就會失敗,直到線程unlock()到state=0(即釋放鎖)爲止,其它線程纔有機會獲取該鎖。

如何保證只有當state爲0時更新才能成功?這就是compareAndSetState方法去做的事,compareAndSetState使用一種比較交換的技術(CAS Compare And Swap)來鑑別線程衝突,一旦檢測到衝突的產生,更新會失敗,並且線程不會阻塞掛起(跟悲觀鎖的主要區別),而是返回操作結果,你後面需要重試或者報錯都取決於你,它是一個原子操作,所以在多線程中,CAS可以保證同步,這也就是樂觀鎖的一個經典實現。

CAS的更多內容網上有一堆,大家可以拓展閱讀。所以在對一個鎖的實現進行定性時,不能陷入一種誤區,就是這個鎖一定是悲觀 or 樂觀,需要根據鎖的內部實現方式,服務對象來綜合判斷。

可重入性

重入鎖 意思是 同一個線程 能夠 多次獲取同一個鎖,並且不產生死鎖。可能會問什麼情況會這個線程多次獲取同一個鎖?下面是最簡單的示例,test方法調用test1方法,兩個方法都用synchronized修飾,如果synchronized沒有可重入的特性,線程執行到test1的時候會因爲沒有鎖而卡住(test的鎖還沒有釋放)。

    synchronized void test() {
        test1();
    }

    synchronized void test1() {

    }

ReentrantLock名字本身就表示它是一個可重入鎖,繼續從代碼看看怎麼實現的吧。

        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;
                }
            }
            else if (current == getExclusiveOwnerThread()) {
                int nextc = c + acquires;
                if (nextc < 0)
                    throw new Error("Maximum lock count exceeded");
                setState(nextc);
                return true;
            }
            return false;
        }
    }

這個代碼還是取的上面那個代碼,只是這次我們得關注重心在 else if代碼塊中。

當getState()返回>0時,說明這個線程已經持有該鎖了,但是還沒有釋放鎖,然後他它會先判斷當前線程是不是持有該鎖的線程(兩個線程不相等當然不能執行操作),這是可重入的前提,必須是同一個線程,

current == getExclusiveOwnerThread()

判斷完沒毛病後,就是簡單的對state變量進行加法操作,你重入了2次,state就爲2,重入3次就是3。可以看到這部分拿鎖操作連CAS都沒用,效率會非常快。

在解鎖方面,大家也應該想到了,就是對state-1,直到state爲0

		//releases 默認爲1
        protected final boolean tryRelease(int releases) {
            int c = getState() - releases;
            //當然也會判斷一下當前鎖的持有線程是不是跟  當前執行的線程一致
            if (Thread.currentThread() != getExclusiveOwnerThread())
                throw new IllegalMonitorStateException();
            boolean free = false;
            //如果state減爲0,則釋放鎖,否則只是更新一下state變量,重入了幾次,就要解鎖幾次。
            if (c == 0) {
                free = true;
                //鎖持有線程清空
                setExclusiveOwnerThread(null);
            }
            setState(c);
            return free;
        }

可重入鎖解決了實際使用可能的死鎖問題,而且性能消耗比線程第一次獲取鎖小很多.到目前爲止,我們已經看完了ReentrantLock的門面設計,說前面的都是門面,因爲ReentrantLock還有一個重要的組成部分還沒介紹,就是先進先出的隊列,用於暫存被睡眠的線程,我們由公平鎖來引出它。

公平 or 非公平

讓我們回到獲取鎖的流程,tryAcquire方法如果失敗了,就會被丟進一個等待隊列,先從addWaiter開始。

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

addWaiter 和 enq方法都被我摘出來。當一個節點獲取鎖失敗時,會被放入隊列中,隊列本身的插入邏輯與一般維護隊列的代碼沒什麼區別,但怎麼保證線程安全性?還是利用了CAS來保證。

在enq中,如果這個隊列本身不存在,會進行一個循環(enq),先建一個頭節點,這個頭節點沒有綁定線程信息,然後需要加入隊列的節點Node會放在尾節點。

    private Node addWaiter(Node mode) {
        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;
            if (compareAndSetTail(pred, node)) {
                pred.next = node;
                return node;
            }
        }
        enq(node);
        return node;
    }

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

addWaiter結束以後,就輪到acquireQueued。這裏的邏輯有點繞,剛進去就是一個死循環

當前線程node稱爲 節點A

  1. 先判斷 節點A前置節點 是不是頭節點
    1.1 是的話說明可以直接 嘗試獲取 鎖(tryAcquire),獲取成功,則 節點A 設爲Head節點,前置節點脫離鏈表(p.next = null)
    1.2 不是的話進入 <2> 步驟
  2. shouldParkAfterFailedAcquire
    2.1 先判斷 前置節點 是不是已經是 **請求釋放(Node.SIGNAL) ** 的狀態,是的話 返回true,接着直接執行 parkAndCheckInterrupt 方法掛起 節點A 所持有的當前線程。
    2.2 如果 前置節點 不是 **請求釋放(Node.SIGNAL) ** 的狀態,且waitStatus狀態值>0,說明 前置節點 已經被取消,就需要找前置節點的前置,一直找到waitStatus狀態 <=0爲止(do while 循環)作爲 節點A 的新前置節點。
    2.3 如果 前置節點 不是 **請求釋放(Node.SIGNAL) ** 的狀態,且waitStatus狀態值<=0 ,說明 前置節點 的waitStatus狀態需要被設置成 請求釋放(Node.SIGNAL) 的狀態,但是現在不能讓它掛起,而是繼續<1>的步驟。
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);
        }
    }

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

經過上面的循環,節點A 有兩個歸宿,一個是拿鎖成功返回,一個是被掛起,而掛起後怎麼被 喚醒再繼續 上面的步驟<1>的,玄機在解鎖的代碼。

假設tryRelease返回爲true

  1. 頭節點 Head,頭節點非空且狀態不是初始態(0),進入喚醒流程
  2. 頭節點 狀態如果 < 0,重置頭節點爲0(CAS)
  3. 取頭節點的 後繼節點 ,判斷 後繼節點 的waitStatus是不是取消態(waitStatus>0),是的話重新找離頭節點最近的後繼節點,從尾節點往前找
  4. 最後喚醒這個 後繼節點
    public final boolean release(int arg) {
        if (tryRelease(arg)) {
            Node h = head;
            if (h != null && h.waitStatus != 0)
                unparkSuccessor(h);
            return true;
        }
        return false;
    }
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;
        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);
    }

在喚醒和睡眠的代碼裏,邏輯比較繞的,就是對waitStatus狀態的變更,而總結一句話就是:

喚醒前,總會把 Head頭節點 設爲 0(初始態),並且喚醒節點成功獲取鎖之後會取代頭節點位置(acquireQueued setHead(node))
睡眠前,總會把前置節點設爲 -1 (請求釋放(Node.SIGNAL) )

介紹完AQS對隊列的使用,我們可以看到對公平鎖和非公平鎖的使用

公平鎖: 按照字面意思理解很簡單,下一個處理的線程一定是按照隊列排序的,即使是新來的線程,也必須先進隊列再操作。

FairSync是ReentrantLock的公平鎖實現類,裏面的tryAcquire方法跟NonfairSync(非公平鎖)唯一不同的就是多了一個hasQueuedPredecessors判斷

hasQueuedPredecessors的意思就是每個新來的線程必要要先判斷 隊列裏是不是已經有等待的線程,如果自己不是隊列裏第一個線程,就不允許嘗試獲取鎖。

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

非公平鎖: 首先要更正一個誤區,即使是非公平鎖,也大致保持着公平的特性,如果沒有新來的線程,對鎖的獲取也是先來後到的原則(隊列來保證),只是如果有新來的線程,就可以插隊,而插隊不插隊的判斷邏輯就是有沒有使用hasQueuedPredecessors方法(NonfairSync實現類是沒有使用hasQueuedPredecessors方法)

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