AQS的核心原理分析

之前已經寫過一篇關於AQS的介紹了,基本概念我就不多逼逼了,我直接把我那篇文章複製過來。後面來根據java的源碼分析這個AQS的內部實現。

前言:這一部分的基礎概念講解,全部都是我之前的一篇博客:談談你對AQS的瞭解

1.寫在前面:

這篇文章,我們來聊聊面試時一個比較有殺傷力的問題:聊聊你對AQS的理解?

之前有同學反饋,去互聯網公司面試,面試官聊到併發時就問到了這個問題。當時那位同學內心估計受到了一萬點傷害。。。因爲首先,很多人可能連AQS是什麼都不知道。或者僅僅是聽說過AQS這個名詞,但是可能連全稱怎麼拼寫都不知道。更有甚者,可能會說:AQS?是不是一種思想?我們平時開發怎麼來用AQS?總結起來,很多同學都對AQS有一種雲裏霧裏的感覺,如果用搜索引擎查一下AQS是什麼,估計看幾篇文章就直接放棄了,因爲密密麻麻的文字,實在是看不懂!所以基於上述痛點,這篇文章就用最簡單的大白話配合N多張手繪圖,給大家講清楚AQS到底是什麼?讓各位同學面試被問到這個問題時,不至於不知所措。

二、ReentrantLock和AQS的關係

首先來看看,如果用java併發包下的ReentrantLock來加鎖和釋放鎖,是個什麼樣的感覺?

這個學過java的同學應該都會吧,畢竟是java併發基本API的使用,我們直接看一下代碼:

你這時可能會問,這個跟AQS有啥關係?關係大了去了!因爲java併發包下很多API都是基於AQS來實現的加鎖和釋放鎖等功能的,AQS是java併發包的基礎類。舉個栗子,比如說ReentrantLock、ReentrantReadWriteLock底層都是基於AQS來實現的。

那麼AQS的全稱是什麼呢:AbstractQueuedSynchronizer,抽象隊列同步器

給大家畫一個圖,看一下ReentrantLock和AQS之間的關係。

我們看上圖,說白了,ReentrantLock內部包含了一個AQS對象,也就是AbstractQueuedSynchronizer類型的對象。這個AQS對象就是ReentrantLock可以實現加鎖和釋放鎖的關鍵性的核心組件。

三、ReentrantLock加鎖和釋放鎖的底層原理

好了,現在如果有一個線程過來嘗試用ReentrantLock的lock()方法進行加鎖,會發生什麼事情?很簡單,這個AQS對象內部有一個核心的變量叫做state,是int類型的,代表了加鎖的狀態。初始狀態下,這個state的值是0。另外,這個AQS內部還有一個關鍵變量,用來記錄當前加鎖的是哪個線程,初始化狀態下,這個變量是null。

接着線程1跑過來調用ReentrantLock的lock()方法嘗試進行加鎖,這個加鎖的過程,直接就是用CAS操作將state值從0變爲1。

(關於CAS,之前專門有文章做過詳細闡述,大家可以自行閱讀了解)

如果之前沒人加過鎖,那麼state的值肯定是0,此時線程1就可以加鎖成功。一旦線程1加鎖成功了之後,就可以設置當前加鎖線程是自己。所以大家看下面的圖,就是線程1跑過來加鎖的一個過程。

其實看到這兒,大家應該對所謂的AQS有感覺了。說白了,就是併發包裏的一個核心組件,裏面有state變量、加鎖線程變量等核心的東西,維護了加鎖狀態。你會發現,ReentrantLock這種東西只是一個外層的API,內核中的鎖機制實現都是依賴AQS組件的

這個ReentrantLock之所以用Reentrant打頭,意思就是他是一個可重入鎖

可重入鎖的意思,就是你可以對一個ReentrantLock對象多次執行lock()加鎖和unlock()釋放鎖,也就是可以對一個鎖加多次,叫做可重入加鎖。

