什么是自旋?
首先,我們了解什么叫自旋?“自旋”可以理解為“自我旋轉”,這里的“旋轉”指“循環”,比如 while 循環或者 for 循環。
“自旋”就是自己在這里不停地循環,直到目標達成。而不像普通的鎖那樣,如果獲取不到鎖就進入阻塞。
對比自旋和非自旋的獲取鎖的流程,
下面我們用這樣一張流程圖來對比一下自旋鎖和非自旋鎖的獲取鎖的過程。
首先,我們來看自旋鎖,它並不會放棄 CPU 時間片,而是通過自旋等待鎖的釋放,也就是說,它會不停地再次地嘗試獲取鎖,如果失敗就再次嘗試,直到成功為止。
我們再來看下非自旋鎖,非自旋鎖和自旋鎖是完全不一樣的,如果它發現此時獲取不到鎖,它就把自己的線程切換狀態,讓線程休眠,然后 CPU 就可以在這段時間去做很多其他的事情,直到之前持有這把鎖的線程釋放了鎖,於是 CPU 再把之前的線程恢復回來,讓這個線程再去嘗試獲取這把鎖。如果再次失敗,就再次讓線程休眠,如果成功,一樣可以成功獲取到同步資源的鎖。
可以看出,非自旋鎖和自旋鎖最大的區別,就是如果它遇到拿不到鎖的情況,它會把線程阻塞,直到被喚醒。而自旋鎖會不停地嘗試。那么,自旋鎖這樣不停嘗試的好處是什么呢?
自旋鎖的好處
首先,阻塞和喚醒線程都是需要高昂的開銷的,如果同步代碼塊中的內容不復雜,那么可能轉換線程帶來的開銷比實際業務代碼執行的開銷還要大。
在很多場景下,可能我們的同步代碼塊的內容並不多,所以需要的執行時間也很短,如果我們僅僅為了這點時間就去切換線程狀態,那么其實不如讓線程不切換狀態,而是讓它自旋地嘗試獲取鎖,等待其他線程釋放鎖,有時我只需要稍等一下,就可以避免上下文切換等開銷,提高了效率。
用一句話總結自旋鎖的好處,那就是自旋鎖用循環去不停地嘗試獲取鎖,讓線程始終處於 Runnable 狀態,節省了線程狀態切換帶來的開銷。
AtomicLong 的實現
在 Java 1.5 版本及以上的並發包中,也就是 java.util.concurrent 的包中,里面的原子類基本都是自旋鎖的實現。
比如我們看一個 AtomicLong 的實現,里面有一個 getAndIncrement 方法,源碼如下:
public final long getAndIncrement() { return unsafe.getAndAddLong(this, valueOffset, 1L); }
可以看到它調用了一個 unsafe.getAndAddLong,所以我們再來看這個方法:
public final long getAndAddLong (Object var1,long var2, long var4){ long var6; do { var6 = this.getLongVolatile(var1, var2); } while (!this.compareAndSwapLong(var1, var2, var6, var6 + var4)); return var6; }
在這個方法中,它用了一個 do while 循環。這里就很明顯了:
do { var6 = this.getLongVolatile(var1, var2); } while (!this.compareAndSwapLong(var1, var2, var6, var6 + var4));
這里的 do-while 循環就是一個自旋操作,如果在修改過程中遇到了其他線程競爭導致沒修改成功的情況,就會 while 循環里進行死循環,直到修改成功為止。
自己實現一個可重入的自旋鎖
下面我們來看一個自己實現可重入的自旋鎖。
import java.util.concurrent.atomic.AtomicReference; import java.util.concurrent.locks.Lock; /** * 描述: 實現一個可重入的自旋鎖 */ public class ReentrantSpinLock { private AtomicReference<Thread> owner = new AtomicReference<>(); //重入次數 private int count = 0; public void lock() { Thread t = Thread.currentThread(); if (t == owner.get()) { ++count; return; } //自旋獲取鎖 while (!owner.compareAndSet(null, t)) { System.out.println("自旋了"); } } public void unlock() { Thread t = Thread.currentThread(); //只有持有鎖的線程才能解鎖 if (t == owner.get()) { if (count > 0) { --count; } else { //此處無需CAS操作,因為沒有競爭,因為只有線程持有者才能解鎖 owner.set(null); } } } public static void main(String[] args) { ReentrantSpinLock spinLock = new ReentrantSpinLock(); Runnable runnable = new Runnable() { @Override public void run() { System.out.println(Thread.currentThread().getName() + "開始嘗試獲取自旋鎖"); spinLock.lock(); try { System.out.println(Thread.currentThread().getName() + "獲取到了自旋鎖"); Thread.sleep(4000); } catch (InterruptedException e) { e.printStackTrace(); } finally { spinLock.unlock(); System.out.println(Thread.currentThread().getName() + "釋放了了自旋鎖"); } } }; Thread thread1 = new Thread(runnable); Thread thread2 = new Thread(runnable); thread1.start(); thread2.start(); } }
這段代碼的運行結果是:
前面會打印出很多“自旋了”,說明自旋期間,CPU依然在不停運轉。
缺點:
那么自旋鎖有沒有缺點呢?其實自旋鎖是有缺點的。
它最大的缺點就在於雖然避免了線程切換的開銷,但是它在避免線程切換開銷的同時也帶來了新的開銷,因為它需要不停得去嘗試獲取鎖。
如果這把鎖一直不能被釋放,那么這種嘗試只是無用的嘗試,會白白浪費處理器資源。
也就是說,雖然一開始自旋鎖的開銷低於線程切換,但是隨着時間的增加,這種開銷也是水漲船高,后期甚至會超過線程切換的開銷,得不償失。
適用場景:
所以我們就要看一下自旋鎖的適用場景。首先,自旋鎖適用於並發度不是特別高的場景,以及臨界區比較短小的情況,這樣我們可以利用避免線程切換來提高效率。
可是如果臨界區很大,線程一旦拿到鎖,很久才會釋放的話,那就不合適用自旋鎖,因為自旋會一直占用 CPU 卻無法拿到鎖,白白消耗資源。