什么是自旋鎖
自旋鎖是指當一個線程嘗試獲取某個鎖時,如果該鎖已被其他線程占用,就一直循環檢測鎖是否被釋放,而不是進入線程掛起或睡眠狀態。
為什么要使用自旋鎖
多個線程對同一個變量一直使用CAS操作,那么會有大量修改操作,從而產生大量的緩存一致性流量,因為每一次CAS操作都會發出廣播通知其他處理器,從而影響程序的性能。
線程自旋與線程阻塞
阻塞的缺點顯而易見,線程一旦進入阻塞(Block),再被喚醒的代價比較高,性能較差。自旋的優點是線程還是Runnable的,只是在執行空代碼。當然一直自旋也會白白消耗計算資源,所以常見的做法是先自旋一段時間,還沒拿到鎖就進入阻塞。JVM在處理synchrized實現時就是采用了這種折中的方案,並提供了調節自旋的參數。
SpinLock簡單自旋鎖(可重入)
spin-lock 是一種基於test-and-set操作的鎖機制。
test_and_set是一個原子操作,讀取lock,查看lock值,如果是0,設置其為1,返回0。如果是lock值為1, 直接返回1。這里lock的值0和1分別表示無鎖和有鎖。由於test_and_set的原子性,不會同時有兩個進程/線程同時進入該方法, 整個方法無須擔心並發操作導致的數據不一致。
這里用AtomicReference是為了使用它的原子性的compareAndSet方法(CAS操作),解決了多線程並發操作導致數據不一致的問題,確保其他線程可以看到鎖的真實狀態。
-
缺點:
- CAS操作需要硬件的配合;
- 保證各個CPU的緩存(L1、L2、L3、跨CPU Socket、主存)的數據一致性,通訊開銷很大,在多處理器系統上更嚴重;
- 沒法保證公平性,不保證等待進程/線程按照FIFO順序獲得鎖。
public class SpinLock implements Lock {
/**
* use thread itself as synchronization state
* 使用Owner Thread作為同步狀態,比使用一個簡單的boolean flag可以攜帶更多信息
*/
private AtomicReference<Thread> owner = new AtomicReference<>();
/**
* reentrant count of a thread, no need to be volatile
*/
private int count = 0;
@Override
public void lock() {
Thread t = Thread.currentThread();
// if re-enter, increment the count.
if (t == owner.get()) {
++count;
return;
}
//spin
while (owner.compareAndSet(null, t)) {
}
}
@Override
public void unlock() {
Thread t = Thread.currentThread();
//only the owner could do unlock;
if (t == owner.get()) {
if (count > 0) {
// reentrant count not zero, just decrease the counter.
--count;
} else {
// compareAndSet is not need here, already checked
owner.set(null);
}
}
}
}
TicketLock
Ticket Lock 是為了解決上面的公平性問題,類似於現實中銀行櫃台的排隊叫號:鎖擁有一個服務號,表示正在服務的線程,還有一個排隊號;每個線程嘗試獲取鎖之前先拿一個排隊號,然后不斷輪詢鎖的當前服務號是否是自己的排隊號,如果是,則表示自己擁有了鎖,不是則繼續輪詢。
當線程釋放鎖時,將服務號加1,這樣下一個線程看到這個變化,就退出自旋。
public class TicketLock implements Lock {
private AtomicInteger serviceNum = new AtomicInteger(0);
private AtomicInteger ticketNum = new AtomicInteger(0);
private final ThreadLocal<Integer> myNum = new ThreadLocal<>();
@Override
public void lock() {
myNum.set(ticketNum.getAndIncrement());
while (serviceNum.get() != myNum.get()) {
}
}
@Override
public void unlock() {
serviceNum.compareAndSet(myNum.get(), myNum.get() + 1);
myNum.remove();
}
}
- 缺點:
Ticket Lock 雖然解決了公平性的問題,但是多處理器系統上,每個進程/線程占用的處理器都在讀寫同一個變量serviceNum ,每次讀寫操作都必須在多個處理器緩存之間進行緩存同步,這會導致繁重的系統總線和內存的流量,大大降低系統整體的性能。
下面介紹的CLH鎖和MCS鎖都是為了解決這個問題的。
CLHLock
CLH的發明人是:Craig,Landin and Hagersten。是一種基於鏈表的可擴展、高性能、公平的自旋鎖,申請線程只在本地變量上自旋,它不斷輪詢前驅的狀態,如果發現前驅釋放了鎖就結束自旋。
CLH隊列中的結點QNode中含有一個locked字段,該字段若為true表示該線程需要獲取鎖,且不釋放鎖,為false表示線程釋放了鎖。結點之間是通過隱形的鏈表相連,之所以叫隱形的鏈表是因為這些結點之間沒有明顯的next指針,而是通過preNode所指向的結點的變化情況來影響myNode的行為。CLHLock上還有一個尾指針,始終指向隊列的最后一個結點。CLHLock的類圖如下所示:
當一個線程需要獲取鎖時,會創建一個新的QNode,將其中的locked設置為true表示需要獲取鎖,然后線程對tail域調用getAndSet方法,使自己成為隊列的尾部,同時獲取一個指向其前趨的引用preNode,然后該線程就在前趨結點的locked字段上自旋,直到前趨結點釋放鎖。當一個線程需要釋放鎖時,將當前結點的locked域設置為false,同時回收前趨結點。如下圖所示,線程A需要獲取鎖,其myNode域為true,些時tail指向線程A的結點,然后線程B也加入到線程A后面,tail指向線程B的結點。然后線程A和B都在它的preNode域上旋轉,一旦它的preNode結點的locked字段變為false,它就可以獲取鎖。明顯線程A的preNode locked域為false,此時線程A獲取到了鎖。
實現如下:
public class CLHLock implements Lock {
/**
* 鎖等待隊列的尾部
*/
private AtomicReference<QNode> tail;
private ThreadLocal<QNode> preNode;
private ThreadLocal<QNode> myNode;
public CLHLock() {
tail = new AtomicReference<>(null);
myNode = ThreadLocal.withInitial(QNode::new);
preNode = ThreadLocal.withInitial(() -> null);
}
@Override
public void lock() {
QNode qnode = myNode.get();
//設置自己的狀態為locked=true表示需要獲取鎖
qnode.locked = true;
//鏈表的尾部設置為本線程的qNode,並將之前的尾部設置為當前線程的preNode
QNode pre = tail.getAndSet(qnode);
preNode.set(pre);
if(pre != null) {
//當前線程在前驅節點的locked字段上旋轉,直到前驅節點釋放鎖資源
while (pre.locked) {
}
}
}
@Override
public void unlock() {
QNode qnode = myNode.get();
//釋放鎖操作時將自己的locked設置為false,可以使得自己的后繼節點可以結束自旋
qnode.locked = false;
//回收自己這個節點,從虛擬隊列中刪除
//將當前節點引用置為自己的preNode,那么下一個節點的preNode就變為了當前節點的preNode,這樣就將當前節點移出了隊列
myNode.set(preNode.get());
}
private class QNode {
/**
* true表示該線程需要獲取鎖,且不釋放鎖,為false表示線程釋放了鎖,且不需要鎖
*/
private volatile boolean locked = false;
}
}
CLH隊列鎖的優點是空間復雜度低(如果有n個線程,L個鎖,每個線程每次只獲取一個鎖,那么需要的存儲空間是O(L+n),n個線程有n個myNode,L個鎖有L個tail),CLH的一種變體被應用在了JAVA並發框架中。唯一的缺點是在NUMA(一種CPU架構)系統結構下性能很差,在這種系統結構下,每個線程有自己的內存,如果前趨結點的內存位置比較遠,自旋判斷前趨結點的locked域,性能將大打折扣,但是在SMP(一種CPU架構)系統結構下該法還是非常有效的。一種解決NUMA系統結構的思路是MCS隊列鎖。
MCSLock
MCS 來自於其發明人名字的首字母: John Mellor-Crummey和Michael Scott。是一種基於鏈表的可擴展、高性能、公平的自旋鎖,申請線程只在本地變量上自旋,直接前驅負責通知其結束自旋,從而極大地減少了不必要的處理器緩存同步的次數,降低了總線和內存的開銷。
public class MCSLock implements Lock {
private AtomicReference<QNode> tail;
private ThreadLocal<QNode> myNode;
public MCSLock() {
tail = new AtomicReference<>(null);
myNode = ThreadLocal.withInitial(QNode::new);
}
@Override
public void lock() {
QNode qnode = myNode.get();
QNode preNode = tail.getAndSet(qnode);
if (preNode != null) {
qnode.locked = false;
preNode.next = qnode;
//wait until predecessor gives up the lock
while (!qnode.locked) {
}
}
qnode.locked = true;
}
@Override
public void unlock() {
QNode qnode = myNode.get();
if (qnode.next == null) {
//后面沒有等待線程的情況
if (tail.compareAndSet(qnode, null)) {
//真的沒有等待線程,則直接返回,不需要通知
return;
}
//wait until predecessor fills in its next field
// 突然有人排在自己后面了,可能還不知道是誰,下面是等待后續者
while (qnode.next == null) {
}
}
//后面有等待線程,則通知后面的線程
qnode.next.locked = true;
qnode.next = null;
}
private class QNode {
/**
* 是否被qNode所屬線程鎖定
*/
private volatile boolean locked = false;
/**
* 與CLHLock相比,多了這個真正的next
*/
private volatile QNode next = null;
}
}
CLH鎖 與 MCS鎖 的比較
-
差異:
- 從代碼實現來看,CLH比MCS要簡單得多。
- CLH是在前趨結點的locked域上自旋等待,而MCS是在自己的結點的locked域上自旋等待。正因為如此,它解決了CLH在NUMA系統架構中獲取locked域狀態內存過遠的問題。
- 從鏈表隊列來看,CLHNode不直接持有前驅節點,CLH鎖釋放時只需要改變自己的屬性;MCSNode直接持有后繼節點,MCS鎖釋放需要改變后繼節點的屬性。
- CLH鎖釋放時只需要改變自己的屬性,MCS鎖釋放則需要改變后繼節點的屬性。