大家看明白了那個state變量之後,就知道了如何進行可重入加鎖!

其實每次線程1可重入加鎖一次,會判斷一下當前加鎖線程就是自己,那麼他自己就可以可重入多次加鎖,每次加鎖就是把state的值給累加1,別的沒啥變化。

接着,如果線程1加鎖了之後,線程2跑過來加鎖會怎麼樣呢?我們來看看鎖的互斥是如何實現的?

線程2跑過來一下看到,哎呀!state的值不是0啊?所以CAS操作將state從0變爲1的過程會失敗,因爲state的值當前爲1,說明已經有人加鎖了!

接着線程2會看一下,是不是自己之前加的鎖啊?當然不是了,“加鎖線程”這個變量明確記錄了是線程1佔用了這個鎖,所以線程2此時就是加鎖失敗。給大家來一張圖,一起來感受一下這個過程:

接着,線程2會將自己放入AQS中的一個等待隊列,因爲自己嘗試加鎖失敗了,此時就要將自己放入隊列中來等待,等待線程1釋放鎖之後,自己就可以重新嘗試加鎖了。所以大家可以看到,AQS是如此的核心!AQS內部還有一個等待隊列,專門放那些加鎖失敗的線程!

接着,線程1在執行完自己的業務邏輯代碼之後,就會釋放鎖!他釋放鎖的過程非常的簡單,就是將AQS內的state變量的值遞減1,如果state值爲0,則徹底釋放鎖,會將“加鎖線程”變量也設置爲null!整個過程,參見下圖:

接下來,會從等待隊列的隊頭喚醒線程2重新嘗試加鎖。

好!線程2現在就重新嘗試加鎖,這時還是用CAS操作將state從0變爲1,此時就會成功,成功之後代表加鎖成功,就會將state設置爲1。此外,還要把“加鎖線程”設置爲線程2自己,同時線程2自己就從等待隊列中出隊了。最後再來一張圖,大家來看看這個過程。

四、總結

OK,本文到這裏爲止,基本藉着ReentrantLock的加鎖和釋放鎖的過程,給大家講清楚了其底層依賴的AQS的核心原理。基本上大家把這篇文章看懂,以後再也不會擔心面試的時候被問到:談談你對AQS的理解這種問題了。其實一句話總結:AQS就是一個併發包的基礎組件,用來實現各種鎖,各種同步組件的。它包含了state變量、加鎖線程、等待隊列等併發中的核心組件。

這上面講的其實很不錯,仔細看完肯定能夠知道什麼是AQS,但是想要更加深入的瞭解原理,可以看下我下面的原理分析。

五、什麼是Lock

Lock 在 J.U.C 中是最核心的組件,前面我們講 synchronized 的時候說過,鎖最重 要的特性就是解決併發安全問題。爲什麼要以 Lock 作爲切入點呢?如果有同學看 過 J.U.C 包中的所有組件,一定會發現絕大部分的組件都有用到了 Lock。所以通 過 Lock 作爲切入點使得在後續的學習過程中會更加輕鬆。

Lock 本質上是一個接口,它定義了釋放鎖和獲得鎖的抽象方法,定義成接口就意 味着它定義了鎖的一個標準規範,也同時意味着鎖的不同實現。實現 Lock 接口的類有很多,以下爲幾個常見的鎖實現

ReentrantLock:表示重入鎖,它是唯一一個實現了 Lock 接口的類。重入鎖指的是 線程在獲得鎖之後,再次獲取該鎖不需要阻塞,而是直接關聯一次計數器增加重入 次數
ReentrantReadWriteLock:重入讀寫鎖,它實現了 ReadWriteLock 接口,在這個 類中維護了兩個鎖,一個是 ReadLock,一個是 WriteLock,他們都分別實現了 Lock 接口。讀寫鎖是一種適合讀多寫少的場景下解決線程安全問題的工具,基本原則 是: 讀和讀不互斥、讀和寫互斥、寫和寫互斥。也就是說涉及到影響數據變化的 操作都會存在互斥。

