如何手寫一個AQS?

手寫一個AQS

AQS即AbstractQueuedSynchronizer,是用來實現鎖和線程同步的一個工具類。大部分操作基於CAS和FIFO隊列來實現。

如果讓我們自己基於API來實現一個鎖,實現可以分爲幾個大部分

  1. 加鎖
  2. 解鎖
  3. 入隊
  4. 出隊
  5. 阻塞
  6. 喚醒

我們來想一下這幾個部分的實現

加鎖

1.用一個變量state作爲鎖的標誌位,默認是0,表示此時所有線程都可以加鎖,加鎖的時候通過cas將state從0變爲1,cas執行成功表示加鎖成功

2.當有線程佔有了鎖,這時候有其他線程來加鎖,判斷當前來搶鎖的線程是不是佔用鎖的線程?是:重入鎖,state+1,當釋放的時候state-1,用state表示加鎖的次數 否:加鎖失敗,將線程放入等待隊列,並且阻塞

3.有沒有什麼其他可以優化的地方?當放入等待隊列的時候,看看有沒有其他線程?有,鎖被佔用了,並且輪不到當前線程來搶,直接阻塞就行了 在放入隊列時候,通過cas再嘗試獲取一波鎖,如果獲取成功,就不用阻塞了,提高了效率

解鎖

1.通過cas對state-1,如果是重入鎖,釋放一次減一次,當state=0時表示鎖被釋放。2.喚醒等待隊列中的線程

入隊

入隊這個過程和我們平常使用的隊列不同。我們平常使用的隊列每次生成一個節點放入即可。

而AQS隊列,當隊列爲空時,第一次生成兩個節點,第一個節點代表當前佔有鎖的線程,第二個節點爲搶鎖失敗的節點。不爲空的時候,每次生成一個節點放入隊尾。

「當把線程放入隊列中時,後續應該做哪些操作呢?」

如果讓你寫是不是直接放入隊列中就完事了?但Doug Lea是這樣做的

  1. 如果當前線程是隊列中的第二個節點則再嘗試搶一下鎖(不是第二個節點就不用搶來,輪不到),這樣避免了頻繁的阻塞和喚醒線程,提高了效率
  2. 上鬧鐘,讓上一個線程來喚醒自己(後續會說到,即更改上一個節點的waitStatus)
  3. 阻塞

出隊

當A線程釋放鎖,喚醒隊列中的B線程,A線程會從隊列中刪除

那出隊這個事情由誰來做?是由被喚醒的線程來做,即B線程

阻塞和喚醒

阻塞和喚醒線程調用api即可

// 阻塞線程
LockSupport.park(this)
// 喚醒線程
LockSupport.unpark(this)

獨佔鎖的獲取和釋放

JUC中的許多併發工具類ReentrantLock,CountDownLatch等的實現都依賴AbstractQueuedSynchronizer

AbstractQueuedSynchronizer定義了一個鎖實現的內部流程,而如何加鎖和解鎖則在各個子類中實現,典型的模板方法模式

AQS內部維護了一個FIFO的隊列(底層實現就是雙向鏈表),通過該隊列來實現線程的併發訪問控制,隊列中的元素是一個Node節點

static final class Node {
 //表示當前線程以共享模式持有鎖
 static final Node SHARED = new Node();
 //表示當前線程以獨佔模式持有鎖
 static final Node EXCLUSIVE = null;

 static final int CANCELLED =  1;
 static final int SIGNAL    = -1;
 static final int CONDITION = -2;
 static final int PROPAGATE = -3;

 //當前節點的狀態
 volatile int waitStatus;

 //前繼節點
 volatile Node prev;

 //後繼節點
 volatile Node next;

 //當前線程
 volatile Thread thread;

 //存儲在condition隊列中的後繼節點
 Node nextWaiter;

}

waitStatus(默認是0)表示節點的狀態,包含的狀態有

狀態 含義
CANCELLED 1 線程獲取鎖的請求已經取消
SIGNAL -1 表示當前節點的的後繼節點將要或者已經被阻塞,在當前節點釋放的時候需要unpark後繼節點
CONDITION -2 表示當前節點在等待condition,即在condition隊列中
PROPAGATE -3 表示狀態需要向後傳播,僅在共享模式下使用)

