Java併發程式設計——深入理解自旋鎖

it_was發表於2020-09-16

1.什麼是自旋鎖

自旋鎖(spinlock):是指當一個執行緒在獲取鎖的時候,如果鎖已經被其它執行緒獲取,那麼該執行緒將迴圈等待,然後不斷的判斷鎖是否能夠被成功獲取,直到獲取到鎖才會退出迴圈。
獲取鎖的執行緒一直處於活躍狀態,但是並沒有執行任何有效的任務,使用這種鎖會造成busy-waiting

2.Java如何實現自旋鎖?

先看一個實現自旋鎖的例子,java.util.concurrent包裡提供了很多面向併發程式設計的類. 使用這些類在多核CPU的機器上會有比較好的效能.主要原因是這些類裡面大多使用(失敗-重試方式的)樂觀鎖而不是synchronized方式的悲觀鎖.


class spinlock {
    private AtomicReference<Thread> cas;
    spinlock(AtomicReference<Thread> cas){
        this.cas = cas;
    }
    public void lock() {
        Thread current = Thread.currentThread();
        // 利用CAS
        while (!cas.compareAndSet(null, current)) { //為什麼預期是null??
            // DO nothing
            System.out.println("I am spinning");
        }
    }

    public void unlock() {
        Thread current = Thread.currentThread();
        cas.compareAndSet(current, null);
    }
}

lock()方法利用的CAS,當第一個執行緒A獲取鎖的時候,能夠成功獲取到,不會進入while迴圈,如果此時執行緒A沒有釋放鎖,另一個執行緒B又來獲取鎖,此時由於不滿足CAS,所以就會進入while迴圈,不斷判斷是否滿足CAS,直到A執行緒呼叫unlock方法釋放了該鎖。

自旋鎖驗證程式碼

package ddx.多執行緒;

import java.util.concurrent.atomic.AtomicReference;

public class 自旋鎖 {
    public static void main(String[] args) {
        AtomicReference<Thread> cas = new AtomicReference<Thread>();
        Thread thread1 = new Thread(new Task(cas));
        Thread thread2 = new Thread(new Task(cas));
        thread1.start();
        thread2.start();
    }


}

//自旋鎖驗證
class Task implements Runnable {
    private AtomicReference<Thread> cas;
    private spinlock slock ;

    public Task(AtomicReference<Thread> cas) {
        this.cas = cas;
        this.slock = new spinlock(cas);
    }

    @Override
    public void run() {
        slock.lock(); //上鎖
        for (int i = 0; i < 10; i++) {
            //Thread.yield();
            System.out.println(i);
        }
        slock.unlock();
    }
}

透過之前的AtomicReference類建立了一個自旋鎖cas,然後建立兩個執行緒,分別執行,結果如下:

0
I am spin
I am spin
I am spin
I am spin
I am spin
I am spin
I am spin
I am spin
I am spin
I am spin
I am spin
I am spin
I am spin
I am spin
I am spin
I am spin
1
I am spin
I am spin
I am spin
I am spin
I am spin
2
3
4
5
6
7
8
9
I am spin
0
1
2
3
4
5
6
7
8
9

透過對輸出結果的分析我們可以得知,首先假定執行緒一在執行lock方法的時候獲得了鎖,透過方法

cas.compareAndSet(null, current)

將引用改為執行緒一的引用,跳過while迴圈,執行列印函式

而執行緒二此時也進入lock方法,在執行比較操作的時候發現,expect value != update value,於是進入while迴圈,列印

i am spinning。由以下紅字可以得出結論,Java中的一個執行緒並不是總是佔著cpu時間片不放,一直執行完的,而是採用搶佔式排程,所以出現了上面兩個執行緒交替執行的現象

Java執行緒的實現是透過對映到系統的輕量級執行緒上,輕量級執行緒有對應系統的核心執行緒,核心執行緒的排程由系統排程器來排程的,所以Java的執行緒排程方式取決於系統核心排程器,只不過剛好目前主流作業系統的執行緒實現都是搶佔式的。

3.自旋鎖存在的問題