StampedLock: stampedLock 是 JDK8 引入的新的鎖機制,可以簡單認爲是讀寫 鎖的一個改進版本,讀寫鎖雖然通過分離讀和寫的功能使得讀和讀之間可以完全 併發,但是讀和寫是有衝突的,如果大量的讀線程存在,可能會引起寫線程的飢餓。 stampedLock 是一種樂觀的讀策略,使得樂觀鎖完全不會阻塞寫線程

Lock的繼承體系圖:

ReentrantLock 重入鎖:

重入鎖,表示支持重新進入的鎖,也就是說,如果當前線程 t1 通過調用 lock 方法獲取了鎖之後,再次調用 lock,是不會再阻塞去獲取鎖的,直接增加重試次數就行了。synchronized 和 ReentrantLock 都是可重入鎖。很多同學不理解爲什麼鎖會存在重入的特性,那是因爲對於同步鎖的理解程度還不夠,比如在下面這類的場景中,存在多個加鎖的方法的相互調用,其實就是一種重入特性的場景。

重入鎖的設計目的

比如調用 demo 方法獲得了當前的對象鎖,然後在這個方法中再去調用demo2,demo2 中的存在同一個實例鎖,這個時候當前線程會因爲無法獲得demo2 的對象鎖而阻塞,就會產生死鎖。重入鎖的設計目的是避免線程的死鎖

Lock的基本使用:

ReentrantLock 的實現原理

我們知道鎖的基本原理是,基於將多線程並行任務通過某一種機制實現線程的串 行執行,從而達到線程安全性的目的。在 synchronized 中,我們分析了偏向鎖、 輕量級鎖、樂觀鎖。基於樂觀鎖以及自旋鎖來優化了 synchronized 的加鎖開銷, 同時在重量級鎖階段,通過線程的阻塞以及喚醒來達到線程競爭和同步的目的。 那麼在 ReentrantLock 中,也一定會存在這樣的需要去解決的問題。就是在多線程 競爭重入鎖時,競爭失敗的線程是如何實現阻塞以及被喚醒的呢

AQS 是什麼

在 Lock 中,用到了一個同步隊列 AQS,全稱 AbstractQueuedSynchronizer,它 是一個同步工具也是 Lock 用來實現線程同步的核心組件。如果你搞懂了 AQS,那 麼 J.U.C 中絕大部分的工具都能輕鬆掌握

AQS 的兩種功能

從使用層面來說,AQS 的功能分爲兩種:獨佔和共享 獨佔鎖,每次只能有一個線程持有鎖,比如前面給大家演示的 ReentrantLock 就是 以獨佔方式實現的互斥鎖 共享鎖,允許多個線程同時獲取鎖,併發訪問共享資源,比如 ReentrantReadWriteLock

AQS 的內部實現

AQS 隊列內部維護的是一個 FIFO 的雙向鏈表,這種結構的特點是每個數據結構都有兩個指針,分別指向直接的後繼節點和直接前驅節點。所以雙向鏈表可以從任 意一個節點開始很方便的訪問前驅和後繼。每個 Node 其實是由線程封裝,當線 程爭搶鎖失敗後會封裝成 Node 加入到 ASQ 隊列中去;當獲取鎖的線程釋放鎖以 後,會從隊列中喚醒一個阻塞的節點(線程)。

Node節點:

有了前面這麼多前置知識,我們應該多AQS以一個全局的深刻認識,帶着這些理論基礎,不多逼逼,我們來看下他的源碼分析吧,這一次我會畫很多圖來分析(我一般是不用工具畫圖的,我都是在書本上畫,這樣比較快)

我們先來看一下非公平鎖的實現(如果不知道這個概念的,本文的最後會有介紹,不影響這個源碼的分析)

這個lock的第一行代碼就是一個CAS操作,不知道CAS的可以看下我這篇博客   什麼是CAS和ABA問題