0 Node被初始化後的默認值,當前節點在隊列中等待獲取鎖

再來看AbstractQueuedSynchronizer這個類的屬性

//等待隊列的頭節點
private transient volatile Node head;

//等待隊列的尾節點
private transient volatile Node tail;

//加鎖的狀態,在不同子類中有不同的意義
private volatile int state;

「這個state在不同的子類中有不同的含義」

「ReentrantLock」:state表示加鎖的次數,爲0表示沒有被加鎖,爲1表示被加鎖1次,爲2表示被加鎖2次,因爲ReentrantLock是一個可以重入的鎖「CountDownLatch」:state表示一個計數器,當state>0時,線程調用await會被阻塞,當state值被減少爲0時,線程會被喚醒「Semaphore」:state表示資源的數量,state>0時,可以獲取資源,並將state-1,當state=0時,獲取不到資源,此時線程會被阻塞。當資源被釋放時,state+1,此時其他線程可以獲得資源

AbstractQueuedSynchronizer中的FIFO隊列是用雙向鏈表來實現的

在這裏插入圖片描述

AQS提供了獨佔鎖和共享鎖兩種加鎖方式,每種方式都有響應中斷和不響應中斷的區別,所以AQS的鎖可以分爲如下四類

  1. 不響應中斷的獨佔鎖(acquire)
  2. 響應中斷的獨佔鎖(acquireInterruptibly)
  3. 不響應中斷的共享鎖(acquireShared)
  4. 響應中斷的共享鎖(acquireSharedInterruptibly)

而釋放鎖的方式只有兩種

  1. 獨佔鎖的釋放(release)
  2. 共享鎖的釋放(releaseShared)

不響應中斷的獨佔鎖

以ReentrantLock爲例,從加鎖這一部分開始分析

// 調用ReentrantLock.FairSync#lock方法其實就是調用acquire(1);
public final void acquire(int arg) {
 if (!tryAcquire(arg) &&
  acquireQueued(addWaiter(Node.EXCLUSIVE), arg))//獲取到鎖返回false,否則返回true
  selfInterrupt();//當前線程將自己中斷
}
  1. 先嚐試獲取,如果獲取到直接退出,否則進入2
  2. 獲取鎖失敗,以獨佔模式將線程包裝成Node放到隊列中
  3. 如果放入的節點是隊列的第二個節點,則再嘗試獲取鎖,因爲此時鎖有可能釋放類,不是第二個節點就不用嘗試了,因爲輪不到。如果獲取到鎖則將當前節點設爲head節點,退出,否則進入4
  4. 設置好鬧鐘後將自己阻塞
  5. 線程被喚醒,重新競爭鎖,獲取鎖成功,繼續執行。如果線程發生過中斷,則最後重置中斷標誌位位true,即執行selfInterrupt()方法

「從代碼層面詳細分析一波,走起」

tryAcquire是讓子類實現的

protected boolean tryAcquire(int arg) {
 throw new UnsupportedOperationException();
}

這裏通過拋出異常來告訴子類要重寫這個方法,爲什麼不將這個方法定義爲abstract方法呢?因爲AQS有2種功能,獨佔和共享,如果用abstract修飾,則子類需要同時實現兩種功能的方法,對子類不友好

  1. 當隊列不爲空,嘗試將新節點通過CAS的方式設置爲尾節點,如果成功,返回附加着當前線程的節點
  2. 當隊列爲空,或者新節點通過CAS的方式設置爲尾節點失敗,進入enq方法
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)) {
   pred.next = node;
   return node;
  }
 }
 enq(node);
 return node;
}
  1. 當隊列不爲空,一直CAS,直到把新節點放入隊尾
  2. 當隊列爲空,先往對列中放入一個節點,在把傳入的節點CAS爲尾節點

「前面已經說過了哈,AQS隊列爲空時,第一次會放入2個節點」

private Node enq(final Node node) {
 for (;;) {
  Node t = tail;
  // 隊列爲空,進行初始化,
  if (t == null) {
   if (compareAndSetHead(new Node()))
    tail = head;
  } else {
   node.prev = t;
   if (compareAndSetTail(t, node)) {
    t.next = node;
    return t;
   }
  }
 }
}

