從0到1 怎樣憑空設計一個ReentrantLock—淺析ReentrantLock實現原理
自己動手設計一個ReentrantLock的思路演化
倘若真的就讓我們憑空實現一把鎖出來 我們應該怎樣設計這把鎖呢?
這裏提供了幾種僞代碼思路
實現思路的僞代碼—自旋
volatile int status=0;//標識 0是無鎖狀態 1是上鎖狀態
void lock(){
while(!compareAndSet(0,1)){
}
}
void unlock(){
status=0;
}
boolean compareAndSet(int expect, int newValue){
//cas修改status 成功返回true
return true;
}
你能get到他的邏輯嗎?哈哈
我們簡單解釋 有兩個線程A和B 此時線程A進入lock()這裏 調用這個CAS方法 將0改爲1 CAS返回true 加上前面的
!整體返回false 於是給某個同步方法加上了鎖 線程B此時走到這裏 CAS的期望值是0 但是此時拿到的status真實值是1 CAS比較不成功 返回false 加上!整體返回true 進入while{ }這個裏面一直轉啊轉 等多會A釋放了鎖 把status改成0 線程B才能獲取到鎖
這個邏輯就是這樣的 有點酷啊
我們看一下他的缺點
這裏可是有個while死循環 當倒黴的線程B進行CAS修改不成功的時候 就會一直轉在這個while死循環裏 就浪費了cpu資源
我們思考一個改進辦法 讓得不到鎖的線程讓出cpu
僞代碼 sleep+自旋
volatile int status=0;//標識 0是無鎖狀態 1是上鎖狀態
void lock(){
while(!compareAndSet(0,1)){
sleep(10);
}
}
void unlock(){
status=0;
}
boolean compareAndSet(int expect, int newValue){
//cas修改status 成功返回true
return true;
}
這是我們思考的第一種 讓得不到鎖的線程讓出cpu的想法
但是這裏有問題 就是睡眠的時間不好控制 比如A佔了一把鎖佔了10分鐘 這裏睡眠了10秒鐘 那麼每隔10秒
倒黴的B就會去在試着去CAS獲取鎖一下 並沒有完全的改善 所以睡眠的時間不好控制 多了少了都有問題
這種方法我們淘汰
僞代碼 park+自旋
volatile int status=0;//標識 0是無鎖狀態 1是上鎖狀態
Queue parkQueue;
void lock(){
while(!compareAndSet(0,1)){
//沒有獲取到鎖的線程讓它休眠一會
park();
}
}
void unlock(){
status=0;
lock_notify();
}
void park(){
//將當前線程加入到等待隊列
parkQueue.add(currentThread);
//釋放cpu
releaseCPU();
}
void lock_notify(){
//得到等待隊裏要被喚醒的頭部線程
Thread t= parkQueue.getHeader();
//喚醒他
unpark(t);
}
boolean compareAndSet(int expect, int newValue){
//cas修改status 成功返回true
return true;
}
我們得到了一種park+自旋的方式 這種方式似乎解決了好多問題 即上了鎖 又去解決了獲取不到鎖的線程浪費cpu的問題
注意 我們這裏設置了一個等待隊列 每次我們把隊列頭的元素取出來把他叫醒
ReentrantLock淺析
我們上面得到了一個巧妙的結果 即park+自旋
這個思路基本就是我們源碼的宏觀思路了
下面我們真的看一下@author Doug Lea這個大神是怎麼寫出來一個鎖的!!!
ReentrantLock的具體實現由有公平鎖和非公平鎖兩種形式 我們逐一具體來看
這裏因爲ReentrantLock的具體實現包含公平鎖和非公平鎖兩種 我們得初步對兩種實現的整個一個鏈路有一個全面的認識
公平鎖的具體實現
我們進入lock()方法的公平鎖實現
我們按照上面這幅圖的公平鎖鏈路一點點往下走
抽象的lock()
abstract void lock();
FairSync
final void lock() {
acquire(1);
}
進入acquire(1)
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt();
}
其中tryAcquire(arg)
是嘗試獲取鎖,這個方法是公平鎖的核心之一,它的源碼如下
tryAcquire
protected final boolean tryAcquire(int acquires) {
//獲取當前線程
final Thread current = Thread.currentThread();
//0表示無鎖狀態 1表示有鎖狀態
int c = getState();
// 若爲0,說明是無鎖狀態
if (c == 0) {
//判斷自己要不要排隊 不用排隊就把state改爲1 表示上鎖了
//hasQueuedPredecessors()這個方法
//就是去判斷有沒有其他線程已經在隊列裏排隊了
//如果前頭沒其他線程排隊 這個方法返回false 加上! 返回true
//返回true 就cas賦值
if (!hasQueuedPredecessors() &&
compareAndSetState(0, acquires)) {
//設置當前線程爲擁有鎖的線程
setExclusiveOwnerThread(current);
return true;
}
}
//判斷當前線程current和當前持有鎖的線程是不是相同
else if (current == getExclusiveOwnerThread()) {
//是重入就把鎖的計數器加1
int nextc = c + acquires;
if (nextc < 0)
throw new Error("Maximum lock count exceeded");
setState(nextc);
return true;
}
return false;
}
在tryAcquire()
方法中,主要是做了以下幾件事:
- 判斷當前線程的鎖的擁有者的狀態值是否爲0,若爲0,通過該方法
hasQueuedPredecessors()
再判斷等待線程隊列中,是否存在排在前面的線程。 - 若是沒有排在前面的線程 則通過該方法
compareAndSetState(0, acquires)
設置當前的線程狀態爲1。 - 將線程擁有者設爲當前線程
setExclusiveOwnerThread(current)
- 若是當前線程的鎖的擁有者的狀態值不爲0,說明當前的鎖已經被佔用,通過
current == getExclusiveOwnerThread()
判斷鎖的擁有者的線程,是否爲當前線程,實現鎖的可重入。 - 若是鎖的可重入 當前線程將線程的狀態值+1,並更新狀態值。
公平鎖的tryAcquire()
,實現的原理圖如下:
當tryAcquire嘗試獲取鎖失敗的時候 表明此時有鎖的競爭 就要把後面來的線程加入到等待隊列裏去
我們來看看acquireQueued()
方法,該方法是將線程加入等待的線程隊列中並且park這個線程,源碼如下:
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
//死循環
for (;;) {
//predecessor()方法拿到node的上一個節點
//把上一個節點記成p
final Node p = node.predecessor();
//判斷p是不是頭部
//如果前置節點p是頭部 就又調用tryAcquire方法自旋去嘗試獲取鎖
if (p == head && tryAcquire(arg)) {
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
// 在獲取鎖失敗後,應該將線程Park(暫停)
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
acquireQueued()
方法主要執行以下幾件事:
- 死循環處理等待線程中的前置節點,並嘗試獲取鎖,若是
p == head && tryAcquire(arg)
,則跳出循環,即獲取鎖成功。 - 若是獲取鎖不成功
shouldParkAfterFailedAcquire(p, node) &&parkAndCheckInterrupt()
就會將線程暫停。
這裏模擬一個場景 線程t1獲取到了鎖 並且一直持有鎖不去釋放 t2嘗試獲取鎖失敗 便要加入阻塞隊列並park
用一張圖去解釋上面這個場景的執行流程
還是上面的這個場景 依舊是t1佔着鎖一直不放 我們把t2加入到AQS隊列的示意圖畫一下
上圖是線程t2進入到隊列中的最終示意圖
我們把這個線程t2入隊的中間細節畫一個流程圖說明一下 這裏也會重點闡釋爲什麼t2之前會有一個thread爲null的節點
這裏我們解釋這個有意思的現象 t2之前爲什麼會有一個thread爲null的節點
這是因爲此時t1持有鎖 持有鎖的線程永遠不會參與排隊
在排隊的第一個永遠是第二個節點
此時 t2正在排隊 t2就是AQS中的第二個節點 但是t2是第一個排隊的 因爲t1持有鎖 t1不參與排隊
本文參考
公衆號 非科班的科班
https://mp.weixin.qq.com/s/PNsI9LgkG3sdreEZs9G93A
b站視頻
https://www.bilibili.com/video/BV14J4112757