java併發程式設計系列:牛逼的AQS(上)

小孩子4919發表於2019-05-07

標籤: 「我們都是小青蛙」公眾號文章

設計java的大叔們為了我們方便的自定義各種同步工具,為我們提供了大殺器AbstractQueuedSynchronizer類,這是一個抽象類,以下我們會簡稱AQS,翻譯成中文就是抽象佇列同步器。這傢伙老有用了,封裝了各種底層的同步細節,我們程式設計師想自定義自己的同步工具的時候,只需要定義這個類的子類並覆蓋它提供的一些方法就好了。我們前邊用到的顯式鎖ReentrantLock就是藉助了AQS的神力實現的,現在馬上來看看這個類的實現原理以及如何使用它自定義同步工具。

同步狀態

AQS中維護了一個名叫state的欄位,是由volatile修飾的,它就是所謂的同步狀態

private volatile int state;
複製程式碼

並且提供了幾個訪問這個欄位的方法:

方法名 描述
protected final int getState() 獲取state的值
protected final void setState(int newState) 設定state的值
protected final boolean compareAndSetState(int expect, int update) 使用CAS方式更新state的值

可以看到這幾個方法都是final修飾的,說明子類中無法重寫它們。另外它們都是protected修飾的,說明只能在子類中使用這些方法。

在一些執行緒協調的場景中,一個執行緒在進行某些操作的時候其他的執行緒都不能執行該操作,比如持有鎖時的操作,在同一時刻只能有一個執行緒持有鎖,我們把這種情景稱為獨佔模式;在另一些執行緒協調的場景中,可以同時允許多個執行緒同時進行某種操作,我們把這種情景稱為共享模式

我們可以通過修改state欄位代表的同步狀態來實現多執行緒的獨佔模式或者共享模式

比如在獨佔模式下,我們可以把state的初始值設定成0,每當某個執行緒要進行某項獨佔操作前,都需要判斷state的值是不是0,如果不是0的話意味著別的執行緒已經進入該操作,則本執行緒需要阻塞等待;如果是0的話就把state的值設定成1,自己進入該操作。這個先判斷再設定的過程我們可以通過CAS操作保證原子性,我們把這個過程稱為嘗試獲取同步狀態。如果一個執行緒獲取同步狀態成功了,那麼在另一個執行緒嘗試獲取同步狀態的時候發現state的值已經是1了就一直阻塞等待,直到獲取同步狀態成功的執行緒執行完了需要同步的操作後釋放同步狀態,也就是把state的值設定為0,並通知後續等待的執行緒。

共享模式下的道理也差不多,比如說某項操作我們允許10個執行緒同時進行,超過這個數量的執行緒就需要阻塞等待。那麼我們就可以把state的初始值設定為10,一個執行緒嘗試獲取同步狀態的意思就是先判斷state的值是否大於0,如果不大於0的話意味著當前已經有10個執行緒在同時執行該操作,本執行緒需要阻塞等待;如果state的值大於0,那麼可以把state的值減1後進入該操作,每當一個執行緒完成操作的時候需要釋放同步狀態,也就是把state的值加1,並通知後續等待的執行緒。

所以對於我們自定義的同步工具來說,需要自定義獲取同步狀態與釋放同步狀態的方式,而AQS中的幾個方法正是用來做這個事兒的:

方法名 描述
protected boolean tryAcquire(int arg) 獨佔式的獲取同步狀態,獲取成功返回true,否則false
protected boolean tryRelease(int arg) 獨佔式的釋放同步狀態,釋放成功返回true,否則false
protected int tryAcquireShared(int arg) 共享式的獲取同步狀態,獲取成功返回true,否則false
protected boolean tryReleaseShared(int arg) 共享式的釋放同步狀態,釋放成功返回true,否則false
protected boolean isHeldExclusively() 在獨佔模式下,如果當前執行緒已經獲取到同步狀態,則返回 true;其他情況則返回 false

我們說AQS是一個抽象類,我們以tryAcquire為例看看它在AQS中的實現:

protected boolean tryAcquire(int arg) {
    throw new UnsupportedOperationException();
}
複製程式碼

喔?我的天,竟然只是丟擲個異常,這不科學。是的,在AQS中的確沒有實現這個方法,不同的同步工具針對的具體併發場景不同,所以如何獲取同步狀態和如何釋放同步狀態是需要我們在自定義的AQS子類中實現的,如果我們自定義的同步工具需要在獨佔模式下工作,那麼我們就重寫tryAcquiretryReleaseisHeldExclusively方法,如果是在共享模式下工作,那麼我們就重寫tryAcquireSharedtryReleaseShared方法。比如在獨佔模式下我們可以這樣定義一個AQS子類:

public class Sync extends AbstractQueuedSynchronizer {

    @Override
    protected boolean tryAcquire(int arg) {
        return compareAndSetState(0, 1);
    }

    @Override
    protected boolean tryRelease(int arg) {
        setState(0);
        return true;
    }

    @Override
    protected boolean isHeldExclusively() {
        return getState() == 1;
    }
}
複製程式碼

tryAcquire表示嘗試獲取同步狀態,我們這裡定義了一種極其簡單的獲取方式,就是使用CAS的方式把state的值設定成1,如果成功則返回true,失敗則返回falsetryRelease表示嘗試釋放同步狀態,這裡同樣採用了一種極其簡單的釋放演算法,直接把state的值設定成0就好了。isHeldExclusively就表示當前是否有執行緒已經獲取到了同步狀態。如果你有更復雜的場景,可以使用更復雜的獲取和釋放演算法來重寫這些方法

通過上邊的嘮叨,我們只是瞭解了啥是個同步狀態,學會了如何通過繼承AQS來自定義獨佔模式和共享模式下獲取和釋放同步狀態的各種方法,但是你會驚訝的發現會了這些仍然沒有什麼卵用。我們期望的效果是一個執行緒獲取同步狀態成功會立即返回true,並繼續執行某些需要同步的操作,在操作完成後釋放同步狀態,如果獲取同步狀態失敗的話會立即返回false,並且進入阻塞等待狀態,那執行緒是怎麼進入等待狀態的呢?不要走開,下節更精彩。

同步佇列

AQS中還維護了一個所謂的同步佇列,這個佇列的節點類被定義成了一個靜態內部類,它的主要欄位如下:

static final class Node {
    volatile int waitStatus;
    volatile Node prev;
    volatile Node next;
    volatile Thread thread;
    Node nextWaiter;
    
    static final int CANCELLED =  1;
    static final int SIGNAL    = -1;
    static final int CONDITION = -2;
    static final int PROPAGATE = -3;
}
複製程式碼

AQS中定義一個頭節點引用,一個尾節點引用

private transient volatile Node head;
private transient volatile Node tail;
複製程式碼

通過這兩個節點就可以控制到這個佇列,也就是說可以在佇列上進行諸如插入和移除操作。可以看到Node類中有一個Thread型別的欄位,這表明每一個節點都代表一個執行緒。我們期望的效果是當一個執行緒獲取同步狀態失敗之後,就把這個執行緒阻塞幷包裝成Node節點插入到這個同步佇列中,當獲取同步狀態成功的執行緒釋放同步狀態的時候,同時通知在佇列中下一個未獲取到同步狀態的節點,讓該節點的執行緒再次去獲取同步狀態

這個節點類的其他欄位的意思我們之後遇到會詳細嘮叨,我們先看一下獨佔模式共享模式下在什麼情況下會往這個同步佇列裡新增節點,什麼情況下會從它裡邊移除節點,以及執行緒阻塞和恢復的實現細節。

獨佔式同步狀態獲取與釋放

獨佔模式下,同一個時刻只能有一個執行緒獲取到同步狀態,其他同時去獲取同步狀態的執行緒會被包裝成一個Node節點放到同步佇列中,直到獲取到同步狀態的執行緒釋放掉同步狀態才能繼續執行。初始狀態的同步佇列是一個空佇列,裡邊一個節點也沒有,就長這樣:

image_1c3cqmj2g1ve01vd36i71ui91jo645.png-5.5kB

接下來我們就要詳細看一下獲取同步狀態失敗的執行緒是如何被包裝成Node節點插入到佇列中同時阻塞等待的。

前邊說過,獲取和釋放同步狀態的方式是由我們自定義的,在獨佔模式需要我們定義AQS的子類並且重寫下邊這些方法:

protected boolean tryAcquire(int arg)
protected boolean tryRelease(int arg)
protected boolean isHeldExclusively()
複製程式碼

在定義了這些方法後,誰去呼叫它們呢?AQS裡定義了一些呼叫它們的方法,這些方法都是由public final修飾的:

方法名 描述
void acquire(int arg) 獨佔式獲取同步狀態,如果獲取成功則返回,如果失敗則將當前執行緒包裝成Node節點插入同步佇列中。
void acquireInterruptibly(int arg) 與上個方法意思相同,只不過一個執行緒在執行本方法過程中被別的執行緒中斷,則丟擲InterruptedException異常。
boolean tryAcquireNanos(int arg, long nanos) 在上個方法的基礎上加了超時限制,如果在給定時間內沒有獲取到同步狀態,則返回false,否則返回true
boolean release(int arg) 獨佔式的釋放同步狀態。

忽然擺了這麼多方法可能有點突兀哈,我們先看一下acquire方法的原始碼:

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

程式碼顯示acquire方法實際上是通過tryAcquire方法來獲取同步狀態的,如果tryAcquire方法返回true則結束,如果返回false則繼續執行。這個tryAcquire方法就是我們自己規定的獲取同步狀態的方式。假設現在有一個執行緒已經獲取到了同步狀態,而執行緒t1同時呼叫tryAcquire方法嘗試獲取同步狀態,結果就是獲取失敗,會先執行addWaiter方法,我們一起來看一下這個方法:

private Node addWaiter(Node mode) {
    Node node = new Node(Thread.currentThread(), mode);  //構造一個新節點
    Node pred = tail;
    if (pred != null) { //尾節點不為空,插入到佇列最後
        node.prev = pred;
        if (compareAndSetTail(pred, node)) {       //更新tail,並且把新節點插入到列表最後
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}

private Node enq(final Node node) {
    for (;;) {
        Node t = tail;
        if (t == null) {    //tail節點為空,初始化佇列
            if (compareAndSetHead(new Node()))  //設定head節點
                tail = head;
        } else {    //tail節點不為空,開始真正插入節點
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                return t;
            }
        }
    }
}
複製程式碼

可以看到,這個addWaiter方法就是向佇列中插入節點的方法。首先會構造一個Node節點,假設這個節點為節點1,它的thread欄位就是當前執行緒t2,這個節點被剛剛建立出來的樣子就是這樣:

image_1c3dakn72sg8n8s1ib71jeu19pp3t.png-19.6kB

然後我們再分析一下具體的插入過程。如果tail節點不為空,直接把新節點插入到佇列後邊就返回了,如果tail節點為空,呼叫enq方法先初始化一下headtail節點之後再把新節點插入到佇列後邊。enq方法的這幾行初始化佇列的程式碼需要特別注意:

if (t == null) {    //tail節點為空,初始化佇列
    if (compareAndSetHead(new Node()))  //設定head節點
        tail = head;
} else {
    //真正插入節點的過程
}
複製程式碼

也就是說在佇列為空的時候會先讓headtail引用指向同一個節點後再進行插入操作,而這個節點竟然就是簡簡單單的new Node(),真是沒有任何新增劑呀~ 我們先把這個節點稱為0號節點吧,這個節點的任何一個欄位都沒有被賦值,所以在第一次節點插入後,佇列其實長這樣:

image_1c3dapsu21h36kbj1rev135718d05q.png-42.3kB

其中的節點1才是我們真正插入的節點,代表獲取同步狀態失敗的執行緒,0號節點是在初始化過程中建立的,我們之後再看它有什麼用。

addWaiter方法呼叫完會返回新插入的那個節點,也就是節點1acquire方法會接著呼叫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;
            }
            if (shouldParkAfterFailedAcquire(p, node) &&
                parkAndCheckInterrupt())
                interrupted = true;
        }
    } finally {
        if (failed)
            cancelAcquire(node);
    }
}
複製程式碼