放入隊列後還要幹什麼?

  1. 如果是第二個節點再嘗試獲取一波鎖,因爲此時有可能鎖已經釋放了,其他節點就不用了,因爲還輪不到
  2. 上鬧鐘,讓別的線程喚醒自己
  3. 阻塞自己
// 自旋獲取鎖,直到獲取鎖成功,或者異常退出
// 但是並不是busy acquire,因爲當獲取失敗後會被掛起,由前驅節點釋放鎖時將其喚醒
// 同時由於喚醒的時候可能有其他線程競爭,所以還需要進行嘗試獲取鎖,體現的非公平鎖的精髓。
final boolean acquireQueued(final Node node, int arg) {
 boolean failed = true;
 try {
  boolean interrupted = false;
  for (;;) {
   // 獲取前繼節點
   final Node p = node.predecessor();
   // node節點的前繼節點是head節點,嘗試獲取鎖,如果成功說明head節點已經釋放鎖了
   // 將node設爲head開始運行(head中不包含thread)
   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);
 }
}

根據前繼節點的狀態,是否可以阻塞當前獲取鎖失敗的節點

一般情況會經歷如下2個過程

  1. 默認情況下上一個節點的waitStatus=0,所以會進入compareAndSetWaitStatus方法,通過cas將上一個節點的waitStatus設置爲SIGNAL,然後return false
  2. shouldParkAfterFailedAcquire方法外面是一個死循環,當再次進入這個方法時,如果上一步cas成功,則會走第一個if,return true。接着執行parkAndCheckInterrupt,線程會阻塞
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
 int ws = pred.waitStatus;
 // 前繼節點釋放時會unpark後繼節點,可以掛起
 if (ws == Node.SIGNAL)
  return true;
 if (ws > 0) {
  //將CANCELLED狀態的線程清理出隊列
  // 後面會提到爲什麼會有CANCELLED的節點
  do {
   node.prev = pred = pred.prev;
  } while (pred.waitStatus > 0);
  pred.next = node;
 } else {
  // 將前繼節點的狀態設置爲SIGNAL,代表釋放鎖時需要喚醒後面的線程
  // cas更新可能失敗,所以不能直接返回true
  compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
 }
 return false;
}

shouldParkAfterFailedAcquire表示上好鬧鐘了,可以阻塞線程了。後續當線程被喚醒的時候會從return語句出繼續執行,然後進入acquireQueued方法的死循環,重新搶鎖。至此,加鎖結束。

// 掛起線程,返回是否被中斷過
private final boolean parkAndCheckInterrupt() {
 // 阻塞線程
 LockSupport.park(this);
 // 返回當前線程是否被調用過Thread#interrupt方法
 return Thread.interrupted();
}

最後用一個流程圖來解釋不響應中斷的獨佔鎖

入隊過程中有異常該怎麼辦?

可以看到上面調用acquireQueued方法發生異常的時候,會調用cancelAcquire方法,我們就詳細分析一下這個cancelAcquire方法有哪些作用?

「哪些地方執行發生異常會執行cancelAcquire?」

可以看到調用cancelAcquire方法的有如下幾個部分「分析這些方法的調用,發現基本就是如下2個地方會發生異常」

  1. 嘗試獲取鎖的方法如tryAcquire,這些一般是交給子類來實現的
  2. 當線程是被調用Thread#interrupt方法喚醒,如果要響應中斷,會拋出InterruptedException

//處理異常退出的node
private void cancelAcquire(Node node) {
 if (node == null)
  return;

 // 設置該節點不再關聯任何線程
 node.thread = null;

 // 跳過CANCELLED節點,找到一個有效的前繼節點
 Node pred = node.prev;
 while (pred.waitStatus > 0)
  node.prev = pred = pred.prev;

 // 獲取過濾後的有效節點的後繼節點
 Node predNext = pred.next;

 // 設置狀態爲取消
 node.waitStatus = Node.CANCELLED;

 // case 1
 if (node == tail && compareAndSetTail(node, pred)) {
  compareAndSetNext(pred, predNext, null);
 } else {
  // case 2
  int ws;
  if (pred != head &&
   ((ws = pred.waitStatus) == Node.SIGNAL ||
    (ws <= 0 && compareAndSetWaitStatus(pred, ws, Node.SIGNAL))) &&
   pred.thread != null) {
   Node next = node.next;
   if (next != null && next.waitStatus <= 0)
    compareAndSetNext(pred, predNext, next);
  } else {
   // case3
   unparkSuccessor(node);
  }

  node.next = node; // help GC
 }
}

