先上結論:
一切互斥操作的依賴是 自旋鎖(spin_lock),互斥量(semaphore)等其他需要隊列的實現均需要自選鎖保證臨界區互斥訪問。
而自旋鎖需要xcmpchg等類似的可提供CAS操作的硬件指令提供原子性 和 可見性,(xcmpchg會鎖總線或緩存行,一切會鎖總線或緩存行的操作都會刷StoreBuffer,起到寫屏障的操作)
所以,任意的互斥操作,無論是 java 層面,hotspot層面,linux層面 的根本依賴都是 xcmpchg 等硬件指令。java算是上層,需要依賴hotspot和linux嵌入的匯編完成xcmpchg的調用。
所有同步手段的根本是硬件,軟件是輔助手段,軟件和硬件的交界面是用於並發控制的硬件指令(如 cmpchg, 帶lock前綴的指令,lwsync, sfence 等)
整個依賴鏈條:
1. Java 的並發工具包 JUC 中大部分同步工具類依賴 AQS 為他們提供隊列服務和資源控制服務。
2. AQS 依賴 LockSupport 的 park 和 unpark 為他提供線程休眠喚醒操作
3. LockSupport 的 park 和 unpark 是依賴 JVM(此處語境討論 Hotspot)調用操作系統的 pthread_mutex_lock 和 pthread_cond_wait , 前者是保護后者和 counter 變量的互斥鎖,保證只有一個線程操作 counter 變量和 condtion 上的等待隊列
4. pthread_mutex_wait 依賴於 操作系統的 futex 機制,多個用戶態的線程(Java線程,即Mutator)通過用戶空間相同,物理頁共享,共同爭搶受寫屏障增強,線程可見性強的資源變量。如果搶不到,需要用 futex_wait 系統調用,具體是委托內核查看該變量是否還是 futex_wait 的入參(爭搶失敗后的值),如果是,則讓內核將自己從 runqueue(Linux下的就緒進程隊列)摘下來,並且狀態設為 TASK_INTERRUABLE,表示不需要繼續執行,但是可以用信號喚醒,如果不是,返回用戶空間,再次爭搶
5. futex_wait 和 futex_wakeup 依賴 spin_lock保護桶bucket,其實保護bucket上的一整條鏈表
6. 操作系統的 down , up 依賴 spin_lock 保護等待隊列和資源變量
硬件層
預備知識:
寫屏障:
簡化微機架構(Intel X86):

無寫屏障:
1.假設有變量var

2. CPU A(進程/線程A) 修改 var = 1

3. CPU C(進程/線程C) 讀取到 var = 3, 無法立刻得到 A 的修改

有寫屏障(A,B,C任意CPU在修改完某個變量后均使用寫屏障):
上面的微機架構可以簡化成:

1.A修改var

2.C立刻可見

