Java實現手寫自旋鎖的示例代碼

前言

我們在寫並發程序的時候,一個非常常見的需求就是保證在某一個時刻隻有一個線程執行某段代碼,像這種代碼叫做臨界區,而通常保證一個時刻隻有一個線程執行臨界區的代碼的方法就是鎖。在本篇文章當中我們將會仔細分析和學習自旋鎖,所謂自旋鎖就是通過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    進入臨界區 state = 1
Thread-0    進入臨界區 state = 2
Thread-0    進入臨界區 state = 3
Thread-9    進入臨界區 state = 1
Thread-9    進入臨界區 state = 2
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-8    進入臨界區 state = 2
Thread-8    進入臨界區 state = 3
Thread-5    進入臨界區 state = 1
Thread-5    進入臨界區 state = 2
Thread-5    進入臨界區 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    進入臨界區 state = 2
Thread-1    進入臨界區 state = 3
300

從上面的輸出結果我們就可以知道,當一個線程能夠獲取鎖的時候他能夠進行重入,而且最終輸出的結果也是正確的,因此驗證瞭我們寫瞭可重入自旋鎖是有效的!

總結

在本篇文章當中主要給大傢介紹瞭自旋鎖和可重入自旋鎖的原理,並且實現瞭一遍,其實代碼還是比較簡單關鍵需要大傢將這其中的邏輯理清楚:

所謂自旋鎖就是通過while循環實現的,讓拿到鎖的線程進入臨界區執行代碼,讓沒有拿到鎖的線程一直進行while死循環。

可重入的含義就是一個線程已經競爭到瞭一個鎖,在競爭到這個鎖之後又一次有重入臨界區代碼的需求,如果能夠保證這個線程能夠重新進入臨界區,這就叫可重入。

我們在實現自旋鎖的時候使用的是AtomicInteger類,並且我們使用0和1這兩個數值用於表示無鎖和鎖被占用兩個狀態,在獲取鎖的時候使用while循環不斷進行CAS操作,直到操作成功返回true,在釋放鎖的時候使用CAS將鎖的狀態從1變成0。

實現可重入鎖最重要的一點就是需要記錄是那個線程獲得瞭鎖,同時還需要記錄獲取瞭幾次鎖,因為我們在解鎖的時候需要進行判斷,之後count = 1的情況才能將鎖的狀態從1設置成0。

到此這篇關於Java實現手寫自旋鎖的示例代碼的文章就介紹到這瞭,更多相關Java自旋鎖內容請搜索WalkonNet以前的文章或繼續瀏覽下面的相關文章希望大傢以後多多支持WalkonNet!

推薦閱讀: