深入理解Java併發框架AQS系列(四):共享鎖(Shared Lock)

昔久 發表於 2021-04-08

深入理解Java併發框架AQS系列(一):執行緒
深入理解Java併發框架AQS系列(二):AQS框架簡介及鎖概念
深入理解Java併發框架AQS系列(三):獨佔鎖(Exclusive Lock)
深入理解Java併發框架AQS系列(四):共享鎖(Shared Lock)

一、前言

那些“簡單的”併發程式碼背後,隱藏著大量資訊。。。

獨佔鎖雖說在j.u.c中有現成的實現,但在JAVA的語言層面也同樣提供了支援(synchronized);但共享鎖卻是隻存在於AQS中,而它在實際生產中的使用頻次絲毫不亞於獨佔鎖,在整個AQS體系中佔有舉重若輕的地位。而在某種意義上,因為可能同時存在多個執行緒的併發,它的複雜度要高於獨佔鎖。本章除了介紹共享鎖資料結構等,還會重點對焦併發處理,看 doug lea 在併發部分是否有遺漏

j.u.c下支援的併發鎖有SemaphoreCountDownLatch等,本章我們採用經典併發類Semaphore來闡述

二、簡介

深入理解Java併發框架AQS系列(四):共享鎖(Shared Lock)

共享鎖其實是相對獨佔鎖而言的,涉及到共享鎖就要聊到併發度,即同一時刻最多允許同時執行執行緒的數量。上圖所述的併發度為3,即在同一時刻,最多可有3個人在同時過河。

但共享鎖的併發度也可以設定為1,此時它可以看作是一個特殊的獨佔鎖

2.1、waitStatus

在獨佔鎖章節中,我們介紹到了關鍵的狀態標記欄位waitStatus,它在獨佔鎖的取值有

  • 0
  • SIGNAL (-1)
  • CANCELLED (1)

而這些取值在共享鎖中也都存在,含義也保持一致,而除了上述這3個取值外,共享鎖還額外引入了新的取值:

  • PROPAGATE (-3)

-3這個取值在整個AQS體系中,只存在於共享鎖中,它的存在是為了更好的解決併發問題,我們將在後文中詳細介紹

2.2、使用場景

本人參加的某效能挑戰賽中,有這樣一個場景:資料產生於CPU,且有12個執行緒在不斷的製造資料,而這些資料需要持久化到磁碟中,由於資料產生的非常快,此時的瓶頸卡在IO上;磁碟的效能經過基準測試,發現每次寫入8K資料,且開4個執行緒寫入時,能將IO打滿;但如何控制在同一時刻,最多有4個執行緒進行IO寫入呢?

深入理解Java併發框架AQS系列(四):共享鎖(Shared Lock)

其實這是一個典型的使用共享鎖的場景,我們用三四行程式碼即可解決

// 設定共享鎖的併發度為4
Semaphore semaphore = new Semaphore(4);
// 加鎖
semaphore.acquire();
// 執行資料儲存
storeIO();
// 釋放鎖
semaphore.release();

三、併發

3.1、獨佔鎖 vs 共享鎖

