我們在寫並發程式的時候,一個很常見的需求就是保證在某一個時刻只有一個執行緒執行某段程式碼,像這種程式碼叫做臨界區,而通常保證一個時刻只有一個執行緒執行臨界區的程式碼的方法就是鎖。在本篇文章當中我們將會仔細分析和學習自旋鎖,所謂自旋鎖就是透過while循環實現的,讓拿到鎖的線程進入臨界區執行程式碼,讓沒有拿到鎖的線程一直進行while死循環,這其實就是線程自己「旋」在while循環了,因而這種鎖就叫做自旋鎖。
在談自旋鎖定之前就不得不先談原子性了。所謂原子性簡單說來就是一個一個操作要么不做要么全做,全做的意思就是在操作的過程當中不能夠被中斷,比如說對變量data進行加一操作,有以下三個步驟:
將data從記憶體載入到暫存器。
將data這個值加一。
將得到的結果寫回記憶體。
原子性就表示一個執行緒在進行加一操作的時候,不能夠被其他執行緒中斷,只有這個執行緒執行完這三個過程的時候其他執行緒才能夠操作數據data。
我們現在用程式碼體驗一下,在Java當中我們可以使用AtomicInteger進行對整型資料的原子操作:
import java.util.concurrent.atomic.AtomicInteger; public class AtomicDemo { public static void main(String[] args) throws InterruptedException { AtomicInteger data = new AtomicInteger(); data.set(0); // 将数据初始化位0 Thread t1 = new Thread(() -> { for (int i = 0; i < 100000; i++) { data.addAndGet(1); // 对数据 data 进行原子加1操作 } }); Thread t2 = new Thread(() -> { for (int i = 0; i < 100000; i++) { data.addAndGet(1);// 对数据 data 进行原子加1操作 } }); // 启动两个线程 t1.start(); t2.start(); // 等待两个线程执行完成 t1.join(); t2.join(); // 打印最终的结果 System.out.println(data); // 200000 } }
從上面的程式碼分析可以知道,如果是一般的整數變數如果兩個執行緒同時進行操作的時候,最終的結果是會小於200000。
我們現在來模擬一下一般的整數變數出現問題的過程:
主記憶體data的初始值等於0,兩個執行緒得到的data初始值都等於0。
現在線程一將data加一,然後線程一將data的值同步回主內存,整個記憶體的資料變化如下:
#現在線程二data加一,然後將data的值同步回主記憶體(將原來主記憶體的值覆寫了):
我們本來希望data的值在經過上面的變化之後變成2,但是線程二覆蓋了我們的值,因此在多線程情況下,會使得我們最終的結果變小。
但是在上面的程式當中我們最終的輸出結果是等於20000的,這是因為給data進行1的操作是原子的不可分的,在操作的過程當中其他線程是不能對data進行操作的。這就是原子性帶來的優勢。
AtomicInteger類別
現在我們已經了解了原子性的作用了,我們現在來了解AtomicInteger類別的另外一個原子性的操作——compareAndSet,這個操作叫做比較並交換(CAS),他具有原子性。
public static void main(String[] args) { AtomicInteger atomicInteger = new AtomicInteger(); atomicInteger.set(0); atomicInteger.compareAndSet(0, 1); }
compareAndSet函數的意義:首先會比較第一個參數(對應上面的程式碼就是0)和atomicInteger的值,如果相等則進行交換,也就是將atomicInteger的值設定為第二個參數(對應上面的程式碼是1),如果這些操作成功,那麼compareAndSet函數就回傳true,如果操作失敗則回傳false,操作失敗可能是因為第一個參數的值(期望值)和atomicInteger不相等,如果相等也可能因為在更改atomicInteger的值的時候失敗(因為可能有多個線程在操作,因為原子性的存在,只能有一個線程操作成功)。
自旋鎖實作原理
我們可以使用AtomicInteger類別實作自旋鎖,我們可以用0這個值表示未上鎖,1這個值表示已經上鎖了。
AtomicInteger類別的初始值為0。
在上鎖時,我們可以使用程式碼atomicInteger.compareAndSet(0, 1)進行實現,我們在前面已經提到了只能夠有一個線程完成這個操作,也就是說只能有一個線程調用這行程式碼接著回傳true其餘執行緒都會回傳false,這些回傳false的執行緒無法進入臨界區,因此我們需要這些執行緒停在atomicInteger.compareAndSet(0, 1)這行程式碼無法往下執行,我們可以使用while循環讓這些線程一直停在這裡while (!value.compareAndSet(0, 1));,只有返回true的線程才能夠跳出循環,其餘線程都會一直在這裡循環,我們稱這種行為叫做自旋,這種鎖因而也被叫做自旋鎖。
线程在出临界区的时候需要重新将锁的状态调整为未上锁的上状态,我们使用代码value.compareAndSet(1, 0);就可以实现,将锁的状态还原为未上锁的状态,这样其他的自旋的线程就可以拿到锁,然后进入临界区了。
自旋锁代码实现
import java.util.concurrent.atomic.AtomicInteger; public class SpinLock { // 0 表示未上锁状态 // 1 表示上锁状态 protected AtomicInteger value; public SpinLock() { this.value = new AtomicInteger(); // 设置 value 的初始值为0 表示未上锁的状态 this.value.set(0); } public void lock() { // 进行自旋操作 while (!value.compareAndSet(0, 1)); } public void unlock() { // 将锁的状态设置为未上锁状态 value.compareAndSet(1, 0); } }
上面就是我们自己实现的自旋锁的代码,这看起来实在太简单了,但是它确实帮助我们实现了一个锁,而且能够在真实场景进行使用的,我们现在用代码对上面我们写的锁进行测试。
测试程序:
public class SpinLockTest { public static int data; public static SpinLock lock = new SpinLock(); public static void add() { for (int i = 0; i < 100000; i++) { // 上锁 只能有一个线程执行 data++ 操作 其余线程都只能进行while循环 lock.lock(); data++; lock.unlock(); } } public static void main(String[] args) throws InterruptedException { Thread[] threads = new Thread[100]; // 设置100个线程 for (int i = 0; i < 100; i ++) { threads[i] = new Thread(SpinLockTest::add); } // 启动一百个线程 for (int i = 0; i < 100; i++) { threads[i].start(); } // 等待这100个线程执行完成 for (int i = 0; i < 100; i++) { threads[i].join(); } System.out.println(data); // 10000000 } }
在上面的代码单中,我们使用100个线程,然后每个线程循环执行100000data++操作,上面的代码最后输出的结果是10000000,和我们期待的结果是相等的,这就说明我们实现的自旋锁是正确的。
可重入自旋锁
在上面实现的自旋锁当中已经可以满足一些我们的基本需求了,就是一个时刻只能够有一个线程执行临界区的代码。但是上面的的代码并不能够满足重入的需求,也就是说上面写的自旋锁并不是一个可重入的自旋锁,事实上在上面实现的自旋锁当中重入的话就会产生死锁。
我们通过一份代码来模拟上面重入产生死锁的情况:
public static void add(int state) throws InterruptedException { TimeUnit.SECONDS.sleep(1); if (state <= 3) { lock.lock(); System.out.println(Thread.currentThread().getName() + "\t进入临界区 state = " + state); for (int i = 0; i < 10; i++) data++; add(state + 1); // 进行递归重入 重入之前锁状态已经是1了 因为这个线程进入了临界区 lock.unlock(); } }
在上面的代码当中加入我们传入的参数state的值为1,那么在线程执行for循环之后再次递归调用add函数的话,那么state的值就变成了2。
if条件仍然满足,这个线程也需要重新获得锁,但是此时锁的状态是1,这个线程已经获得过一次锁了,但是自旋锁期待的锁的状态是0,因为只有这样他才能够再次获得锁,进入临界区,但是现在锁的状态是1,也就是说虽然这个线程获得过一次锁,但是它也会一直进行while循环而且永远都出不来了,这样就形成了死锁了。
可重入自旋锁思想
针对上面这种情况我们需要实现一个可重入的自旋锁,我们的思想大致如下:
在我们实现的自旋锁当中,我们可以增加两个变量,owner一个用于存当前拥有锁的线程,count一个记录当前线程进入锁的次数。
如果线程获得锁,owner = Thread.currentThread()并且count = 1。
当线程下次再想获取锁的时候,首先先看owner是不是指向自己,则一直进行循环操作,如果是则直接进行count++操作,然后就可以进入临界区了。
我们在出临界区的时候,如果count大于一的话,说明这个线程重入了这把锁,因此不能够直接将锁设置为0也就是未上锁的状态,这种情况直接进行count--操作,如果count等于1的话,说明线程当前的状态不是重入状态(可能是重入之后递归返回了),因此在出临界区之前需要将锁的状态设置为0,也就是没上锁的状态,好让其他线程能够获取锁。
可重入锁代码实现
实现的可重入锁代码如下:
public class ReentrantSpinLock extends SpinLock { private Thread owner; private int count; @Override public void lock() { if (owner == null || owner != Thread.currentThread()) { while (!value.compareAndSet(0, 1)); owner = Thread.currentThread(); count = 1; }else { count++; } } @Override public void unlock() { if (count == 1) { count = 0; value.compareAndSet(1, 0); }else count--; } }
下面我们通过一个递归程序去验证我们写的可重入的自旋锁是否能够成功工作。
测试程序:
import java.util.concurrent.TimeUnit; public class ReentrantSpinLockTest { public static int data; public static ReentrantSpinLock lock = new ReentrantSpinLock(); public static void add(int state) throws InterruptedException { TimeUnit.SECONDS.sleep(1); if (state <= 3) { lock.lock(); System.out.println(Thread.currentThread().getName() + "\t进入临界区 state = " + state); for (int i = 0; i < 10; i++) data++; add(state + 1); lock.unlock(); } } public static void main(String[] args) throws InterruptedException { Thread[] threads = new Thread[10]; for (int i = 0; i < 10; i++) { threads[i] = new Thread(new Thread(() -> { try { ReentrantSpinLockTest.add(1); } catch (InterruptedException e) { e.printStackTrace(); } }, String.valueOf(i))); } for (int i = 0; i < 10; i++) { threads[i].start(); } for (int i = 0; i < 10; i++) { threads[i].join(); } System.out.println(data); } }
上面程序的输出:
Thread-3 進入臨界區state = 1
Thread-3 進入臨界區state = 2
Thread-3 進入臨界區state = 3
Thread-0 1
Thread-0 進入臨界區state = 2
Thread-0 進入臨界區state = 3
Thread-9 進入臨界區state = 1
Thread-9 # 進入臨界區state = 2state = 1
Thread-9 # 進入臨界區state = 2state ##Thread-9 進入臨界區state = 3
Thread-4 進入臨界區state = 1
Thread-4 進入臨界區state = 2
Thread-4 # 進入臨界區state = 3# Thread-7 進入臨界區state = 1
Thread-7 進入臨界區state = 2
Thread-7 進入臨界區state = 3
Thread-8 進入臨界區state = 1##Thread-##Thread-8 8 進入臨界區state = 2
Thread-8 進入臨界區state = 3
Thread-5 進入臨界區state = 1
Thread 臨界區state = 3
Thread-2 進入臨界區state = 1
Thread-2 進入臨界區state = 2
Thread-2 進入臨界區state = 3
Thread-6 進入臨界區state = 1
Thread-6 進入臨界區state = 2
Thread-6 進入臨界區state = 3
Thread-1 進入臨界區state = 1## #Thread-1# . 2
Thread-1 進入臨界區state = 3
300
從上面的輸出結果我們可以知道,當一個執行緒能夠取得鎖定的時候他能夠進行重入,而且最終輸出的結果也是正確的,因此驗證了我們寫了可重入自旋鎖是有效的!
以上是如何使用Java實現手動自旋鎖的詳細內容。更多資訊請關注PHP中文網其他相關文章!