Java 併發高頻面試題:聊聊你對 AQS 的理解?

深刻淺出AbstractQueuedSynchronizer

有情懷,有乾貨,微信搜索【三太子敖丙】關注這個有一點點東西的程序員。java

本文 GitHub github.com/JavaFamily 已收錄,有一線大廠面試完整考點、資料以及個人系列文章。node

在Java多線程編程中,重入鎖(ReentrantLock) 和信號量(Semaphore)是兩個極其重要的併發控制工具。相信大部分讀者都應該比較熟悉它們的使用(若是不清楚的小夥伴,趕快拿出書本翻閱一下)。git

可是不知道你們是否是有了解太重入鎖和信號量的實現細節? 我就帶你們看一看它們的具體實現。程序員

首先,先上一張重要的類圖,來講明一下三者之間的關係:github

能夠看到, 重入鎖和信號量都在本身內部,實現了一個AbstractQueuedSynchronizer的子類,子類的名字都是Sync。而這個Sync類,也正是重入鎖和信號量的核心實現。子類Sync中的代碼也比較少,其核心算法都由AbstractQueuedSynchronizer提供。所以,能夠說,只要你們瞭解了AbstractQueuedSynchronizer,就清楚得知道重入鎖和信號量的實現原理了。面試

瞭解AbstractQueuedSynchronizer你必須知道的

在正是進入AbstractQueuedSynchronizer以前,還有一些基礎知識須要你們瞭解,這樣才能更好的理解AbstractQueuedSynchronizer的實現。算法

基於許可的多線程控制

爲了控制多個線程訪問共享資源 ,咱們須要爲每一個訪問共享區間的線程派發一個許可。拿到一個許可的線程才能進入共享區間活動。當線程完成工做後,離開共享區間時,必需要歸還許可,以確保後續的線程能夠正常取得許可。若是許可用完了,那麼線程進入共享區間時,就必須等待,這就是控制多線程並行的基本思想。編程

打個比方,一大羣孩子去遊樂場玩摩天輪,摩天輪上只能坐20個孩子。可是卻來了100個小孩。那麼許能夠的個數就是20。也就說一次只有20個小孩能夠上摩天輪玩,其餘的孩子必須排隊等待。只有等摩天輪上的孩子離開控制一個位置時,纔能有其餘小孩上去玩。微信

所以,使用許可控制線程行爲和排隊玩摩天輪差很少就是一個意思了。markdown

排他鎖和共享鎖

第二個重要的概念就是排他鎖(exclusive)和共享鎖(shared)。顧名思義,在排他模式上,只有一個線程能夠訪問共享變量,而共享模式則容許多個線程同時訪問。簡單地說,重入鎖是排他的;信號量是共享的。

用摩天輪的話來講,排他鎖就是雖然我這裏有20個位置,可是小朋友也只能一個一個上哦,多出來的位置怎麼辦呢,能夠空着,也可讓摩天輪上惟一的小孩換着作,他想坐哪兒就坐哪兒,1分鐘換個位置,都沒有關係。而共享鎖,就是玩耍摩天輪正常的打開方式了。

LockSupport

LockSupport能夠理解爲一個工具類。它的做用很簡單,就是掛起和繼續執行線程。它的經常使用的API以下:

  • public static void park() : 若是沒有可用許可,則掛起當前線程
  • public static void unpark(Thread thread):給thread一個可用的許可,讓它得以繼續執行

由於單詞park的意思就是停車,所以這裏park()函數就表示讓線程暫停。反之,unpark()則表示讓線程繼續執行。

須要注意的是,LockSupport自己也是基於許可的實現,如何理解這句話呢,請看下面的代碼:

LockSupport.unpark(Thread.currentThread());
LockSupport.park();
複製代碼

你們能夠猜一下,park()以後,當前線程是中止,仍是 能夠繼續執行呢?

答案是:能夠繼續執行。那是由於在park()以前,先執行了unpark(),進而釋放了一個許可,也就是說當前線程有一個可用的許可。而park()在有可用許可的狀況下,是不會阻塞線程的。

綜上所述,park()和unpark()的執行效果和它調用的前後順序沒有關係。這一點至關重要,由於在一個多線程的環境中,咱們每每很難保證函數調用的前後順序(都在不一樣的線程中併發執行),所以,這種基於許可的作法可以最大限度保證程序不出錯。

與park()和unpark()相比, 一個典型的反面教材就是Thread.resume()和Thread.suspend()。

看下面的代碼:

Thread.currentThread().resume();
Thread.currentThread().suspend();
複製代碼

首先讓線程繼續執行,接着在掛起線程。這個寫法和上面的park()的示例很是接近,可是運行結果倒是大相徑庭的。在這裏,當前線程就是卡死。

所以,使用park()和unpark()纔是咱們的首選。而在AbstractQueuedSynchronizer中,也正是使用了LockSupport的park()和unpark()操做來控制線程的運行狀態的。

AbstractQueuedSynchronizer內部數據結構

好了,基礎的部分就介紹到這裏。下面,讓咱們切入正題:首先來看一下AbstractQueuedSynchronizer的內部數據結構。

在AbstractQueuedSynchronizer內部,有一個隊列,咱們把它叫作同步等待隊列。它的做用是保存等待在這個鎖上的線程(因爲lock()操做引發的等待)。此外,爲了維護等待在條件變量上的等待線程,AbstractQueuedSynchronizer又須要再維護一個條件變量等待隊列,也就是那些由Condition.await()引發阻塞的線程。

因爲一個重入鎖能夠生成多個條件變量對象,所以,一個重入鎖就可能有多個條件變量等待隊列。實際上,每一個條件變量對象內部都維護了一個等待列表。其邏輯結構以下所示:

下面的類圖展現了代碼層面的具體實現:

能夠看到,不管是同步等待隊列,仍是條件變量等待隊列,都使用同一個Node類做爲鏈表的節點。對於同步等待隊列,Node中包括鏈表的上一個元素prev,下一個元素next和線程對象thread。對於條件變量等待隊列,還使用nextWaiter表示下一個等待在條件變量隊列中的節點。

Node節點另一個重要的成員是waitStatus,它表示節點等待在隊列中的狀態:

  • CANCELLED:表示線程取消了等待。若是取得鎖的過程當中發生了一些異常,則可能出現取消的狀況,好比等待過程當中出現了中斷異常或者出現了timeout。
  • SIGNAL:表示後續節點須要被喚醒。
  • CONDITION:線程等待在條件變量隊列中。
  • PROPAGATE:在共享模式下,無條件傳播releaseShared狀態。早期的JDK並無這個狀態,咋看之下,這個狀態是多餘的。引入這個狀態是爲了解決共享鎖併發釋放引發線程掛起的bug 6801020。(隨着JDK的不斷完善,它的代碼也愈來愈難懂了 :(,就和咱們本身的工程代碼同樣,bug修多了,細節就顯得愈來愈晦澀)
  • 0: 初始狀態

其中CANCELLED=1,SIGNAL=-1,CONDITION=-2,PROPAGATE=-3 。在具體的實現中,就能夠簡單的經過waitStatus釋放小於等於0,來判斷是不是CANCELLED狀態。

排他鎖

瞭解了AbstractQueuedSynchronizer的基本實現思路和數據結構,接下來一塊兒看一下它的實現細節吧。首先,來看一下排他鎖的實現。重入鎖是一種 典型的排他鎖。

請求鎖

下面是排他鎖得到請求許可的代碼:

public final void acquire(int arg) {
    //嘗試得到許可, arg爲許可的個數。對於重入鎖來講,每次請求1個。
    if (!tryAcquire(arg) &&
    // 若是tryAcquire 失敗,則先使用addWaiter()將當前線程加入同步等待隊列
    // 而後繼續嘗試得到鎖
    acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
    selfInterrupt();
}
複製代碼

進入一步看一下tryAcquire()函數。該函數的做用是嘗試得到一個許可。對於AbstractQueuedSynchronizer來講,這是一個未實現的抽象函數。

具體實如今子類中。在重入鎖,讀寫鎖,信號量等實現中, 都有各自的實現。

若是tryAcquire()成功,則acquire()直接返回成功。若是失敗,就用addWaiter()將當前線程加入同步等待隊列。

接着, 對已經在隊列中的線程請求鎖,使用acquireQueued()函數,從函數名字上能夠看到,其參數node,必須是一個已經在隊列中等待的節點。它的功能就是爲已經在隊列中的Node請求一個許可。

這個函數你們要好好看看,由於不管是普通的lock()方法,仍是條件變量的await()都會使用這個方法。

條件變量等待

若是調用Condition.await(),那麼線程也會進入等待,下面來看實現:

Condition對象的signal()通知

signal()通知的時候,是在條件等待隊列中,按照FIFO進行,首先從第一個節點下手:

release()釋放鎖

釋放排他鎖很簡單

public final boolean release(int arg) {
    //tryRelease()是一個抽象方法,在子類中有具體實現和tryAcquire()同樣
    if (tryRelease(arg)) {
        Node h = head;
        if (h != null && h.waitStatus != 0)
            // 從隊列中喚醒一個等待中的線程(遇到CANCEL的直接跳過)
            unparkSuccessor(h);
            return true;
    }
    return false;
}
複製代碼

共享鎖

與排他鎖相比,共享鎖的實現略微複雜一點。這也很好理解。由於排他鎖的場景很簡單,單進單出,而共享鎖就不同了。多是N進M出,處理起來要麻煩一些。可是,他們的核心思想仍是一致的。共享鎖的幾個典型應用有:信號量,讀寫鎖中的寫鎖。

得到共享鎖

爲了實現共享鎖,在AbstractQueuedSynchronizer中,專門有一套針對共享鎖的方法。

得到共享鎖使用acquireShared()方法:

釋放共享鎖

釋放共享鎖的代碼以下:

public final boolean releaseShared(int arg) {
    //tryReleaseShared()嘗試釋放許可,這是一個抽象方法,須要在子類中實現
    if (tryReleaseShared(arg)) {
        //上述代碼中已經出現這個函數了,就是喚醒線程,設置傳播狀態
        doReleaseShared();
        return true;
    }
    return false;
}
複製代碼

寫在最後的話

AbstractQueuedSynchronizer 是一個比較複雜的實現,要徹底理解其中的細節還須要慢慢琢磨。

這篇文章也只能起到一個拋磚引玉的做用,將AbstractQueuedSynchronizer的設計思想,核心數據結構已經核心實現代碼展現給你們。但願對你們理解AbstractQueuedSynchronizer的實現,以及理解重入鎖,信號量,讀寫鎖有必定幫助。

多線程系列還在在路上會繼續安排,小傻瓜若是對這個類有深一步的理解,能夠在評論區來一波:變得更強


我是敖丙,你知道的越多,你不知道的越多,感謝各位人才的:點贊收藏評論,咱們下期見!


文章持續更新,能夠微信搜一搜「 三太子敖丙 」第一時間閱讀,回覆【資料】有我準備的一線大廠面試資料和簡歷模板,本文 GitHub github.com/JavaFamily 已經收錄,有大廠面試完整考點,歡迎Star。

相關文章
相關標籤/搜索