精美圖文講解Java AQS 共享式獲取同步狀態以及Semaphore的應用

日拱一兵發表於2020-06-17

| 好看請贊,養成習慣

  • 你有一個思想,我有一個思想,我們交換後,一個人就有兩個思想

  • If you can NOT explain it simply, you do NOT understand it well enough

現陸續將Demo程式碼和技術文章整理在一起 Github實踐精選 ,方便大家閱讀檢視,本文同樣收錄在此,覺得不錯,還請Star?

看到本期內容這麼少,是不是心動了呢?

前言

上一篇萬字長文 Java AQS佇列同步器以及ReentrantLock的應用 為我們讀 JUC 原始碼以及其設計思想做了足夠多的鋪墊,接下來的內容我將重點說明差異化,如果有些童鞋不是能很好的理解文中的一些內容,強烈建議回看上一篇文章,搞懂基礎內容,接下來的閱讀真會輕鬆加愉快


AQS 中我們介紹了獨佔式獲取同步狀態的多種情形:

  • 獨佔式獲取鎖
  • 可響應中斷的獨佔式獲取鎖
  • 有超時限制的獨佔式獲取鎖

AQS 提供的模版方法裡面還差共享式獲取同步狀態沒有介紹,所以我們今天來揭開這個看似神祕的面紗

AQS 中的共享式獲取同步狀態

獨佔式是你中沒我,我中沒你的的一種互斥形式,共享式顯然就不是這樣了,所以他們的唯一區別就是:

同一時刻能否有多個執行緒同時獲取到同步狀態

簡單來說,就是這樣滴:

我們知道同步狀態 state 是維護在 AQS 中的,拋開可重入鎖的概念,我在上篇文章中也提到了,獨佔式和共享式控制同步狀態 state 的區別僅僅是這樣:

所以說想了解 AQS 的 xxxShared 的模版方法,只需要知道它是怎麼控制 state 的就好了

AQS共享式獲取同步狀態原始碼分析

為了幫助大家更好的回憶內容,我將上一篇文章的兩個關鍵內容貼上在此處,幫助大家快速回憶,關於共享式,大家只需要關注【騷紫色】就可以了

自定義同步器需要重寫的方法

AQS 提供的模版方法

故事就從這裡說起吧 (你會發現和獨佔式驚人的相似),關鍵程式碼都加了註釋

    public final void acquireShared(int arg) {
      	// 同樣呼叫自定義同步器需要重寫的方法,非阻塞式的嘗試獲取同步狀態,如果結果小於零,則獲取同步狀態失敗
        if (tryAcquireShared(arg) < 0)
          	// 呼叫 AQS 提供的模版方法,進入等待佇列
            doAcquireShared(arg);
    }

進入 doAcquireShared 方法:

    private void doAcquireShared(int arg) {
      	// 建立共享節點「SHARED」,加到等待佇列中
        final Node node = addWaiter(Node.SHARED);
        boolean failed = true;
        try {
            boolean interrupted = false;
          	// 進入“自旋”,這裡並不是純粹意義上的死迴圈,在獨佔式已經說明過
            for (;;) {
              	// 同樣嘗試獲取當前節點的前驅節點
                final Node p = node.predecessor();
              	// 如果前驅節點為頭節點,嘗試再次獲取同步狀態
                if (p == head) {
                  	// 在此以非阻塞式獲取同步狀態
                    int r = tryAcquireShared(arg);
                  	// 如果返回結果大於等於零,才能跳出外層迴圈返回
                    if (r >= 0) {
                      	// 這裡是和獨佔式的區別
                        setHeadAndPropagate(node, r);
                        p.next = null; // help GC
                        if (interrupted)
                            selfInterrupt();
                        failed = false;
                        return;
                    }
                }
                if (shouldParkAfterFailedAcquire(p, node) &&
                    parkAndCheckInterrupt())
                    interrupted = true;
            }
        } finally {
            if (failed)
                cancelAcquire(node);
        }
    }

上面程式碼第 18 行我們提到和獨佔式獲取同步狀態的區別,貼心的給大家一個更直觀的對比:

差別只在這裡,所以我們就來看看 setHeadAndPropagate(node, r) 到底幹了什麼,我之前說過 JDK 原始碼中的方法命名絕大多數還是非常直觀的,該方法直譯過來就是 【設定頭並且傳播/繁衍】。獨佔式只是設定了頭,共享式除了設定頭還多了一個傳播,你的疑問應該已經來了:

啥是傳播,為什麼會有傳播這個設定呢?