使用寫屏障類似:
var = 1;
write_fence_here(); // 寫屏障
作用只是將 storeBuffer中的內容馬上刷出到 自己的高速緩存中,因為高速緩存有MESI緩存一致性協議,所以其他CPU讀取該變量,將是一直的新值(即使穿透緩存直接讀取內存也是一樣一致)
讀屏障本文不討論,其本身作用是將 Invalidate Queue 中的無效化請求應用到自己獨享的緩存中,以便不去讀之前的舊數據。而是因為緩存行已經 Invalidated,而去 從其他CPU或內存讀取最新數據。
操作系統層
自旋鎖和隊列鎖(一般互斥量是隊列鎖):
1.自旋鎖簡化:
while (true) { if (compareAndSet(期望的舊值, 新值)) { return; } }
自旋鎖在Linux中寫作 spin_lock ,spin 本身有“連軸轉”的意思。自旋鎖的本質是獲取不到資源就一直空轉。
compareAndSet : 類似下面代碼,但是被包裝成 一條硬件指令,所以是原子的,在他執行的中間,不能有別的CPU插手這個內存的操作。
並且CAS要么全部完成,要么不執行,不能只執行一半,因為他是一條鎖了總線或緩存行的硬件指令。在SMP條件下,如果不鎖總線或緩存行,指令也不是原子的,比如ADD(read-write-read),只有微操作是原子的。
比如將某個值打入某個寄存器中(write)。
boolean compareAndSet (期望的舊值, 新值) { if (變量值 == 期望的舊值) { 變量 = 新值; return true; } return false; }
2. 隊列鎖簡化:
addToQueue: 將線程/進程的TCB/PCB(在linux是task_struct),放入等待隊列,當持有資源的線程釋放資源的時候會喚醒等待隊列中線程(PCB/TCP就是代表進程/線程的結構)。
並且將進程/線程的 狀態設為非運行狀態(linux中一般使用TASK_INTERRUPTABLE), 並從就緒隊列上摘下來(Linux上是runqueue)
schedule :當前線程已設置為非運行狀態,所以會選擇其他線程占用CPU, 當前線程在此點睡眠
while (true) { if (!compareAndSet(期望舊值,新值)) { // 嘗試獲取資源,如:compareAndSet(原資源數,原資源數 - 1) addToQueue(當前線程PCB/TCB); // 獲取不到就進入等待隊列 schedule();// 睡眠,讓出CPU } }
為什么說互斥量(隊列鎖)依賴自旋鎖?
假設有以下情況:(互斥量對應資源初始值=1)

如此一來,明明有資源,但是線程B卻無法被喚醒。
究其原因,是因為B的 檢測資源-掛入等待隊列-睡眠 這三個階段,不是原子的。線程A 可以修改資源,讓資源變成1。
線程A對資源的操作插入到了線程B的操作之中,使得B的操作集合中語句前后所處的狀態不一致,即非原子的,受干擾的(區別於事物原子性)。
可以使用自旋鎖保護 資源,在讀取資源時,其他線程不能修改資源,那么釋放操作就會被放到睡眠之后:

為何可以使用自旋鎖? 因為自旋鎖不涉及隊列,如果線程無法獲取自旋鎖,就在CPU 上空轉,直到獲取為止,不需要隊列去存儲他們,所以不會出現多個線程修改一個隊列的情況。
也不會睡眠,所以也不會出現因為睡眠而錯過資源的情況,像上二張圖就是錯過資源的情況,自選鎖一直都在爭搶。
但是自旋鎖的局限性也很大,空轉,無意義的CPU時間被浪費。所以只有競爭不是很激烈,以及占用鎖時間不長的情況,才使用自旋鎖。
這里的對隊列操作,只是簡單地讀取一下變量,和在鏈表上掛一個節點,很快。
在Linux(3.0.7)下的實現:
up 操作是釋放互斥量資源,down 操作是獲取互斥量資源

futex(fast user mutex):之所以稱為 user mutex,是因為多個用戶態線程通過一塊共享內存存儲代表資源的變量,多個用戶態線程對這個資源的操作是原子性的,這是在用戶態的操作。
當用戶線程發現自己爭搶不到資源,才委托系統調用幫自己檢查一下這個變量還是不是剛才讀到的變量,如果是就當前線程休眠,所以是在用戶態判斷是否可以獲取資源,不行再使用系統調用陷入內核態。比如說,我有一塊內存頁,被A,B兩個線程共享,這個內存頁里有個變量 var ,表示資源的個數,一開始是1。線程A和B都是通過CAS型的硬件指令去設置這個資源,即操作是原子性的。假如一開始A,CAS 搶奪成功,資源var 變成 0。資源B 直接通過自己的頁面映射表去到這個共享的物理頁,讀取一下,發現是0,那么當前表示無資源可用。B將會使用系統調用,委托操作系統檢查,這個資源是不是還是0,如果是就將自己休眠,否則B退出內核態回到用戶態。為什么要委托操作系統再檢查一次呢?因為有可能A已經釋放資源了,B只要再CAS一次就能獲得資源。


futex 機制的實現比較簡單,基於散列表:
每一個futex_key代表一個共享變量,即資源。
每一個節點包裹着 futex_key
每一個futex_bucket代表一個hash桶,也就是hash表中的某個位置
一個 futex_bucket 的鏈表中,有不同節點,說明有不同資源。比如說,“螢石” 是一種資源,“紅石”也是一種資源,他們的數量所代表的變量(地址)的節點會存在於下圖的同一個鏈表上
每個bucket都有一個 鎖 可以被自旋鎖 鎖定,鎖的單位是 一個 bucket上的鏈表,所以當一種資源需要加鎖,會鎖到鏈表上的其他資源。
設計者這么做其實並不過分,因為一個桶中的鏈表長度並不是很長,而且spin_lock是短時間鎖,將鎖粒度控制在整個散列表一個鎖和每個節點一個鎖之間,是對空間和時間的權衡。

futex在 線程處於內核態 ,讀取資源 之前,會用 spin_lock 鎖住 bucket,讀取資源后發現沒有資源會把自己掛入等待隊列,然后釋放spin_lock 。
持有資源的線程在喚醒等待隊列中線程之前,同樣要用 spin_lock 鎖住同樣位置的 bucket。
下圖是 futex 的互斥機制,可能會有疑問:獲取資源不用算進去嗎?
這和程序順序有關,釋放資源肯定在喚醒之前的,這是必須遵循的,因為釋放完資源才會去喚醒進程去爭奪
那么喚醒等待隊列這個操作可能在 被自旋鎖保護區域的上面或者下面。
如果在上面,那么資源在喚醒之前就釋放了,保護區里肯定可以得到資源,免於睡眠。
如果在下面,那么無論資源在喚醒之前的哪個位置,就算是在保護區里也好,只要是釋放了就行。因為喚醒操作在保護區之后,而保護區里,要休眠進程已經掛到等待隊列。
所以喚醒操作必能喚醒要休眠進程,因為他在 入隊操作之后,他能找到那些休眠的進程,從而喚醒他們。

再向上一層看, pthread_mutex_wait 和 pthread_cond_wait,這兩個函數是 Hotspot 實現 park 函數依賴的操作系統層面接口。而park函數是 LockSupport.park 方法的本地方法實現。
其中 pthread_cond_wait 是把 Java線程(java應用線程,即Mutator)放入到一個等待隊列,這個隊列稱為條件隊列。對應LockSupport.park 方法。
還有一個與之對應的解鎖方法,pthread_cond_signal ,是喚醒這個隊列上的線程。那么怎么保證對這個等待隊列的操作是互斥的呢?如果不互斥,就可能發生下面這鍾典型的寫覆蓋並發問題:

依賴的是 pthread_mutex_lock, 要操作隊列之前先獲取互斥量,操作完釋放互斥量
pthread_mutex_lock(&mutex); pthread_cond_wait(&queue); pthread_mutex_unlock(&mutex);
pthread_mutex_lock 依賴的是上面所說的,futex, 所以 pthread_mutex_lock 就是上面說的,先在用戶態讀取資源,如果沒資源了,就調用 SYS futex 系統調用
jvm(hotspot)層
到這里,操作系統和java層面差不多要連起來了,我們再通過LockSupport向上走。
在 調用LockSupport.unpark 之后調用LockSupport.park 的話,線程不會休眠。這個點很重要,沒有這個點 ,JUC中的AQS無法正常工作。
偽代碼:xchg相比xcmpchg不會比較,而是直接原子設置相應內存單元的值。
park () { // 之前有資源,直接返回,並且把資源消耗掉 if (xchg(&counter ,1, 0) == 1) { return; } // 准備操作 票據和隊列 pthread_mutex_lock(); // 可能之前 獲取 mutex 的線程給予了 資源 // 必須要有這一句,否則可能錯過釋放了的資源,永遠無法被喚醒 if (counter == 1) { counter = 0; pthread_mutex_unlock(); return; } pthread_cond_wait(); // 這句為什么在 pthread_cond_wait 之后呢? // 因為這里是線程被喚醒之后的地方,其他線程給了一個資源,當前線程才被喚醒 // 既然被喚醒了,就要去消耗這個資源,這樣一喚醒(資源+1),一睡眠(資源-1)。 // 扯平之后就是當前線程的 繼續運行狀態 counter = 0; pthread_mutex_unlock(); } unpark () { pthread_mutex_lock(); counter = 1; writeBarrierHere(); pthread_cond_signal(); pthread_mutex_unlock(); }
回到剛才的問題:為什么unpark 之后 park 不會休眠在 AQS 中起到關鍵作用?
java層
假設線程A是已經獲取資源,要釋放資源的線程
B是嘗試獲取資源的線程

線程A對應下面兩處代碼:

線程B對應下面兩處代碼。

極端一段假設:當線程B執行到下面的綠色處,A執行完成他 release 方法中的兩處代碼


雖然A釋放了資源,但是B還是判斷要休眠,於是調用LockSupport.park。於是雖然有資源但是B還是調用了park
B真的就這樣休眠了嗎?不會,奧秘在unparkSuccessor。

他會unpark 頭節點的后繼。B在調用 acquireQueued之前已經在隊列中,所以B的線程會被調用 LockSupport.unpark(B);

於是B在下次調用 LockSupport.park 的時候不會休眠,可以接着爭搶資源!
最后,JUC中的絕大多是同步工具,如Semaphore 和 CountDownLatch 都是依賴AQS的。整個JAVA應用層面到硬件原理層面的同步體系至此介紹完畢。