可以看到,如果新插入的節點的前一個節點是頭節點的話,會再次呼叫tryAcquire嘗試獲取同步狀態,這個主要是怕獲取同步狀態的執行緒很快就把同步狀態給釋放了,所以在當前執行緒阻塞之前抱著僥倖的心理再試試能不能成功獲取到同步狀態,如果僥倖可以獲取,那就呼叫setHead方法把頭節點換成自己:

private void setHead(Node node) {
    head = node;
    node.thread = null;
    node.prev = null;
}
複製程式碼

同時把本Node節點的thread欄位設定為null,意味著自己成為了0號節點

如果當前Node節點不是頭節點或者已經獲取到同步狀態的執行緒並沒有釋放同步狀態,那就乖乖的往下執行shouldParkAfterFailedAcquire方法:

private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    int ws = pred.waitStatus;   //前一個節點的狀態
    if (ws == Node.SIGNAL)  //Node.SIGNAL的值是-1
        return true;
    if (ws > 0) {   //當前執行緒已被取消操作,把處於取消狀態的節點都移除掉
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {    //設定前一個節點的狀態為-1
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}
複製程式碼

這個方法是對Node節點中的waitStatus的各種操作。如果當前節點的前一個節點的waitStatusNode.SIGNAL,也就是-1,那麼意味著當前節點可以被阻塞,如果前一個節點的waitStatus大於0,意味著該節點代表的執行緒已經被取消操作了,需要把所有waitStatus大於0的節點都移除掉,如果前一個節點的waitStatus既不是-1,也不大於0,就把如果前一個節點的waitStatus設定成Node.SIGNAL。我們知道Node類裡定義了一些代表waitStatus的靜態變數,我們來看看waitStatus的各個值都是什麼意思吧:

靜態變數 描述
Node.CANCELLED 1 節點對應的執行緒已經被取消了(我們後邊詳細會說執行緒如何被取消)
Node.SIGNAL -1 表示後邊的節點對應的執行緒處於等待狀態
Node.CONDITION -2 表示節點在等待佇列中(稍後會詳細說什麼是等待佇列)
Node.PROPAGATE -3 表示下一次共享式同步狀態獲取將被無條件的傳播下去(稍後再說共享式同步狀態的獲取與釋放時詳細嘮叨)
0 初始狀態

現在我們重點關注waitStauts0或者-1的情況。目前我們的當前節點是節點1,它對應著當前執行緒,當前節點的前一個節點是0號節點。在一開始,所有的Node節點的waitStatus都是0,所以在第一次呼叫shouldParkAfterFailedAcquire方法時,當前節點的前一個節點,也就是0號節點waitStatus會被設定成Node.SIGNAL立即返回false,這個狀態的意思就是說0號節點後邊的節點都處於等待狀態,現在的佇列已經變成了這個樣子:

image_1c3datk24q84tg05qi14di14qk67.png-59.7kB

由於acquireQueued方法是一個迴圈,在第二次執行到shouldParkAfterFailedAcquire方法時,由於0號節點waitStatus已經為Node.SIGNAL了,所以shouldParkAfterFailedAcquire方法會返回true,然後繼續執行parkAndCheckInterrupt方法:

private final boolean parkAndCheckInterrupt() {
    LockSupport.park(this);
    return Thread.interrupted();
}
複製程式碼

LockSupport.park(this)方法:

public static void park(Object blocker) {
    Thread t = Thread.currentThread();
    setBlocker(t, blocker);
    UNSAFE.park(false, 0L);     //呼叫底層方法阻塞執行緒
    setBlocker(t, null);
}
複製程式碼

其中的UNSAFE.park(false, 0L)方法如下:

public native void park(boolean var1, long var2);
複製程式碼

這個就表示立即阻塞執行緒,這是一個底層方法,我們程式設計師就不用關心作業系統時如何阻塞執行緒的了。呼~至此,我們在獨佔模式下跑完了一個獲取不到同步狀態的執行緒是怎麼被插入到同步佇列以及被阻塞的過程。這個過程需要大家多看幾遍,畢竟比較麻煩哈~

如果此時再新來一個執行緒t2呼叫acquire方法要求獲取同步狀態的話,它同樣會被包裝成Node插入同步佇列的,效果就像下圖一樣:

image_1c3dbas6bkii33r5c0g3f3ss6k.png-60.4kB

大家注意一下節點1waitStauts已經變成-1了,別忘了waitStauts值為-1的時候,也就是Node.SIGNAL意味著它的下一個節點處於等待狀態,因為0號節點節點1waitStauts值都為-1,也就意味著它們兩個的後繼節點,也就是節點1節點2都處於等待狀態。

以上就是執行緒t1t2在某個執行緒已經獲取了同步狀態的情況下呼叫acquire方法時所產生的後果,acquireInterruptiblyacquire方法基本一致,只不過它是可中斷的,也就是說在一個執行緒呼叫acquireInterruptibly由於沒有獲取到同步狀態而發生阻塞之後,如果有別的執行緒中斷了這個執行緒,則acquireInterruptibly方法會丟擲InterruptedException異常並返回。tryAcquireNanos也是支援中斷的,只不過還帶有一個超時時間,如果超出了該時間tryAcquireNanos還沒有返回,則返回false

如果一個執行緒在各種acquire方法中獲取同步狀態失敗的話,會被包裝成Node節點放到同步佇列,這個可以看作是一個插入過程。有進就有出,如果一個執行緒完成了獨佔操作,就需要釋放同步狀態,同時把同步佇列第一個(非0號節點)節點代表的執行緒叫醒,在我們上邊的例子中就是節點1,讓它繼續執行,這個釋放同步狀態的過程就需要呼叫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;
}
複製程式碼