想了解這個問題,你需要先知道非阻塞共享式獲取同步狀態返回值的含義:

這裡說的傳播其實說的是 propagate > 0 的情況,道理也很簡單,當前執行緒獲取同步狀態成功了,還有剩餘的同步狀態可用於其他執行緒獲取,那就要通知在等待佇列的執行緒,讓他們嘗試獲取剩餘的同步狀態

如果要讓等待佇列中的執行緒獲取到通知,需要執行緒呼叫 release 方法實現的。接下來,我們走近 setHeadAndPropagate 一探究竟,驗證一下

  // 入參,node: 當前節點
	// 入參,propagate:獲取同步狀態的結果值,即上面方法中的變數 r
	private void setHeadAndPropagate(Node node, int propagate) {
    		// 記錄舊的頭部節點,用於下面的check
        Node h = head; 
    		// 將當前節點設定為頭節點
        setHead(node);
        
    		// 通過 propagate 的值和 waitStatus 的值來判斷是否可以呼叫 doReleaseShared 方法
        if (propagate > 0 || h == null || h.waitStatus < 0 ||
            (h = head) == null || h.waitStatus < 0) {
            Node s = node.next;
          	// 如果後繼節點為空或者後繼節點為共享型別,則進行喚醒後繼節點
    				// 這裡後繼節點為空意思是隻剩下當前頭節點了,另外這裡的 s == null 也是判斷空指標的標準寫法
            if (s == null || s.isShared())
                doReleaseShared();
        }
    }

上面方法的大方向作用我們瞭解了,但是程式碼中何時呼叫 doReleaseShared 的判斷邏輯還是挺讓人費解的,為什麼會有這麼一大堆的判斷,我們來逐個分析一下:

這裡的空判斷有點讓人頭大,我們先挑出來說明一下:

排除了其他判斷條件的干擾,接下來我們就專注分析 propagate 和 waitStatus 兩個判斷條件就可以了,這裡再將 waitStatus 的幾種狀態展示在這裡,幫助大家理解,【騷粉色】是我們一會要用到的:

propagate > 0

上面已經說過了,如果成立,直接短路後續判斷,然後根據 doReleaseShared 的判斷條件進行釋放

propagate > 0 不成立, h.waitStatus < 0 成立 (注意這裡的h是舊的頭節點)

什麼時候 h.waitStatus < 0 呢?拋開 CONDITION 的使用,只剩下 SIGNAL 和 PROPAGATE,想知道這個答案,需要提前看一下 doReleaseShared() 方法了:

    private void doReleaseShared() {
        for (;;) {
            Node h = head;
            if (h != null && h != tail) {
                int ws = h.waitStatus;
                if (ws == Node.SIGNAL) {
                  	// CAS 將頭節點的狀態設定為0                
                    if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                        continue;            // loop to recheck cases
                    // 設定成功後才能跳出迴圈喚醒頭節點的下一個節點
                  	unparkSuccessor(h);
                }
                else if (ws == 0 &&
                         // 將頭節點狀態CAS設定成 PROPAGATE 狀態
                         !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                    continue;                // loop on failed CAS
            }
            if (h == head)                   // loop if head changed
                break;
        }
    }

doReleaseShared() 方法中可以看出:

  • 如果讓 h.waitStatus < 0 成立,只能將其設定成 PROPAGATE = -3 的情況,設定成功的前提是 h 頭節點 expected 的狀態是 0;

  • 如果 h.waitStatus = 0,是上述程式碼第 8 行 CAS 設定成功,然後喚醒等待中的執行緒

所以猜測,當前執行緒執行到 h.waitStatus < 0 的判斷前,有另外一個執行緒剛好執行了 doReleaseShared() 方法,將 waitStatus 又設定成PROPAGATE = -3

這個理解有點繞,我們還是來畫個圖理解一下吧:

可能有同學還是不太能理解這麼寫的道理,我們一直說 propagate <> = 0 的情況,propagate = 0 代表的是當時/當時/當時 嘗試獲取同步狀態沒成功,但是之後可能又有共享狀態被釋放了,所以上面的邏輯是以防這種萬一,你懂的,嚴謹的併發就是要防止一切萬一,現在結合這個情景再來理解上面的判斷你是否豁然開朗了呢?

繼續向下看,

前序條件不成立,(h = head) == null || h.waitStatus < 0 注意這裡的h是新的頭節點)

有了上面鋪墊,這個就直接畫個圖就更好理解啦,其實就是沒有那麼巧有另外一個執行緒摻合了

相信到這裡你應該理解共享式獲取同步狀態的全部過程了吧,至於非阻塞共享式獲取同步狀態帶有超時時間獲取同步狀態,結合本文講的 setHeadAndPropagate 邏輯和獨佔式獲取同步狀態的實現過程過程來看,真是一毛一樣,這裡就不再累述了,趕緊開啟你的 IDE 去驗證一下吧

我們分析了AQS 的模版方法,還一直沒說 tryAcquireShared(arg) 這個方法是如何被重寫的,想要了解這個,我們就來看一看共享式獲取同步狀態的經典應用 Semaphore

Semaphore 的應用及原始碼分析

Semaphore 概念

Semaphore 中文多翻譯為 【訊號量】,我還特意查了一下劍橋辭典的英文解釋:

其實就是訊號標誌(two flags),比如紅綠燈,每個交通燈產生兩種不同行為

  • Flag1-紅燈:停車
  • Flag2-綠燈:行車

在 Semaphore 裡面,什麼時候是紅燈,什麼時候是綠燈,其實就是靠 tryAcquireShared(arg) 的結果來表示的

  • 獲取不到共享狀態,即為紅燈
  • 獲取到共享狀態,即為綠燈

所以我們走近 Semaphore ,來看看它到底是怎麼應用 AQS 的,又是怎樣重寫 tryAcquireShared(arg) 方法的

Semaphore 原始碼分析

先看一下類結構

看到這裡你是否有點跌眼鏡,和 ReentrantLock 相似的可怕吧,如果你有些陌生,再次強烈建議你回看上一篇文章 Java AQS佇列同步器以及ReentrantLock的應用 ,這裡直接提速對比看公平和非公平兩種重寫的 tryAcquireShared(arg) 方法,沒有意外,公平與否,就是判斷是否有前驅節點

方法內部只是計算 state 的剩餘值,那 state 的初始值是多少怎麼設定呢?當然也就是構造方法了:

		public Semaphore(int permits) {
      	// 預設仍是非公平的同步器,至於為什麼預設是非公平的,在上一篇文章中也特意說明過
        sync = new NonfairSync(permits);
    }
    
    NonfairSync(int permits) {
    		super(permits);
    }

super 方法,就會將初始值給到 AQS 中的 state

也許你發現了,當我們把 permits 設定為1 的時候,不就是 ReentrantLock 的互斥鎖了嘛,說的一點也沒錯,我們用 Semaphore 也能實現基本互斥鎖的效果


static int count;
//初始化訊號量
static final Semaphore s 
    = new Semaphore(1);
//用訊號量保證互斥    
static void addOne() {
  s.acquire();
  try {
    count+=1;
  } finally {
    s.release();
  }
}

But(英文聽力中的重點),Semaphore 肯定不是為這種特例存在的,它是共享式獲取同步狀態的一種實現。如果使用訊號量,我們通常會將 permits 設定成大於1的值,不知道你是否還記得我曾在 為什麼要使用執行緒池? 一文中說到的池化概念,在同一時刻,允許多個執行緒使用連線池,每個連線被釋放之前,不允許其他執行緒使用。所以說 Semaphore 可以允許多個執行緒訪問一個臨界區,最終很好的做到一個限流/限流/限流 的作用

雖然 Semaphore 能很好的提供限流作用,說實話,Semaphore 的限流作用比較單一,我在實際工作中使用 Semaphore 並不是很多,如果真的要用高效能限流器,Guava RateLimiter 是一個非常不錯的選擇,我們後面會做分析,有興趣的可以提前瞭解一下

關於 Semaphore 原始碼,就這麼三下五除二的結束了

總結

不知你有沒有感覺到,我們的節奏明顯加快了,好多原來分散的點在被瘋狂的串聯起來,如果按照這個方式來閱讀 JUC 原始碼,相信你也不會一頭扎進去迷失方向,然後沮喪的退出 JUC 吧,然後面試背誦答案,然後忘記,然後再背誦?

跟上節奏,關於共享式獲取同步狀態,Semaphore 只不過是非常經典的應用,ReadWriteLock 和 CountDownLatch 日常應用還是非常廣泛的,我們接下來就陸續聊聊它們吧

靈魂追問

  1. Semaphore 的 permits 設定成1 “等同於” 簡單的互斥鎖實現,那它和 ReentrantLock 的區別還是挺大的,都有哪些區別呢?
  2. 你在專案中是如何使用 Semaphore 的呢?

參考

  1. Java 併發實戰
  2. Java 併發程式設計的藝術
  3. https://blog.csdn.net/anlian523/article/details/106319294

相關文章