這行代碼就是一個非公平鎖的一個體現,不管有沒有線程排隊,我先上來都是CAS去搶佔一波,成功了,就表示獲取了鎖,把當前的state設置爲1,並把exclusiveOwnerThread設置爲當前線程,失敗的話,才走acquire(1)去搶佔鎖

我們可以看下這個compareAndSetState(0,1),簡單的介紹下這個CAS,因爲後續的源碼分析會有大量的CAS操作

通過 cas 樂觀鎖的方式來做比較並替換,這段代碼的意思是,如果當前內存中的 state 的值和預期值 expect 相等,則替換爲 update。更新成功返回 true,否則返 回 false.
這個操作是原子的,不會出現線程安全問題,這裏面涉及到 Unsafe 這個類的操作, 以及涉及到 state 這個屬性的意義。

state 是 AQS 中的一個屬性,它在不同的實現中所表達的含義不一樣,對於重入 鎖的實現來說,表示一個同步狀態。它有兩個含義的表示
1. 當 state=0 時,表示無鎖狀態
2. 當 state>0 時,表示已經有線程獲得了鎖,也就是 state=1,但是因爲 ReentrantLock 允許重入,所以同一個線程多次獲得同步鎖的時候,state 會遞增, 比如重入 5 次,那麼 state=5。而在釋放鎖的時候,同樣需要釋放 5 次直到 state=0 其他線程纔有資格獲得鎖

這邊unsafe是個什麼鬼?

Unsafe 類是在 sun.misc 包下,不屬於 Java 標準。但是很多 Java 的基礎類庫,包 括一些被廣泛使用的高性能開發庫都是基於 Unsafe 類開發的,比如 Netty、 Hadoop、Kafka 等;
Unsafe 可認爲是 Java 中留下的後門,提供了一些低層次操作,如直接內存訪問、 線程的掛起和恢復、CAS、線程同步、內存屏障而 CAS 就是 Unsafe 類中提供的一個原子操作,第一個參數爲需要改變的對象, 第二個爲偏移量(即之前求出來的 headOffset 的值),第三個參數爲期待的值,第 四個爲更新後的值整個方法的作用是如果當前時刻的值等於預期值 var4 相等,則 更新爲新的期望值 var5,如果更新成功,則返回 true,否則返回 false;

stateOffset:一個 Java 對象可以看成是一段內存,每個字段都得按照一定的順序放在這段內存 裏,通過這個方法可以準確地告訴你某個字段相對於對象的起始內存地址的字節 偏移。用於在後面的 compareAndSwapInt 中,去根據偏移量找到對象在內存中的 具體位置所以 stateOffset 表示 state 這個字段在 AQS 類的內存中相對於該類首地址的偏移量

看完這個,應該大致明白CAS的操作的用途了吧。那我們看下面這個else的搶佔鎖的代碼:

首先會調用這個tryAcquire方法,我麼點進去,看到是一個

直接拋出了一個異常,是不是很喫驚?別迷了,這個方法被子類重寫了,我們應該看java.util.concurrent.locks.ReentrantLock.NonfairSync#tryAcquire下面的

我這裏其實是有個疑惑的?爲啥這個不在AQS裏面搞一個抽象方法,之類自己實現,非要在父類搞一個異常,不是很懂。

好吧,我們來看下子類的方法實現:

搶到了鎖就返回true,否則就是false

我們再看這一行代碼

看裏面那個addWaiter方法,這個就是構造我們上面理論知識的等待隊列的邏輯:

當 tryAcquire 方法獲取鎖失敗以後,則會先調用 addWaiter 將當前線程封裝成 Node.
入參 mode 表示當前節點的狀態,傳遞的參數是 Node.EXCLUSIVE,表示獨佔狀 態。意味着重入鎖用到了 AQS 的獨佔鎖功能

1. 將當前線程封裝成 Node
2. 當前鏈表中的 tail 節點是否爲空,如果不爲空,則通過 cas 操作把當前線程的