可以看到這個方法會用到我們在AQS子類裡重寫的tryRelease方法,如果成功的釋放了同步狀態,那麼就繼續往下執行,如果頭節點head不為null並且headwaitStatus不為0,就執行unparkSuccessor方法:

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) {    //如果node為最後一個節點或者node的後繼節點被取消了
            s = null;
            for (Node t = tail; t != null && t != node; t = t.prev)   
                if (t.waitStatus <= 0)  //找到離頭節點最近的waitStatus為負數的節點
                    s = t;
        }
        if (s != null)
            LockSupport.unpark(s.thread);   //喚醒該節點對應的執行緒
    }
複製程式碼

我們現在的頭節點head指向的是0號節點,它的狀態為-1,所以它的waitStatus首先會被設定成0,接著它的後繼節點,也就是節點1代表的執行緒會被這樣呼叫LockSupport.unpark(s.thread),這個方法的意思就是喚醒節點1對應的執行緒t2,把節點1thread設定為null並把它設定為頭節點,修改後的佇列就長下邊這樣:

image_1c3dfd25n1nc1acd1c7btcvd7u71.png-41.9kB

所以現在等待佇列裡只有一個t2執行緒是阻塞的。這就是釋放同步狀態的過程。

獨佔式同步工具舉例

看完了獨佔式同步狀態獲取與釋放的原理,我們可以嘗試自定義一個簡單的獨佔式同步工具,我們常用的就是一個獨佔式同步工具,我們下邊來定義一個簡單的鎖:

import java.util.concurrent.locks.AbstractQueuedSynchronizer;

public class PlainLock {

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

        @Override
        protected boolean isHeldExclusively() {
            return getState() == 1;
        }
    }

    private Sync sync = new Sync();


    public void lock() {
        sync.acquire(1);
    }

    public void unlock() {
        sync.release(1);
    }
}
複製程式碼

