點讚的靚仔,你時人群中最閃耀的光芒
前言
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了,騙我讀書少)
博主怕捱打,正在全力解釋中~。先上類圖壓場!
如上圖,左邊是抽象佇列同步器,而右邊則是使用佇列同步器實現的功能——鎖、訊號量、發令槍等。
可以先不看原始碼,我們們自己思考,要以純程式碼的方式實現應當考慮哪些問題?
- 執行緒互斥:可以使用state狀態進行判斷,state=0,則可以獲取到鎖,state>0,則不能獲取。
- 排隊等候:不能獲取鎖的執行緒應當儲存起來,當鎖釋放後可以繼續獲取鎖執行。
- 執行緒喚醒:當鎖釋放後,處於等待狀態的執行緒應當被喚醒。
- 鎖重入 : 如何解決同一個進入多個加鎖的方法(不解決的話分分鐘死鎖給你看)。
對於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具體做了什麼事情呢:
- 第一次迴圈,初始化頭結點與尾結點 new Node()
- 第二次迴圈,將當前執行緒封裝的Node物件設定為尾結點,並將前一個尾結點的next指向此Node
這裡需要一些時間 + 空間的想象力,但如果對連結串列結構比較熟悉的話,這裡理解也是不太困難的。
我們動態的想一想執行過程:
- 第一個執行緒進入lock方法,此時是肯定可以獲取到鎖,直接執行,不會進入到addWaiter方法
- 第二個執行緒進入lock方法,我們假設第一個執行緒還沒有釋放鎖,此時進入執行enq方法,enq進行連結串列的初始化。
- 第三個執行緒以及更多的執行緒進入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...