node 添加到 AQS 隊列
3. 如果爲空或者 cas 失敗,調用 enq 將節點添加到 AQS 隊列

private Node addWaiter(Node mode) {
        Node node = new Node(Thread.currentThread(), mode); //把
當前線程封裝爲 Node
        // Try the fast path of enq; backup to full enq on failure
        Node pred = tail; //tail 是 AQS 中表示同比隊列隊尾的屬性,默認爲null
        if (pred != null) { //tail 不爲空的情況下,說明隊列中存在節點
            node.prev = pred;//把當前線程的 Node 的 prev 指向 tail
            if (compareAndSetTail(pred, node)) {//通過 cas 把 node
加入到 AQS 隊列,也就是設置爲 tail
                pred.next = node;//設置成功以後,把原 tail 節點的 next指向當前 node
                return node;
            }
        }
        enq(node);//tail=null,把 node 添加到同步隊列
        return node;
    }

我們來畫圖分析這個代碼的實現過程:假如有三個線程來來爭搶鎖:

這個圖畫的有點簡陋,大致細節已經畫出來了,明天我再補一個圖,讓大家更明白,這點不搞清楚,後面中斷喚醒啥的不好理解哦。

addWaiter構建完成之後,我們來看一下acquireQueued這個方法,會把構建的Node作爲參數傳遞進來,一上來就會自旋,先拿到這個構建的節點的前一個節點,也就是我們上面畫的節點BNode,如果是head節點,並且調用搶佔鎖成功,把獲得鎖的節點設置爲head,並移除原來初始化的head節點,否則的話,根據waitStatus的值來判斷是否需要掛起線程,最後,通過cancelAcquire取消獲得鎖的操作.看下代碼:

我們來看下shouldParkAfterFailedAcquire

如果 ThreadA 的鎖還沒有釋放的情況下,ThreadB 和 ThreadC 來爭搶鎖肯定是會 失敗,那麼失敗以後會調用 shouldParkAfterFailedAcquire 方法
Node 有 5 中狀態,分別是:CANCELLED(1),SIGNAL(-1)、CONDITION(- 2)、PROPAGATE(-3)、默認狀態(0)

CANCELLED: 在同步隊列中等待的線程等待超時或被中斷,需要從同步隊列中取 消該 Node 的結點, 其結點的 waitStatus 爲 CANCELLED,即結束狀態,進入該狀 態後的結點將不會再變化
SIGNAL: 只要前置節點釋放鎖,就會通知標識爲 SIGNAL 狀態的後續節點的線程

CONDITION: 和 Condition 有關係,後續會講解 PROPAGATE:共享模式下,PROPAGATE 狀態的線程處於可運行狀態
0:初始狀態
這個方法的主要作用是,通過 Node 的狀態來判斷,ThreadA 競爭鎖失敗以後是 否應該被掛起。

1. 如果 ThreadA 的 pred 節點狀態爲 SIGNAL,那就表示可以放心掛起當前線程 2. 通過循環掃描鏈表把 CANCELLED 狀態的節點移除
3. 修改 pred 節點的狀態爲 SIGNAL,即把我們的BNode的waitStatus改爲-1,返回 false.
返回 false 時,也就是不需要掛起,返回 true,則需要調用 parkAndCheckInterrupt 掛起當前線程

那我們來看下parkAndCheckInterrupt

使用 LockSupport.park 掛起當前線程編程 WATING 狀態 Thread.interrupted,返回當前線程是否被其他線程觸發過中斷請求,也就是 thread.interrupt(); 如果有觸發過中斷請求,那麼這個方法會返回當前的中斷標識 true,並且對中斷標識進行復位標識已經響應過了中斷請求。如果返回 true,意味 着在 acquire 方法中會執行 selfInterrupt()。

這邊我想講一下線程的中斷,這個概念還是非常重要的,我這邊先參考一下別人的,以後我再補上我自己的理解吧:

https://www.cnblogs.com/linjiqin/archive/2011/04/11/2012695.html

selfInterrupt: 標識如果當前線程在 acquireQueued 中被中斷過,則需要產生一 箇中斷請求,原因是線程在調用 acquireQueued 方法的時候是不會響應中斷請求 的

LockSupport

LockSupport 類是 Java6 引入的一個類,提供了基本的線程同步原語。LockSupport 實際上是調用了 Unsafe 類裏的函數,歸結到 Unsafe 裏,只有兩個函數

unpark 函數爲線程提供“許可(permit)”,線程調用 park 函數則等待“許可”。這個有 點像信號量,但是這個“許可”是不能疊加的,“許可”是一次性的。
permit 相當於 0/1 的開關,默認是 0,調用一次 unpark 就加 1 變成了 1.調用一次

park 會消費 permit,又會變成 0。 如果再調用一次 park 會阻塞,因爲 permit 已 經是 0 了。直到 permit 變成 1.這時調用 unpark 會把 permit 設置爲 1.每個線程都 有一個相關的 permit,permit 最多隻有一個,重複調用 unpark 不會累積

看完了lock的實現原理,我們再來看一下unlock

如果這個時候 ThreadA 釋放鎖了,那麼我們來看鎖被釋放後會產生什麼效果

如果釋放鎖成功,得到AQS中的head節點,如果head不爲空,而且waitStatus不爲0,就喚醒後續線程

我們看一下那個tryRelease方法

這個方法可以認爲是一個設置鎖狀態的操作,通過將 state 狀態減掉傳入的參數值 (參數是 1),如果結果狀態爲 0,就將排它鎖的 Owner 設置爲 null,以使得其它的線程有機會進行執行。
在排它鎖中,加鎖的時候狀態會增加 1(當然可以自己修改這個值),在解鎖的時 候減掉 1,同一個鎖,在可以重入後,可能會被疊加爲 2、3、4 這些值,只有 unlock() 的次數與 lock()的次數對應纔會將 Owner 線程設置爲空,而且也只有這種情況下 纔會返回 true。

再看一下unparkSuccessor

獲得head節點的狀態,如果小於0,cas操作,把ws設爲0,後面這一步又是把隊列裏面那些狀態>0的都刪掉,這個是從尾部遍歷刪除的,在lock裏面那個是從頭部遍歷刪除的?想一想這裏爲啥從尾部遍歷刪除呢?找到ws狀態值小於等於0的節點,直接喚醒這個線程。

我來解釋下這個疑問吧:

一個新的節點是如何加入到鏈表中:

1. 將新的節點的 prev 指向 tail
2. 通過 cas 將 tail 設置爲新的節點,因爲 cas 是原子操作所以能夠保證線程安全性

3. t.next=node;設置原 tail 的 next 節點指向新的節點

在 cas 操作之後,t.next=node 操作之前。 存在其他線程調用 unlock 方法從 head 開始往後遍歷,由於 t.next=node 還沒執行意味着鏈表的關係還沒有建立完整。 就會導致遍歷到 t 節點的時候被中斷。所以從後往前遍歷,一定不會存在這個問 題。

圖解分析

通過鎖的釋放,原本的結構就發生了一些變化。head 節點的 waitStatus 變成了 0, ThreadB 被喚醒

原本掛起的線程繼續執行

通過 ReentrantLock.unlock,原本掛起的線程被喚醒以後繼續執行,應該從哪裏執 行大家還有印象吧。 原來被掛起的線程是在 acquireQueued 方法中,所以被喚 醒以後繼續從這個方法開始執行AQS.acquireQueued

這個方法前面已經完整分析過了,我們只關注一下 ThreadB 被喚醒以後的執行流 程。
由於 ThreadB 的 prev 節點指向的是 head,並且 ThreadA 已經釋放了鎖。所以這 個時候調用 tryAcquire 方法時,可以順利獲取到鎖

1. 把 ThreadB 節點當成 head
2. 把原 head 節點的 next 節點指向爲 null

 

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