我們在PlainLock中定義了一個AQS子類Sync,重寫了一些方法來自定義了在獨佔模式下獲取和釋放同步狀態的方式,靜態內部類就是AQS子類在我們自定義同步工具中最常見的定義方式。然後在PlainLock裡定義了lock方法代表加鎖,unlock方法程式碼解鎖,具體的方法呼叫我們上邊都快說吐了,這裡就不想讓你再吐一次了,看一下這個鎖的應用:

public class Increment {

    private int i;

    private PlainLock lock = new PlainLock();

    public void increase() {
        lock.lock();
        i++;
        lock.unlock();
    }

    public int getI() {
        return i;
    }

    public static void test(int threadNum, int loopTimes) {
        Increment increment = new Increment();

        Thread[] threads = new Thread[threadNum];

        for (int i = 0; i < threads.length; i++) {
            Thread t = new Thread(new Runnable() {
                @Override
                public void run() {
                    for (int i = 0; i < loopTimes; i++) {
                        increment.increase();
                    }
                }
            });
            threads[i] = t;
            t.start();
        }

        for (Thread t : threads) {  //main執行緒等待其他執行緒都執行完成
            try {
                t.join();
            } catch (InterruptedException e) {
                throw new RuntimeException(e);
            }
        }

        System.out.println(threadNum + "個執行緒,迴圈" + loopTimes + "次結果:" + increment.getI());
    }

    public static void main(String[] args) {
        test(20, 1);
        test(20, 10);
        test(20, 100);
        test(20, 1000);
        test(20, 10000);
        test(20, 100000);
        test(20, 1000000);
    }
}
複製程式碼

執行結果:

20個執行緒,迴圈1次結果:20
20個執行緒,迴圈10次結果:200
20個執行緒,迴圈100次結果:2000
20個執行緒,迴圈1000次結果:20000
20個執行緒,迴圈10000次結果:200000
20個執行緒,迴圈100000次結果:2000000
20個執行緒,迴圈1000000次結果:20000000
複製程式碼

很顯然這個我們只寫了幾行程式碼的鎖已經起了作用,這就是AQS的強大之處,我們只需要寫很少的東西就可以構建一個同步工具,而且不用考慮底層複雜的同步狀態管理、執行緒的排隊、等待與喚醒等等機制。

共享式同步狀態獲取與釋放

共享式獲取與獨佔式獲取的最大不同就是在同一時刻是否有多個執行緒可以同時獲取到同步狀態。獲取不到同步狀態的執行緒也需要被包裝成Node節點後阻塞的,而可以訪問同步佇列的方法就是下邊這些:

|void acquireShared(int arg)|共享式獲取同步狀態,如果失敗則將當前執行緒包裝成Node節點插入同步佇列中。。| |void acquireSharedInterruptibly(int arg)|與上個方法意思相同,只不過一個執行緒在執行本方法過程中被別的執行緒中斷,則丟擲InterruptedException異常。| |boolean tryAcquireSharedNanos(int arg, long nanos)|在上個方法的基礎上加了超時限制,如果在給定時間內沒有獲取到同步狀態,則返回false,否則返回true。| |boolean releaseShared(int arg)|共享式的釋放同步狀態。|

哈,和獨佔模式下的方法長得非常像嘛,只是每個方法中都加了一個Shared單詞。它們的功能也是一樣一樣的,以acquireShared方法為例:

public final void acquireShared(int arg) {
    if (tryAcquireShared(arg) < 0)
        doAcquireShared(arg);
}
複製程式碼

這個方法會呼叫我們自定義的AQS子類中的tryAcquireShared方法去獲取同步狀態,只不過tryAcquireShared的返回值是一個int值,該值不小於0的時候表示獲取同步狀態成功,則acquireShared方法直接返回,什麼都不做;如果該返回值大於0的時候,表示獲取同步狀態失敗,則會把該執行緒包裝成Node節點插入同步佇列,插入過程和獨佔模式下的過程差不多,我們這就不多廢話了。

另外兩個acquire方法也不多廢話了,只不過一個是可中斷的,一個是支援超時的~

釋放同步狀態的方法也和獨佔模式的差不多:

public final boolean releaseShared(int arg) {
    if (tryReleaseShared(arg)) {
        doReleaseShared();
        return true;
    }
    return false;
}
複製程式碼

這個方法會呼叫我們自定義的AQS子類中的tryReleaseShared方法去釋放同步狀態,如果釋放成功的話會移除同步佇列中的一個阻塞節點。與獨佔模式不同的一點是,可能同時會有多個執行緒釋釋放同步狀態,也就是可能多個執行緒會同時移除同步佇列中的阻塞節點,哈哈,如何保證移除過程的安全性?這個問題就不看原始碼了,大家自己嘗試著寫寫。

共享式同步工具舉例

假設某個操作只能同時有兩個執行緒操作,其他的執行緒需要處於等待狀態,我們可以這麼定義這個鎖:

import java.util.concurrent.locks.AbstractQueuedSynchronizer;

public class DoubleLock {


    private static class Sync extends AbstractQueuedSynchronizer {

        public Sync() {
            super();
            setState(2);    //設定同步狀態的值
        }

        @Override
        protected int tryAcquireShared(int arg) {
            while (true) {
                int cur = getState();
                int next = getState() - arg;
                if (compareAndSetState(cur, next)) {
                    return next;
                }
            }
        }

        @Override
        protected boolean tryReleaseShared(int arg) {
            while (true) {
                int cur = getState();
                int next = cur + arg;
                if (compareAndSetState(cur, next)) {
                    return true;
                }
            }
        }
    }
    
    private Sync sync = new Sync();
    
    public void lock() {
        sync.acquireShared(1);     
    }
    
    public void unlock() {
        sync.releaseShared(1);
    }
}
複製程式碼

state的初始值釋2,每當一個執行緒呼叫tryAcquireShared獲取到同步狀態時,state的值都會減1,當state的值為0時,其他執行緒就無法獲取到同步狀態從而被包裝成Node節點進入同步佇列等待。

AQS中其他針對同步佇列的重要方法

除了一系列acquirerelease方法,AQS還提供了許多直接訪問這個佇列的方法,它們由都是public final修飾的:

方法名 描述
boolean hasQueuedThreads() 是否有正在等待獲取同步狀態的執行緒。
boolean hasContended() 是否某個執行緒曾經因為獲取不到同步狀態而阻塞
Thread getFirstQueuedThread() 返回佇列中第一個(等待時間最長的)執行緒,如果目前沒有將任何執行緒加入佇列,則返回 null
boolean isQueued(Thread thread) 如果給定執行緒的當前已加入同步佇列,則返回 true。
int getQueueLength() 返回等待獲取同步狀態的執行緒數估計值,因為在構造該結果時,多執行緒環境下實際執行緒集合可能發生大的變化
Collection<Thread> getQueuedThreads() 返回包含可能正在等待獲取的執行緒 collection,因為在構造該結果時,多執行緒環境下實際執行緒集合可能發生大的變化

如果有需要的話,可以在我們自定義的同步工具中使用它們。

題外話

寫文章挺累的,有時候你覺得閱讀挺流暢的,那其實是背後無數次修改的結果。如果你覺得不錯請幫忙轉發一下,萬分感謝~ 這裡是我的公眾號「我們都是小青蛙」,裡邊有更多技術乾貨,時不時扯一下犢子,歡迎關注:

java併發程式設計系列:牛逼的AQS(上)

小冊

另外,作者還寫了一本MySQL小冊:《MySQL是怎樣執行的:從根兒上理解MySQL》的連結 。小冊的內容主要是從小白的角度出發,用比較通俗的語言講解關於MySQL進階的一些核心概念,比如記錄、索引、頁面、表空間、查詢優化、事務和鎖等,總共的字數大約是三四十萬字,配有上百幅原創插圖。主要是想降低普通程式設計師學習MySQL進階的難度,讓學習曲線更平滑一點~ 有在MySQL進階方面有疑惑的同學可以看一下:

java併發程式設計系列:牛逼的AQS(上)

相關文章