簡單的非公平自旋鎖以及基於排隊的公平自旋鎖的實現
基礎
什麼是自旋鎖
由於本文主要討論的都是自旋鎖,所以首先就需要弄明白什麼是自旋鎖。
自旋鎖最大的特徵,就是它會一直迴圈檢測鎖的狀態,當鎖處於被佔用的狀態時,不會將當前執行緒阻塞住,而是任由它繼續消耗CPU Cycles,直到發現需要的鎖處於可用狀態。
有了這一層瞭解,自旋鎖的優勢和劣勢,以及其適用場景也就一目瞭然了。
優勢:
- 沒有執行緒阻塞,也就沒有了執行緒上下文切換帶來的開銷
- 自旋操作更加直觀,無需分析什麼情況下會導致執行緒阻塞
劣勢:
最大的問題就是由於需要一直迴圈檢測鎖的狀態,因此會浪費CPU Cycles
適用場景:
結合上述的優劣,自旋鎖在鎖的臨界區很小並且鎖爭搶排隊不是非常嚴重的情況下是非常合適的:
- 臨界區小,因此每個使用鎖的執行緒佔用鎖的時間不會很長,自旋等待的執行緒能夠快速地獲取到鎖。
- 所爭搶排隊不嚴重,因此鎖的自旋時間也是可控的,不會有大量執行緒處於自旋等待的狀態中。
自旋鎖只是一種加鎖和釋放的實現策略。它也能夠分為非公平和公平兩種情況。這一點很好理解,每個需要加鎖的執行緒沒有先來後到的概念,完全根據當時執行時的情況來決定哪個執行緒能夠成功加鎖。而公平鎖則通過使用一個佇列對執行緒進行排隊來保證執行緒的先來後到。
核心機制
對於加鎖和釋放鎖的操作,需要是原子性的。這是能夠繼續討論的基石。對於現代處理器,一般通過CAS(Compare And Set)操作來保證原子性。它的原理其實很簡單,就是將“對比-設定”這一個流程原子化,保證在符合某種預期的前提下,完成一次寫操作。
對應到Java語言層面,就是那一大票的AtomicXXX型別。比如在下面的非公平自旋鎖的實現中,會藉助AtomicReference型別提供的CAS操作來完成加鎖和釋放鎖的操作。
非公平自旋鎖
實現比較簡單,直接上程式碼:
public class SimpleSpinLock {
/**
* 維護當前擁有鎖的執行緒物件
*/
private AtomicReference<Thread> owner = new AtomicReference<>();
public void lock() {
Thread currentThread = Thread.currentThread();
// 只有owner沒有被加鎖的時候,才能夠加鎖成功,否則自旋等待
while (!owner.compareAndSet(null, currentThread)) {
}
}
public void unlock() {
Thread currentThread = Thread.currentThread();
// 只有鎖的owner才能夠釋放鎖,其它的執行緒因為無法滿足Compare,因此不會Set成功
owner.compareAndSet(currentThread, null);
}
}
這裡的關鍵就是加鎖和釋放鎖中的兩個CAS操作:
- 加鎖過程。將CAS操作置於一個while迴圈中,來實現自旋的語義。由於CAS操作成功與否是成功取決於它的boolean返回值,因此當CAS操作失敗的情況下,while迴圈將不會退出,會一直嘗試CAS操作直到成功為止,此即所謂的自旋(忙等待)。
- 釋放鎖過程。此時不需要迴圈操作,但是仍然會考慮到只有當前擁有鎖的執行緒才有資格釋放鎖。這一點還是通過CAS操作來保證。
這個鎖的實現是比較簡單的,關鍵需要了解自旋鎖的原理和實現層面的CAS操作。
從加鎖的實現來看,加鎖過程並沒有考慮到先來後到,因此也就不是一個公平的加鎖策略。下面介紹一種基於排隊的公平自旋鎖的實現,它類似於我們在日常生活中的各種服務場景下的排隊。比如你去銀行辦理業務,需要首先在叫號機上拿一個號碼,然後你就處於等待狀態,然後時不時地看一下當前叫到哪個號碼了(自旋等待),直到你的號碼被櫃檯呼叫(加鎖成功)。服務完成後,櫃檯會呼叫下一個號碼(釋放鎖)。
基於排隊的公平自旋鎖
我們可以使用兩個原子整型變數來分別模擬當前排隊號和當前服務號。
加鎖和釋放鎖兩個操作的過程如下:
- 加鎖過程。獲取一個排隊號,當排隊號和當前的服務號不相等時自旋等待。
- 釋放鎖過程。當前正被服務的執行緒釋放鎖,計算下一個服務號並設定。
相應的程式碼如下所示:
public class TicketLock {
/**
* 當前正在接受服務的號碼
*/
private AtomicInteger serviceNum = new AtomicInteger(0);
/**
* 希望得到服務的排隊號碼
*/
private AtomicInteger ticketNum = new AtomicInteger(0);
/**
* 嘗試獲取鎖
*
* @return
*/
public int lock() {
// 獲取排隊號
int acquiredTicketNum = ticketNum.getAndIncrement();
// 當排隊號不等於服務號的時候開始自旋等待
while (acquiredTicketNum != serviceNum.get()) {
}
return acquiredTicketNum;
}
/**
* 釋放鎖
*
* @param ticketNum
*/
public void unlock(int ticketNum) {
// 服務號增加,準備服務下一位
int nextServiceNum = serviceNum.get() + 1;
// 只有當前執行緒擁有者才能釋放鎖
serviceNum.compareAndSet(ticketNum, nextServiceNum);
}
}
這裡需要注意的是:
- 加鎖過程。lock方法會返回一個排隊號,這個排隊號在後面釋放鎖的過程中會被用到。
- 釋放鎖過程。接受希望釋放鎖的執行緒的排隊號。在CAS增加服務號的過程中會首先驗證排隊號的合法性。
總結
本文首先討論了什麼是自旋鎖,以及它的優劣和對應的應用場景。
然後給出了兩種簡單的自旋鎖的實現,分別對應非公平和公平兩種策略。
自旋鎖在Java併發包中扮演著很重要的角色,下一篇文章會分析MCS和CLH這兩種更加高階的自旋鎖原理和相應實現,為後續分析Java併發包中的基石AbstractQueuedSynchronizer掃清障礙。