連肝4天,這瞬間戳中面試官小心心的AQS大餐,給大家安排上!

FishCode發表於2020-08-21
點讚的靚仔,你時人群中最閃耀的光芒

前言

AQS,英文全稱AbstractQueuedSynchronizer,直接翻譯為抽象的佇列同步器。是JDK1.5出現的一個用於解決併發問題的工具類,由大名鼎鼎的Doug Lea打造,與synchornized關鍵字不同的是,AQS是通過程式碼解決併發問題。

回顧併發問題

併發問題是指在多執行緒執行環境下,共享資源安全的問題。
現在的銀行賬戶,通過銀行卡和手機銀行都可以操作賬戶, 如果我們同時拿著銀行卡和存摺去銀行搞事情,會怎麼樣呢?

package demo.pattren.aqs;

public class Money {
    /**
     * 假設現在賬戶有1000塊錢
     */
    private int money = 1000;
    /**
     * 取錢
     */
    public void drawMoney(){
        this.money--;
    }
    public static void main(String[] args) throws InterruptedException {
        Money money = new Money();
        for(int i=0; i<1000; i++){
            new Thread(() -> {
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                money.drawMoney();
            },i + "").start();
        }
        Thread.sleep(2000);
        System.out.println("當前賬戶餘額:" + money.money);
    }
}


這樣想著是不是馬上可以去銀行搞一波事情? 哈哈,你想太多了,如果能這樣搞,銀行早破產了。我們主要是來分析一下出現這個問題的原因,JVM記憶體是JMM結構的,每個執行緒操作的資料是從主記憶體中複製的一個和備份,而多個執行緒就會存在多個備份,當執行緒中的備份資料被修改時,會將值重新整理到主記憶體,比如多個執行緒同時獲取到了賬戶的餘額為500元,A執行緒存錢100,執行緒A將600重新整理到主記憶體,$\color{red}{主記憶體並不會主動通知其他執行緒此時值已經被修改}$,所以主記憶體的值此時與其他執行緒的值是不同的,如果其他執行緒再操作賬戶餘額,是在500的基礎上進行的,這顯然不是我們想要的結果。

解決併發問題

JDK提供了多種解決多執行緒安全的方式。

volatile關鍵字

volatile是JDK提供的關鍵字,用來修飾變數,volatile修飾的變數能夠保證多個執行緒下的可見性,如上個案例,A修改了賬戶的餘額,然後將最新的值重新整理到主記憶體,此時主記憶體會將最新的值同步到其他執行緒。

volatile解決了多執行緒下資料讀取一致的問題,$\color{red}{即保證可見性,但是其並不能保證寫操作的原子性}$,

當多個執行緒同時寫操作的時候,即多個執行緒同時去將執行緒中最新的值重新整理到主記憶體,將會出現問題。

通過volatile關鍵字修飾money變數,發下並不能解決執行緒安全問題。

原子操作類

原子操作類是JDK提供的一系列保證原子操作的工具類,原子類可以保證多執行緒環境下對其值的操作是安全的。

package demo.pattren.aqs;

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicMoney {
    /**
     * 假設現在賬戶有1000塊錢
     */
    private AtomicInteger money = new AtomicInteger(1000);
    /**
     * 取錢
     */
    public void drawMoney(){
        //AtomicInteger的自減操作
        this.money.getAndDecrement();
    }
    public static void main(String[] args) throws InterruptedException {
        AtomicMoney money = new AtomicMoney();
        for(int i=0; i<1000; i++){
            new Thread(() -> {
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                money.drawMoney();
            },i + "").start();
        }
        Thread.sleep(2000);
        System.out.println("當前賬戶餘額:" + money.money);
    }
}


多次測試結果都是0,與預期一致。原子操作類是使用CAS(Compare and swap 比較並替換)的機制來保證操作的原子性,相對於鎖,他的併發性更高。

synchronized關鍵字

synchronized關鍵字是jvm層面來保證執行緒安全的,通過在程式碼塊前後新增monitorenter與monitorexit命令來保證執行緒的安全,而且在JDK1.6對synchronized關鍵字做了較大的優化,效能有了較大的提升。可以確定的是,通過synchronized肯定可以保證執行緒安全,所以使用synchronized也是很好的選擇,當然synchronized鎖的升級不可逆特徵,導致在高併發下效能是不能很好的保證。

Lock鎖

終於迎來了本篇文章的主角,前面的內容,其實與文章的主題AQS並沒有直接的關聯,就簡單帶過。前面很多都是JVM層面來保證執行緒安全的,而AQS則是完全通過程式碼層面來處理執行緒安全的。
(PS:小節標題明明是Lock鎖,怎麼寫AQS了,騙我讀書少)

博主怕捱打,正在全力解釋中~。先上類圖壓場!

如上圖,左邊是抽象佇列同步器,而右邊則是使用佇列同步器實現的功能——鎖、訊號量、發令槍等。
可以先不看原始碼,我們們自己思考,要以純程式碼的方式實現應當考慮哪些問題?

  1. 執行緒互斥:可以使用state狀態進行判斷,state=0,則可以獲取到鎖,state>0,則不能獲取。
  2. 排隊等候:不能獲取鎖的執行緒應當儲存起來,當鎖釋放後可以繼續獲取鎖執行。
  3. 執行緒喚醒:當鎖釋放後,處於等待狀態的執行緒應當被喚醒。
  4. 鎖重入 : 如何解決同一個進入多個加鎖的方法(不解決的話分分鐘死鎖給你看)。

對於1、2兩點,難度應帶不大,而3、4兩點如何去設計呢?我們通過虛擬碼預演操作流程。

在業務端,是這樣操作的。

  加鎖
  {需要被鎖住的程式碼}
  釋放鎖

加鎖與釋放鎖的邏輯

    if(state == 0)
      獲取到鎖
      set(state == 1)
    else
      繼續等待
      while(true){
           if(state == 0)
             再次嘗試獲取鎖
      }

這樣設計之後,整個操作流程再次變成了序列操作。

這和我們去食堂排隊打飯是一樣的,食堂不可能為每個學生都開放一個視窗,所以多個學生就會爭搶有限的視窗,如果沒有一定的控制,那麼食堂每到吃飯的時候都是亂套的,一群學生圍著視窗同時去打飯,想想都是多麼的恐怖。而由此出現了排隊的機制,一個視窗同一時間打飯的人只能有一個,當前一個人離開視窗後,後面排隊的學生才能去打飯。

原始碼解讀

下面我們深入JDK原始碼,領略大師級的程式碼設計。
業務呼叫程式碼:

package demo.aqs;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

public class LockMoney {
    Lock lock = new ReentrantLock();
    /**
     * 假設現在賬戶有1000塊錢
     */
    private int money = 1000;
    //private int money = 1000;
    /**
     * 取錢
     */
    public void drawMoney(){
        lock.lock();
        this.money--;
        lock.unlock();
    }
    public static void main(String[] args) throws InterruptedException {
        LockMoney money = new LockMoney();
        for(int i=0; i<1000; i++){
            new Thread(() -> {
                try {
                    Thread.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
                money.drawMoney();
            },i + "").start();
        }
        Thread.sleep(2000);
        System.out.println("當前賬戶餘額:" + money.money);
    }
}

追蹤Lock方法:
直接看原始碼基本一會兒就暈車,我嘗試繪製出lock方法的呼叫鏈路。然後結合原始碼解釋。


大家跟著箭頭走一遍原始碼,多多少少能夠體會到AQS的實現機制。

NonfairSync.lock

final void lock() {
    //CAS嘗試將state從0更新為1,更新成功則執行if下面的程式碼。
    if (compareAndSetState(0, 1))
        //獲取鎖成功,執行執行緒執行
        setExclusiveOwnerThread(Thread.currentThread());
    else
        //獲取鎖失敗,執行緒入佇列
        acquire(1);
}

看到這段程式碼,是不是瞬間明白前面提到的1、2兩點問題。首先compareAndSetState方法是使用Unsafe直接操作記憶體並且使用樂觀鎖的方式,能夠保證有且僅有一個執行緒能夠操作成功,是多執行緒安全的。即設定將state設定為1成功的執行緒能夠搶佔到鎖(執行緒互斥),而沒有設定成功的執行緒將進行入隊操作(排隊等候),這樣感覺瞬間明朗了許多,那我們接著往下看。

AbstractQueuedSynchronizor.acquire

 public final void acquire(int arg) {
    //tryAcquire失敗並且acquireQueued成功,則呼叫selfInterrupt
    if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
        //當執行緒獲取鎖失敗並且執行緒阻塞失敗會中斷執行緒
        selfInterrupt();
}

AbstractQueuedSynchronizor的tryAcquire方法,其最終呼叫到了Sync的nonfairTryAcquire

 final boolean nonfairTryAcquire(int acquires) {
    final Thread current = Thread.currentThread();
    //獲取當前鎖的狀態值
    int c = getState();
    // state = 0,表示當前鎖為空閒狀態,其實這一段程式碼和前面lock的方法是一樣的
    if (c == 0) {
        if (compareAndSetState(0, acquires)) {
            setExclusiveOwnerThread(current);
            return true;
        }
    }
    //不等於0 則判斷當前執行緒是否為持有鎖的執行緒,如果是則執行程式碼,這裡解決了重入鎖問題
    else if (current == getExclusiveOwnerThread()) {
        //當前狀態值 + 1(可以看前面的傳參)
        int nextc = c + acquires;
        // 囧, 這裡是超出了int的最大值才會出現這樣的情況
        if (nextc < 0) // overflow
            throw new Error("Maximum lock count exceeded");
        //更新state的值
        setState(nextc);
        return true;
    }
    return false;
}

通過閱讀原始碼,可以發現,tryAcquire方法在當前執行緒獲取鎖成功或者是重入鎖的情況下返回true,否則返回false。而同時這個方法解決了上面提到的第4點鎖重入的問題。ok,感覺越來越接近真相了,接著看addWaiter方法。
理解addWaiter方法的程式碼,先看方法中用的得Node物件。 Node物件是對Thread物件的封裝,使其具有執行緒的功能,同時他還有prev、next等屬性。那麼很明瞭,Node是一個連結串列結構的物件

   //前一個結點
   volatile Node prev;
   //下一個結點
   volatile Node next;

同時AbstractQueuedSynchronizor中包含head、tail屬性

 //Node連結串列的頭結點
 private transient volatile Node head;
 //Node連結串列的尾結點
 private transient volatile Node tail;
private Node addWaiter(Node mode) {
    //將當前執行緒包裝為Node物件
    Node node = new Node(Thread.currentThread(), mode);
    //獲取尾節點,當這段程式碼第一次執行的時候,並沒有尾結點
    //所以肯定值為null,那麼會執行下面的enq方法
    Node pred = tail;
    //當再次執行程式碼的時候,尾結點不再為null(enq方法初始化了尾結點,可以先往下看enq方法原始碼)
    if (pred != null) {
        //當前結點的前置結點指向之前的尾結點
        node.prev = pred;
        //CAS嘗試將尾結點從pred設定為node
        if (compareAndSetTail(pred, node)) {
            //設定成功則將pred的next結點執行node
            pred.next = node;
            return node;
        }
    }
    enq(node);
    return node;
}

上面的解釋聽著有點繞腦袋。

不著急,我們先看enq方法

private Node enq(final Node node) {
    //死迴圈
    for (;;) {
        //獲取尾結點
        Node t = tail;
        //尾結點為空,則初始化尾結點和頭結點為同一個新建立的Node物件
        if (t == null) { // Must initialize
            if (compareAndSetHead(new Node()))
                tail = head;
        } else {
            //將當前結點設為為尾結點,並將前一個尾結點的next指向當前結點
            node.prev = t;
            if (compareAndSetTail(t, node)) {
                t.next = node;
                //退出迴圈
                return t;
            }
        }
    }
}

enq具體做了什麼事情呢:

  1. 第一次迴圈,初始化頭結點與尾結點 new Node()
  2. 第二次迴圈,將當前執行緒封裝的Node物件設定為尾結點,並將前一個尾結點的next指向此Node

這裡需要一些時間 + 空間的想象力,但如果對連結串列結構比較熟悉的話,這裡理解也是不太困難的。
我們動態的想一想執行過程:

  1. 第一個執行緒進入lock方法,此時是肯定可以獲取到鎖,直接執行,不會進入到addWaiter方法
  2. 第二個執行緒進入lock方法,我們假設第一個執行緒還沒有釋放鎖,此時進入執行enq方法,enq進行連結串列的初始化。

  1. 第三個執行緒以及更多的執行緒進入lock方法,此時不再執行enq方法,而是在初始化之後的連結串列進行連結。

acquireQueued

final boolean acquireQueued(final Node node, int arg) {
  //區域性變數
  boolean failed = true;
  try {
      //區域性變數
      boolean interrupted = false;
      //死迴圈
      for (;;) {
          //獲取前置結點
          final Node p = node.predecessor();
          //前置結點為head並且嘗試獲取鎖成功,則不阻塞
          if (p == head && tryAcquire(arg)) {
              setHead(node);
              p.next = null; // help GC
              failed = false;
              return interrupted;
          }
          //阻塞操作 , 判斷是否應該阻塞 並且 阻塞是否成功
          if (
                //是否在搶佔鎖失敗後阻塞
              shouldParkAfterFailedAcquire(p, node) &&
              //Unsafe操作使執行緒阻塞
              parkAndCheckInterrupt())
              interrupted = true;
      }
  } finally {
      if (failed)
          cancelAcquire(node);
  }
}

shouldParkAfterFailedAcquire分析

//Node pred 前置結點, Node node 當前結點
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
    //獲取前置結點的等待狀態
    int ws = pred.waitStatus;
    if (ws == Node.SIGNAL)
        /*
         * 喚醒訊號,即前結點正常,就設定waitStatus為SIGNAL,表示前置結點可以喚醒當前結點,那          * 麼當前結點才會安心的被阻塞(如果前置結點不正常,可能就會導致自己不能被喚醒,那肯定不          * 能安心睡覺的)
         */
        return true;
    if (ws > 0) {
        /*
         * 找到前置結點中waitStatus <= 0 的Node結點並設定為當前結點的前置結點
         * 此狀態表示結點不是處於正常狀態,那麼將他從連結串列中刪除,直到找到狀態正常的結點
         */
        do {
            node.prev = pred = pred.prev;
        } while (pred.waitStatus > 0);
        pred.next = node;
    } else {
        /*
         * 當waitStatus = 0 或者 PROPAGATE(-3) 時,CAS設定值為SIGNAL(-1)
         * 此狀態表示執行緒正常,但沒有設定喚醒,一般為tail的前一個結點,那麼需要將其設定為可喚醒          * 狀態(SIGNAL)
         */
        compareAndSetWaitStatus(pred, ws, Node.SIGNAL);
    }
    return false;
}

圖解如下。

至此,我們瞭解了AQS對需要等待的執行緒儲存的過程。
而AQS的解鎖以及公平鎖、非公平鎖,共享鎖、獨享鎖等後續跟上。

參考資料:

https://www.cnblogs.com/water...
https://www.jianshu.com/p/d61...

相關文章