Java中的自旋鎖
自旋鎖(spinlock):是指當一個執行緒在獲取鎖的時候,如果鎖已經被其它執行緒獲取,那麼該執行緒將迴圈等待,然後不斷的判斷鎖是否能夠被成功獲取,直到獲取到鎖才會退出迴圈。
獲取鎖的執行緒一直處於活躍狀態,但是並沒有執行任何有效的任務,使用這種鎖會造成busy-waiting。
Java如何實現自旋鎖?
下面是個簡單的例子:
public class SpinLock { private AtomicReference<Thread> cas = new AtomicReference<Thread>(); public void lock() { Thread current = Thread.currentThread(); // 利用CAS while (!cas.compareAndSet(null, current)) { // DO nothing } } public void unlock() { Thread current = Thread.currentThread(); cas.compareAndSet(current, null); } }
lock()方法利用的CAS,當第一個執行緒A獲取鎖的時候,能夠成功獲取到,不會進入while迴圈,如果此時執行緒A沒有釋放鎖,另一個執行緒B又來獲取鎖,此時由於不滿足CAS,所以就會進入while迴圈,不斷判斷是否滿足CAS,直到A執行緒呼叫unlock方法釋放了該鎖。
自旋鎖的缺點
使用自旋鎖會有以下一個問題:
1. 如果某個執行緒持有鎖的時間過長,就會導致其它等待獲取鎖的執行緒進入迴圈等待,消耗CPU。使用不當會造成CPU使用率極高。
2. 上面Java實現的自旋鎖不是公平的,即無法滿足等待時間最長的執行緒優先獲取鎖。不公平的鎖就會存在“執行緒飢餓”問題。
自旋鎖的優點
自旋鎖不會使執行緒狀態發生切換,一直處於使用者態,即執行緒一直都是active的;不會使執行緒進入阻塞狀態,減少了不必要的上下文切換,執行速度快
非自旋鎖在獲取不到鎖的時候會進入阻塞狀態,從而進入核心態,當獲取到鎖的時候需要從核心態恢復,需要執行緒上下文切換。 (執行緒被阻塞後便進入核心(Linux)排程狀態,這個會導致系統在使用者態與核心態之間來回切換,嚴重影響鎖的效能)
可重入的自旋鎖和不可重入的自旋鎖
文章開始的時候的那段程式碼,仔細分析一下就可以看出,它是不支援重入的,即當一個執行緒第一次已經獲取到了該鎖,在鎖釋放之前又一次重新獲取該鎖,第二次就不能成功獲取到。由於不滿足CAS,所以第二次獲取會進入while迴圈等待,而如果是可重入鎖,第二次也是應該能夠成功獲取到的。
而且,即使第二次能夠成功獲取,那麼當第一次釋放鎖的時候,第二次獲取到的鎖也會被釋放,而這是不合理的。
為了實現可重入鎖,我們需要引入一個計數器,用來記錄獲取鎖的執行緒數。
public class ReentrantSpinLock {
private AtomicReference<Thread> cas = new AtomicReference<Thread>();
private int count;
public void lock() {
Thread current = Thread.currentThread();
if (current == cas.get()) { // 如果當前執行緒已經獲取到了鎖,執行緒數增加一,然後返回
count++;
return;
}
// 如果沒獲取到鎖,則通過CAS自旋
while (!cas.compareAndSet(null, current)) {
// DO nothing
}
}
public void unlock() {
Thread cur = Thread.currentThread();
if (cur == cas.get()) {
if (count > 0) {// 如果大於0,表示當前執行緒多次獲取了該鎖,釋放鎖通過count減一來模擬
count--;
} else {// 如果count==0,可以將鎖釋放,這樣就能保證獲取鎖的次數與釋放鎖的次數是一致的了。
cas.compareAndSet(cur, null);
}
}
}
}
自旋鎖的其他變種
1. TicketLock
TicketLock主要解決的是公平性的問題。
思路:每當有執行緒獲取鎖的時候,就給該執行緒分配一個遞增的id,我們稱之為排隊號,同時,鎖對應一個服務號,每當有執行緒釋放鎖,服務號就會遞增,此時如果服務號與某個執行緒排隊號一致,那麼該執行緒就獲得鎖,由於排隊號是遞增的,所以就保證了最先請求獲取鎖的執行緒可以最先獲取到鎖,就實現了公平性。
可以想象成銀行辦理業務排隊,排隊的每一個顧客都代表一個需要請求鎖的執行緒,而銀行服務視窗表示鎖,每當有視窗服務完成就把自己的服務號加一,此時在排隊的所有顧客中,只有自己的排隊號與服務號一致的才可以得到服務。
實現程式碼:
public class TicketLock {
/**
* 服務號
*/
private AtomicInteger serviceNum = new AtomicInteger();
/**
* 排隊號
*/
private AtomicInteger ticketNum = new AtomicInteger();
/**
* lock:獲取鎖,如果獲取成功,返回當前執行緒的排隊號,獲取排隊號用於釋放鎖. <br/>
*
* @return
*/
public int lock() {
int currentTicketNum = ticketNum.incrementAndGet();
while (currentTicketNum != serviceNum.get()) {
// Do nothing
}
return currentTicketNum;
}
/**
* unlock:釋放鎖,傳入當前持有鎖的執行緒的排隊號 <br/>
*
* @param ticketnum
*/
public void unlock(int ticketnum) {
serviceNum.compareAndSet(ticketnum, ticketnum + 1);
}
}
上面的實現方式是,執行緒獲取鎖之後,將它的排隊號返回,等該執行緒釋放鎖的時候,需要將該排隊號傳入。但這樣是有風險的,因為這個排隊號是可以被修改的,一旦排隊號被不小心修改了,那麼鎖將不能被正確釋放。一種更好的實現方式如下:
public class TicketLockV2 {
/**
* 服務號
*/
private AtomicInteger serviceNum = new AtomicInteger();
/**
* 排隊號
*/
private AtomicInteger ticketNum = new AtomicInteger();
/**
* 新增一個ThreadLocal,用於儲存每個執行緒的排隊號
*/
private ThreadLocal<Integer> ticketNumHolder = new ThreadLocal<Integer>();
public void lock() {
int currentTicketNum = ticketNum.incrementAndGet();
// 獲取鎖的時候,將當前執行緒的排隊號儲存起來
ticketNumHolder.set(currentTicketNum);
while (currentTicketNum != serviceNum.get()) {
// Do nothing
}
}
public void unlock() {
// 釋放鎖,從ThreadLocal中獲取當前執行緒的排隊號
Integer currentTickNum = ticketNumHolder.get();
serviceNum.compareAndSet(currentTickNum, currentTickNum + 1);
}
}
TicketLock存在的問題
多處理器系統上,每個程序/執行緒佔用的處理器都在讀寫同一個變數serviceNum ,每次讀寫操作都必須在多個處理器快取之間進行快取同步,這會導致繁重的系統匯流排和記憶體的流量,大大降低系統整體的效能。
2. CLHLock
CLH鎖是一種基於連結串列的可擴充套件、高效能、公平的自旋鎖,申請執行緒只在本地變數上自旋,它不斷輪詢前驅的狀態,如果發現前驅釋放了鎖就結束自旋,獲得鎖。
實現程式碼如下:
import java.util.concurrent.atomic.AtomicReferenceFieldUpdater;
/**
* CLH的發明人是:Craig,Landin and Hagersten。
* 程式碼來源:http://ifeve.com/java_lock_see2/
*/
public class CLHLock {
/**
* 定義一個節點,預設的lock狀態為true
*/
public static class CLHNode {
private volatile boolean isLocked = true;
}
/**
* 尾部節點,只用一個節點即可
*/
private volatile CLHNode tail;
private static final ThreadLocal<CLHNode> LOCAL = new ThreadLocal<CLHNode>();
private static final AtomicReferenceFieldUpdater<CLHLock, CLHNode> UPDATER = AtomicReferenceFieldUpdater.newUpdater(CLHLock.class, CLHNode.class,
"tail");
public void lock() {
// 新建節點並將節點與當前執行緒儲存起來
CLHNode node = new CLHNode();
LOCAL.set(node);
// 將新建的節點設定為尾部節點,並返回舊的節點(原子操作),這裡舊的節點實際上就是當前節點的前驅節點
CLHNode preNode = UPDATER.getAndSet(this, node);
if (preNode != null) {
// 前驅節點不為null表示當鎖被其他執行緒佔用,通過不斷輪詢判斷前驅節點的鎖標誌位等待前驅節點釋放鎖
while (preNode.isLocked) {
}
preNode = null;
LOCAL.set(node);
}
// 如果不存在前驅節點,表示該鎖沒有被其他執行緒佔用,則當前執行緒獲得鎖
}
public void unlock() {
// 獲取當前執行緒對應的節點
CLHNode node = LOCAL.get();
// 如果tail節點等於node,則將tail節點更新為null,同時將node的lock狀態職位false,表示當前執行緒釋放了鎖
if (!UPDATER.compareAndSet(this, node, null)) {
node.isLocked = false;
}
node = null;
}
}
3. MCSLock
MCSLock則是對本地變數的節點進行迴圈。
/**
* MCS:發明人名字John Mellor-Crummey和Michael Scott
* 程式碼來源:http://ifeve.com/java_lock_see2/
*/
public class MCSLock {
/**
* 節點,記錄當前節點的鎖狀態以及後驅節點
*/
public static class MCSNode {
volatile MCSNode next;
volatile boolean isLocked = true;
}
private static final ThreadLocal<MCSNode> NODE = new ThreadLocal<MCSNode>();
// 佇列
@SuppressWarnings("unused")
private volatile MCSNode queue;
// queue更新器
private static final AtomicReferenceFieldUpdater<MCSLock, MCSNode> UPDATER = AtomicReferenceFieldUpdater.newUpdater(MCSLock.class, MCSNode.class,
"queue");
public void lock() {
// 建立節點並儲存到ThreadLocal中
MCSNode currentNode = new MCSNode();
NODE.set(currentNode);
// 將queue設定為當前節點,並且返回之前的節點
MCSNode preNode = UPDATER.getAndSet(this, currentNode);
if (preNode != null) {
// 如果之前節點不為null,表示鎖已經被其他執行緒持有
preNode.next = currentNode;
// 迴圈判斷,直到當前節點的鎖標誌位為false
while (currentNode.isLocked) {
}
}
}
public void unlock() {
MCSNode currentNode = NODE.get();
// next為null表示沒有正在等待獲取鎖的執行緒
if (currentNode.next == null) {
// 更新狀態並設定queue為null
if (UPDATER.compareAndSet(this, currentNode, null)) {
// 如果成功了,表示queue==currentNode,即當前節點後面沒有節點了
return;
} else {
// 如果不成功,表示queue!=currentNode,即當前節點後面多了一個節點,表示有執行緒在等待
// 如果當前節點的後續節點為null,則需要等待其不為null(參考加鎖方法)
while (currentNode.next == null) {
}
}
} else {
// 如果不為null,表示有執行緒在等待獲取鎖,此時將等待執行緒對應的節點鎖狀態更新為false,同時將當前執行緒的後繼節點設為null
currentNode.next.isLocked = false;
currentNode.next = null;
}
}
}
4. CLHLock 和 MCSLock
都是基於連結串列,不同的是CLHLock是基於隱式連結串列,沒有真正的後續節點屬性,MCSLock是顯示連結串列,有一個指向後續節點的屬性。
將獲取鎖的執行緒狀態藉助節點(node)儲存,每個執行緒都有一份獨立的節點,這樣就解決了TicketLock多處理器快取同步的問題。
自旋鎖與互斥鎖
自旋鎖與互斥鎖都是為了實現保護資源共享的機制。
無論是自旋鎖還是互斥鎖,在任意時刻,都最多隻能有一個保持者。
獲取互斥鎖的執行緒,如果鎖已經被佔用,則該執行緒將進入睡眠狀態;獲取自旋鎖的執行緒則不會睡眠,而是一直迴圈等待鎖釋放。
總結
自旋鎖:執行緒獲取鎖的時候,如果鎖被其他執行緒持有,則當前執行緒將迴圈等待,直到獲取到鎖。
自旋鎖等待期間,執行緒的狀態不會改變,執行緒一直是使用者態並且是活動的(active)。
自旋鎖如果持有鎖的時間太長,則會導致其它等待獲取鎖的執行緒耗盡CPU。
自旋鎖本身無法保證公平性,同時也無法保證可重入性。
基於自旋鎖,可以實現具備公平性和可重入性質的鎖。
TicketLock:採用類似銀行排號叫好的方式實現自旋鎖的公平性,但是由於不停的讀取serviceNum,每次讀寫操作都必須在多個處理器快取之間進行快取同步,這會導致繁重的系統匯流排和記憶體的流量,大大降低系統整體的效能。
CLHLock和MCSLock通過連結串列的方式避免了減少了處理器快取同步,極大的提高了效能,區別在於CLHLock是通過輪詢其前驅節點的狀態,而MCS則是檢視當前節點的鎖狀態。
CLHLock在NUMA架構下使用會存在問題。在沒有cache的NUMA系統架構中,由於CLHLock是在當前節點的前一個節點上自旋,NUMA架構中處理器訪問本地記憶體的速度高於通過網路訪問其他節點的記憶體,所以CLHLock在NUMA架構上不是最優的自旋鎖。