將node出隊有如下三種情況

  1. 當前節點是tail
  2. 當前節點不是head的後繼節點,也不是tail
  3. 當前節點是head的後繼節點

「當前節點是tail」

compareAndSetTail,將tail指向pred compareAndSetNext,將pred的next指向null,也就是把當前節點移出隊列

在這裏插入圖片描述

「當前節點不是head的後繼節點,也不是tail」這裏將node的前繼節點的next指向了node的後繼節點,即compareAndSetNext(pred, predNext, next),「注意pred和node節點中間有可能有CANCELLED的節點,怕亂就沒畫出來」

「當前節點是head的後繼節點」

沒有對隊列進行操作,只是進行head後繼節點的喚醒操作(unparkSuccessor方法,後面會分析這個方法),因爲此時他是head的後繼節點,還是有可能獲取到鎖的,所以喚醒它嘗試獲取一波鎖,當再次調用到shouldParkAfterFailedAcquire(判斷是否應該阻塞的方法時)會把CANCELLED狀態的節點從隊列中刪除

獨佔鎖的釋放

獨佔鎖是釋放其實就是利用cas將state-1,當state=0表示鎖被釋放,需要將阻塞隊列中的線程喚醒

// 調用ReentrantLock#unlock方法其實就是調用release(1)
public final boolean release(int arg) {
 // 嘗試釋放鎖
 // 當state=0,表示鎖被釋放,tryRelease返回true,此時需要喚醒阻塞隊列中的線程
 if (tryRelease(arg)) {
  Node h = head;
  if (h != null && h.waitStatus != 0)
   unparkSuccessor(h);
  return true;
 }
 return false;
}

「tryRelease即具體的解鎖邏輯,需要子類自己去實現」

「喚醒同步隊列中的線程,可以看到前面加了判斷h != null && h.waitStatus != 0」

h = null,說明同步同步隊列中沒有數據,則不需要喚醒 h = null && waitStatus = 0,同步隊列是有了,但是沒有線程給自己上鬧鐘,不用喚醒 h != null && waitStatus < 0,說明頭節點被人上了鬧鐘,自己需要喚醒阻塞的線程 h != null && waitStatus > 0,頭節點因爲發生異常被設置爲取消,但還是得喚醒線程

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;
  // 從隊列尾部向前遍歷找到最前面的一個waitStatus<=0的節點
  for (Node t = tail; t != null && t != node; t = t.prev)
   if (t.waitStatus <= 0)
    s = t;
 }
 if (s != null)
  // 喚醒節點,但並不表示它持有鎖,要從阻塞的地方開始運行
  LockSupport.unpark(s.thread);
}

「爲什麼要從後向前找第一個非CANCELLED的節點呢?」

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

這其實和入隊的邏輯有關係,假如Node1在圖示位置掛起了,Node1後面又陸續增加了Node2和Node3,如果此時從前向後遍歷會導致元素丟失,不能正確喚醒線程

分析一下獨佔鎖響應中斷和不響應中斷的區別

我們之前說過獨佔鎖可以響應中斷,也可以不響應中斷,調用的方法如下?

  1. 不響應中斷的獨佔鎖(acquire)
  2. 響應中斷的獨佔鎖(acquireInterruptibly)

所以我們只需要看這2個方法的區別在哪裏就可以,我下面只列出有區別的部分哈。