使用自旋鎖會有以下一個問題:
1. 如果某個執行緒持有鎖的時間過長,就會導致其它等待獲取鎖的執行緒進入迴圈等待,消耗CPU。使用不當會造成CPU使用率極高。
2. 上面Java實現的自旋鎖不是公平的,即無法滿足等待時間最長的執行緒優先獲取鎖。不公平的鎖就會存在“執行緒飢餓”問題。

4.自旋鎖的優點

  1. 自旋鎖不會使執行緒狀態發生切換,一直處於使用者態,即執行緒一直都是active的;不會使執行緒進入阻塞狀態,減少了不必要的上下文切換,執行速度快
  2. 非自旋鎖在獲取不到鎖的時候會進入阻塞狀態,從而進入核心態,當獲取到鎖的時候需要從核心態恢復,需要執行緒上下文切換。 (執行緒被阻塞後便進入核心(Linux)排程狀態,這個會導致系統在使用者態與核心態之間來回切換,嚴重影響鎖的效能)

5.可重入的自旋鎖和不可重入的自旋鎖

文章開始的時候的那段程式碼,仔細分析一下就可以看出,它是不支援重入的,即當一個執行緒第一次已經獲取到了該鎖,在鎖釋放之前又一次重新獲取該鎖,第二次就不能成功獲取到。由於不滿足CAS,所以第二次獲取會進入while迴圈等待,而如果是可重入鎖,第二次也是應該能夠成功獲取到的。
而且,即使第二次能夠成功獲取,那麼當第一次釋放鎖的時候,第二次獲取到的鎖也會被釋放,而這是不合理的。

例如將程式碼改成如下:

@Override
    public void run() {
        slock.lock(); //上鎖
        slock.lock(); //再次獲取自己的鎖!由於不可重入,則會陷入迴圈
        for (int i = 0; i < 10; i++) {
            //Thread.yield();
            System.out.println(i);
        }
        slock.unlock();
    }

則執行結果將會無限列印,陷入無終止的迴圈!

為了實現可重入鎖,我們需要引入一個計數器,用來記錄獲取鎖的執行緒數。

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);
            }
        }
    }
}

同樣lock方法會先判斷是否當前執行緒已經拿到了鎖,拿到了就讓count加一,可重入,然後直接返回!而unlock方法則會首先判斷當前執行緒是否拿到了鎖,如果拿到了,就會先判斷計數器,不斷減一,不斷解鎖!

可重入自旋鎖程式碼驗證


//可重入自旋鎖驗證
class Task1 implements Runnable{
    private AtomicReference<Thread> cas;
    private ReentrantSpinLock slock ;

    public Task1(AtomicReference<Thread> cas) {
        this.cas = cas;
        this.slock = new ReentrantSpinLock(cas);
    }

    @Override
    public void run() {
        slock.lock(); //上鎖
        slock.lock(); //再次獲取自己的鎖!沒問題!
        for (int i = 0; i < 10; i++) {
            //Thread.yield();
            System.out.println(i);
        }
        slock.unlock(); //釋放一層,但此時count為1,不為零,導致另一個執行緒依然處於忙迴圈狀態,所以加鎖和解鎖一定要對應上,避免出現另一個執行緒永遠拿不到鎖的情況
        slock.unlock();
    }
}
  • 自旋鎖與互斥鎖都是為了實現保護資源共享的機制。
  • 無論是自旋鎖還是互斥鎖,在任意時刻,都最多隻能有一個保持者。
  • 獲取互斥鎖的執行緒,如果鎖已經被佔用,則該執行緒將進入睡眠狀態;獲取自旋鎖的執行緒則不會睡眠,而是一直迴圈等待鎖釋放。
  • 自旋鎖:執行緒獲取鎖的時候,如果鎖被其他執行緒持有,則當前執行緒將迴圈等待,直到獲取到鎖。
  • 自旋鎖等待期間,執行緒的狀態不會改變,執行緒一直是使用者態並且是活動的(active)。
  • 自旋鎖如果持有鎖的時間太長,則會導致其它等待獲取鎖的執行緒耗盡CPU。
  • 自旋鎖本身無法保證公平性,同時也無法保證可重入性。
  • 基於自旋鎖,可以實現具備公平性和可重入性質的鎖。
本作品採用《CC 協議》,轉載必須註明作者和本文連結

相關文章