共享鎖的整體流程與獨佔鎖相似,都是首先嚐試去獲取資源(子類邏輯,一般是CAS操作

  • 如果能拿到資源,那麼進入同步塊執行業務程式碼;當同步塊執行完畢後,喚醒阻塞佇列的頭結點
  • 如果資源已空,那麼進入阻塞佇列並掛起,等待被其他執行緒喚醒

兩者的不同點在什麼地方呢?就在於“喚醒阻塞佇列的頭結點”的操作。在獨佔鎖時,喚醒頭結點的操作,只會有一個執行緒(加鎖成功的執行緒呼叫release())去觸發;而在共享鎖時,可能會有多個執行緒同時去呼叫釋放

深入理解Java併發框架AQS系列(四):共享鎖(Shared Lock)

直觀感覺這樣設計不太合理:如果多個執行緒同時去喚醒頭結點,而頭結點只能被喚醒一次,假定阻塞佇列中有20個節點,那這些節點只能等待上一個節點執行完畢後才會被喚醒,無形中共享鎖的併發度變成了1。要解決這個疑問,我們先來看共享鎖的釋放邏輯

3.2、鎖釋放

先來思考一下鎖釋放需要做的事兒

  • 1、阻塞佇列的第一個節點一定要被啟用;這個問題看似不值一提,卻相當重要,區別於獨佔鎖,共享鎖的鎖釋放是存在併發的,在高併發的流量下,一定要保證阻塞佇列的第一個有效節點被啟用,否則會導致阻塞佇列永久性的掛死
  • 2、保證啟用阻塞佇列時的併發度;這個問題同樣也是獨佔鎖不存在的,也就是我們在3.1提出的問題;假定這樣一種場景:“共享鎖的併發度為10,阻塞佇列中有100個待處理的節點,而此時又沒有新的加鎖請求,如何保證在啟用阻塞佇列時,保持10的併發度?”

共享鎖如何解決這兩個問題呢?我們接下來逐一闡述

3.2.1、呼叫點

與獨佔鎖不同,共享鎖呼叫“鎖釋放”有2個地方(注:AQS的一個阻塞佇列是可以同時新增獨佔節點、共享節點的,為了簡化模型,我們這裡暫不討論這種混合模型

  • a、某執行緒同步塊執行完畢,正常呼叫解鎖邏輯;此點與獨佔鎖一致
  • b、在每次更換頭結點時,如果滿足以下任一條件,同樣會呼叫“鎖釋放”;更換頭結點的操作,其實此時已經意味著當前執行緒已經加鎖成功
    • b.1、有額外的資源可用;拿訊號量舉例,當發現訊號量數量>0時,表示有額外資源可用
    • b.2、舊的頭結點或當前頭結點的ws < 0

那這兩個點呼叫的時候,是否存在併發呢?有同學會說“a存在併發,b是序列的”;其實此處b也是存在併發的,例如執行緒1更換了head節點後,準備執行“鎖釋放”邏輯,正在此時,執行緒2正常鎖釋放後,喚醒了新的head節點(執行緒3),執行緒3又會執行更換head節點,並準備執行“鎖釋放”邏輯;此時執行緒1跟執行緒3都準備執行“鎖釋放”邏輯

共享鎖解鎖邏輯的呼叫點

既然“鎖釋放”存在這麼多併發,那就一定要保證“鎖釋放”邏輯是冪等的,那它又是如何做到呢?

3.2.1、鎖釋放

直接貼一下它的原始碼吧,釋放鎖的程式碼寥寥幾筆,卻很難說它簡單

private void doReleaseShared() {
    for (;;) {
        Node h = head;
        if (h != null && h != tail) {
            int ws = h.waitStatus;
            if (ws == Node.SIGNAL) {
                if (!compareAndSetWaitStatus(h, Node.SIGNAL, 0))
                    continue;            // loop to recheck cases
                unparkSuccessor(h);
            }
            else if (ws == 0 &&
                     !compareAndSetWaitStatus(h, 0, Node.PROPAGATE))
                continue;                // loop on failed CAS
        }
        if (h == head)                   // loop if head changed
            break;
    }
}

對應的流程圖如下:

共享鎖釋放鎖流程

我們簡單描述一下鎖釋放做的事兒

  • 1、首選獲取頭結點的快照,並將其賦予變數h,同時獲取h.waitStatus,並標記位ws
  • 2、判斷ws的狀態
    • ws == -1 表示下一個節點已經掛起,或即將掛起。如果只要發現是-1狀態,就進行執行緒喚起的話,因為存在併發,可能導致目標執行緒被喚起多次,故此處需要通過CAS進行搶鎖,保證只有一個執行緒去喚起
    • ws == 0 如果發現節點ws為0,此處會存在兩種情況(情況1:節點剛新建完畢,還未進入阻塞佇列;情況2:節點由-1修改為了0),不管哪種情況,都強制將其由-1改為-3,標記位強制傳播,此處是否存在漏洞?
    • ws == -3 表示當前節點已經被標識為強制傳播了,直接結束
  • 3、如果此時 h == head,說明在上述邏輯發生時,頭結點沒有發生變化,那麼結束當前操作,否則重複上述步驟。注:AQS中所有節點只有一次當頭結點的機會,也就是某個節點當過一次頭結點後,便會被拋棄,再無可能第二次成為頭結點,這點至關重要

根據以上分析,我們發現,節點的狀態流轉是通過ws來控制的,即0、-1、-3,乍看上去,貌似不太嚴謹,那我們來做具體分析

3.2.2、ws狀態流轉

僅有2個功能點會對ws進行修改,一是將節點加入阻塞佇列時,二就是3.2.1中描述的呼叫鎖釋放邏輯時;

我們將加入阻塞佇列時ws的狀態流轉再回憶下:

  • 狀態為0(初始狀態),加入阻塞佇列前,需要將前節點修改為-1,然後進入執行緒掛起
  • 狀態為-3(強制傳播狀態,被解鎖執行緒標記),加入阻塞佇列前,同樣需要將前節點修改為-1,然後進入執行緒掛起

綜述,我們出一張ws的整體狀態流轉圖

共享鎖加入阻塞佇列時ws流轉

由上圖可得知,只要解鎖邏輯成功通過CAS將head節點由-1修改為0的話,那麼就要負責喚醒阻塞佇列中的第一個節點了

整個流轉過程有bug嗎?我們設想如下場景:共享鎖的併發度設定為1,A、B兩個執行緒同時進入加鎖邏輯,B執行緒成功搶到鎖,並開始進入同步塊,A執行緒搶鎖失敗,準備掛到阻塞佇列,正常流程是A執行緒將ws由0修改為-1後,進入掛起狀態,但B執行緒執行較快,已經優先A執行緒並開始執行解鎖邏輯,將ws由0修改為了-3,然後B執行緒正常結束;A執行緒發現ws為-3後,將其修改為-1,然後進入掛起。 如果這個場景真實發生的話,A執行緒將永久處於掛起狀態,那豈不是存在漏洞?

然而事實並非如此,因為只要A執行緒將ws修改為-1後,都要再嘗試進行一次獲取鎖的操作,正是這個操作避免了上述情況的發生,可見aqs是很嚴謹的

共享鎖加入阻塞佇列及解鎖ws流轉示意圖

3.3、保證併發度

阻塞佇列中節點的啟用順序是什麼樣呢?其實啟用順序3.2章節已經描述的較為清楚,解鎖的邏輯只負責啟用頭節點,那如何保證共享鎖的併發度?

我們還是假定這樣一個場景:共享鎖的併發度為5,阻塞佇列中有20個節點,只有head節點已被喚醒,且沒有新的請求進入,我們希望在同一時刻,同時有5個節點處於啟用狀態。針對上述場景,aqs如何做到呢?

共享鎖阻塞佇列併發度

其實head節點被啟用時,在第一時間會通知後續節點,並將其喚醒,然後才會執行同步塊邏輯,保證了等待中的節點快速啟用