public final void acquire(int arg) {
    if (!tryAcquire(arg) &&
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        selfInterrupt();
}
 public final void acquireInterruptibly(int arg)
         throws InterruptedException 
{
     // 判斷線程是否被中斷
     if (Thread.interrupted())
         throw new InterruptedException();
     if (!tryAcquire(arg))
         doAcquireInterruptibly(arg);
 }

「acquire在嘗試獲取鎖的時候完全不管線程有沒有被中斷,而acquireInterruptibly在嘗試獲取鎖之前會判斷線程是否被中斷,如果被中斷,則直接拋出異常。」

tryAcquire方法一樣,所以我們只需要對比acquireQueued方法和doAcquireInterruptibly方法的區別即可「執行acquireQueued方法當線程發生中斷時,只是將interrupted設置爲true,並且調用selfInterrupt方法將中斷標誌位設置爲true」「而執行doAcquireInterruptibly方法,當線程發生中斷時,直接拋出異常。」

最後看一下parkAndCheckInterrupt方法,這個方法中判斷線程是否中斷的邏輯特別巧!

private final boolean parkAndCheckInterrupt() {
 LockSupport.park(this);
 return Thread.interrupted();
}

「Thread類提供瞭如下2個方法來判斷線程是否是中斷狀態」

  1. isInterrupted
  2. interrupted

「這裏爲什麼用interrupted而不是isInterrupted的呢?」

演示一下這2個方法的區別

@Test
public void testInterrupt() throws InterruptedException {
    Thread thread = new Thread(() -> {
        while (true) {}
    });
    thread.start();
    TimeUnit.MICROSECONDS.sleep(100);
    thread.interrupt();
    // true
    System.out.println(thread.isInterrupted());
    // true
    System.out.println(thread.isInterrupted());
    // true
    System.out.println(thread.isInterrupted());
}
@Test
public void testInterrupt2() {
    Thread.currentThread().interrupt();
    // true
    System.out.println(Thread.interrupted());
    // false
    System.out.println(Thread.interrupted());
    // false
    System.out.println(Thread.interrupted());
}

「isInterrupted和interrupted的方法區別如下」

Thread#isInterrupted:測試線程是否是中斷狀態,執行後不更改狀態標誌 Thread#interrupted:測試線程是否是中斷狀態,執行後將中斷標誌更改爲false

接着再寫2個例子

public static void main(String[] args) {
 LockSupport.park();
 // end被一直阻塞沒有輸出
 System.out.println("end");
}
public static void main(String[] args) {
 Thread.currentThread().interrupt();
 LockSupport.park();
 // 輸出end
 System.out.println("end");
}

可以看到當線程被中斷時,調用park()方法並不會被阻塞

public static void main(String[] args) {
 Thread.currentThread().interrupt();
 LockSupport.park();
 // 返回中斷狀態,並且清除中斷狀態
 Thread.interrupted();
 // 輸出start
 System.out.println("start");
 LockSupport.park();
 // end被阻塞,沒有輸出
 System.out.println("end");
}

到這我們就能理解爲什麼要進行中斷的復位了

  • 如果當前線程是非中斷狀態,則在執行park時被阻塞,返回中斷狀態false
  • 如果當前線程是中斷狀態,則park方法不起作用,返回中斷狀態true,interrupted將中斷復位,變爲false
  • 再次執行循環的時候,前一步已經在線程的中斷狀態進行了復位,則再次調用park方法時會阻塞

「所以這裏要對中斷進行復位,是爲了不讓循環一直執行,讓當前線程進入阻塞狀態,如果不進行復位,前一個線程在獲取鎖之後執行了很耗時的操作,那當前線程豈不是要一直執行死循環,造成CPU使用率飆升?」

獨佔鎖的獲取和釋放我們已經搞清楚了,共享鎖的獲取和釋放我們放到分析CountDownLatch源碼的那一節來分析

基於AQS自己寫一個鎖

你看AQS已經把入隊,出隊,阻塞,喚醒的操作都封裝好了,當我們用AQS來實現自己的鎖時,就非常的方便了,只需要重寫加鎖和解鎖的邏輯即可。我這裏演示一個基於AQS實現的非重入的互斥鎖

public class MyLock {

    private final Sync sync;

    public MyLock() {
        sync = new Sync();
    }

    public class Sync extends AbstractQueuedSynchronizer {

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

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

    }

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

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


本文分享自微信公衆號 - Java識堂(erlieStar)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

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