ReentrantReadWriteLock——讀寫鎖如何升級,爲何讀寫鎖不能插隊?

我們主要探討讀鎖應該插隊嗎?以及什麼是讀寫鎖的升降級。

讀鎖插隊策略:
    首先,我們來看一下讀鎖的插隊策略,在這裏先快速回顧一下在 24 課時公平與非公平鎖中講到的 ReentrantLock,如果鎖被設置爲非公平,那麼它是可以在前面線程釋放鎖的瞬間進行插隊的,而不需要進行排隊。在讀寫鎖這裏,策略也是這樣的嗎?

  首先,我們看到 ReentrantReadWriteLock 可以設置爲公平或者非公平,代碼如下:

公平鎖:

    複製  ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(true);
非公平鎖:

    複製ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(false);
    如果是公平鎖,我們就在構造函數的參數中傳入 true,如果是非公平鎖,就在構造函數的參數中傳入 false,默認是非公平鎖。在獲取讀鎖之前,線程會檢查 readerShouldBlock() 方法,同樣,在獲取寫鎖之前,線程會檢查 writerShouldBlock() 方法,來決定是否需要插隊或者是去排隊。

首先看公平鎖對於這兩個方法的實現:

final boolean writerShouldBlock() {
    return hasQueuedPredecessors();
}
final boolean readerShouldBlock() {
    return hasQueuedPredecessors();
}

    很明顯,在公平鎖的情況下,只要等待隊列中有線程在等待,也就是 hasQueuedPredecessors() 返回 true 的時候,那麼 writer 和 reader 都會 block,也就是一律不允許插隊,都乖乖去排隊,這也符合公平鎖的思想。

    下面讓我們來看一下非公平鎖的實現:

final boolean writerShouldBlock() {
    return false; // writers can always barge
}
final boolean readerShouldBlock() {
    return apparentlyFirstQueuedIsExclusive();
}

    在 writerShouldBlock() 這個方法中始終返回 false,可以看出,對於想獲取寫鎖的線程而言,由於返回值是 false,所以它是隨時可以插隊的,這就和我們的 ReentrantLock 的設計思想是一樣的,但是讀鎖卻不一樣。這裏實現的策略很有意思,先讓我們來看下面這種場景:

    假設線程 2 和線程 4 正在同時讀取,線程 3 想要寫入,但是由於線程 2 和線程 4 已經持有讀鎖了,所以線程 3 就進入等待隊列進行等待。此時,線程 5 突然跑過來想要插隊獲取讀鎖:


面對這種情況有兩種應對策略:

第一種策略:允許插隊
    由於現在有線程在讀,而線程 5 又不會特別增加它們讀的負擔,因爲線程們可以共用這把鎖,所以第一種策略就是讓線程 5 直接加入到線程 2 和線程 4 一起去讀取。

    這種策略看上去增加了效率,但是有一個嚴重的問題,那就是如果想要讀取的線程不停地增加,比如線程 6,那麼線程  6 也可以插隊,這就會導致讀鎖長時間內不會被釋放,導致線程 3 長時間內拿不到寫鎖,也就是那個需要拿到寫鎖的線程會陷入“飢餓”狀態,它將在長時間內得不到執行。

第二種策略:不允許插隊
    這種策略認爲由於線程 3 已經提前等待了,所以雖然線程 5 如果直接插隊成功,可以提高效率,但是我們依然讓線程 5 去排隊等待, 按照這種策略線程 5 會被放入等待隊列中,並且排在線程 3 的後面,讓線程 3 優先於線程 5 執行,這樣可以避免“飢餓”狀態,這對於程序的健壯性是很有好處的,直到線程 3 運行完畢,線程 5 纔有機會運行,這樣誰都不會等待太久的時間。

    所以我們可以看出,即便是非公平鎖,只要等待隊列的頭結點是嘗試獲取寫鎖的線程,那麼讀鎖依然是不能插隊的,目的是避免“飢餓”。

策略選擇演示
    策略的選擇取決於具體鎖的實現,ReentrantReadWriteLock 的實現選擇了策略 2 ,是很明智的。

    下面我們就用實際的代碼來演示一下上面這種場景。

    策略演示代碼如下所示:

public class ReadLockJumpQueue {
 
    private static final ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock();
    private static final ReentrantReadWriteLock.ReadLock readLock = reentrantReadWriteLock
            .readLock();
    private static final ReentrantReadWriteLock.WriteLock writeLock = reentrantReadWriteLock
            .writeLock();
 
    private static void read() {
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到讀鎖,正在讀取");
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            readLock.unlock();
            System.out.println(Thread.currentThread().getName() + "釋放讀鎖");
        }
    }
 
    private static void write() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到寫鎖,正在寫入");
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            writeLock.unlock();
            System.out.println(Thread.currentThread().getName() + "釋放寫鎖");
        }
    }
 
    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> read(),"Thread-2").start();
        new Thread(() -> read(),"Thread-4").start();
        new Thread(() -> write(),"Thread-3").start();
        new Thread(() -> read(),"Thread-5").start();
    }
}

以上代碼的運行結果是:

Thread-2得到讀鎖,正在讀取
Thread-4得到讀鎖,正在讀取
Thread-2釋放讀鎖
Thread-4釋放讀鎖
Thread-3得到寫鎖,正在寫入
Thread-3釋放寫鎖
Thread-5得到讀鎖,正在讀取
Thread-5釋放讀鎖

    從這個結果可以看出,ReentrantReadWriteLock 的實現選擇了“不允許插隊”的策略,這就大大減小了發生“飢餓”的概率。(如果運行結果和課程不一致,可以在每個線程啓動後增加 100ms 的睡眠時間,以便保證線程的運行順序)。

鎖的升降級


    讀寫鎖降級功能代碼演示
    下面我們再來看一下鎖的升降級,首先我們看一下這段代碼,這段代碼演示了在更新緩存的時候,如何利用鎖的降級功能。

public class CachedData {
 
    Object data;
    volatile boolean cacheValid;
    final ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
 
    void processCachedData() {
        rwl.readLock().lock();
        if (!cacheValid) {
            //在獲取寫鎖之前,必須首先釋放讀鎖。
            rwl.readLock().unlock();
            rwl.writeLock().lock();
            try {
                //這裏需要再次判斷數據的有效性,因爲在我們釋放讀鎖和獲取寫鎖的空隙之內,可能有其他線程修改了數據。
                if (!cacheValid) {
                    data = new Object();
                    cacheValid = true;
                }
                //在不釋放寫鎖的情況下,直接獲取讀鎖,這就是讀寫鎖的降級。
                rwl.readLock().lock();
            } finally {
                //釋放了寫鎖,但是依然持有讀鎖
                rwl.writeLock().unlock();
            }
        }
 
        try {
            System.out.println(data);
        } finally {
            //釋放讀鎖
            rwl.readLock().unlock();
        }
    }
}

    在這段代碼中有一個讀寫鎖,最重要的就是中間的 processCachedData 方法,在這個方法中,會首先獲取到讀鎖,也就是rwl.readLock().lock(),它去判斷當前的緩存是否有效,如果有效那麼就直接跳過整個 if 語句,如果已經失效,代表我們需要更新這個緩存了。由於我們需要更新緩存,所以之前獲取到的讀鎖是不夠用的,我們需要獲取寫鎖。

    在獲取寫鎖之前,我們首先釋放讀鎖,然後利用 rwl.writeLock().lock()來獲取到寫鎖,然後是經典的 try finally 語句,在 try 語句中我們首先判斷緩存是否有效,因爲在剛纔釋放讀鎖和獲取寫鎖的過程中,可能有其他線程搶先修改了數據,所以在此我們需要進行二次判斷。

    如果我們發現緩存是無效的,就用 new Object() 這樣的方式來示意,獲取到了新的數據內容,並把緩存的標記位設置爲 ture,讓緩存變得有效。由於我們後續希望打印出 data 的值,所以不能在此處釋放掉所有的鎖。我們的選擇是在不釋放寫鎖的情況下直接獲取讀鎖,也就是rwl.readLock().lock() 這行語句所做的事情,然後,在持有讀鎖的情況下釋放寫鎖,最後,在最下面的 try 中把 data 的值打印出來。

這就是一個非常典型的利用鎖的降級功能的代碼。

    你可能會想,我爲什麼要這麼麻煩進行降級呢?我一直持有最高等級的寫鎖不就可以了嗎?這樣誰都沒辦法來影響到我自己的工作,永遠是線程安全的。

爲什麼需要鎖的降級?
    如果我們在剛纔的方法中,一直使用寫鎖,最後才釋放寫鎖的話,雖然確實是線程安全的,但是也是沒有必要的,因爲我們只有一處修改數據的代碼:

    複製data = new Object();
    後面我們對於 data 僅僅是讀取。如果還一直使用寫鎖的話,就不能讓多個線程同時來讀取了,持有寫鎖是浪費資源的,降低了整體的效率,所以這個時候利用鎖的降級是很好的辦法,可以提高整體性能。

支持鎖的降級,不支持升級


    如果我們運行下面這段代碼,在不釋放讀鎖的情況下直接嘗試獲取寫鎖,也就是鎖的升級,會讓線程直接阻塞,程序是無法運行的。

final static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
 
public static void main(String[] args) {
    upgrade();
}
 
public static void upgrade() {
    rwl.readLock().lock();
    System.out.println("獲取到了讀鎖");
    rwl.writeLock().lock();
    System.out.println("成功升級");
}

    這段代碼會打印出“獲取到了讀鎖”,但是卻不會打印出“成功升級”,因爲 ReentrantReadWriteLock 不支持讀鎖升級到寫鎖。

爲什麼不支持鎖的升級?
    我們知道讀寫鎖的特點是如果線程都申請讀鎖,是可以多個線程同時持有的,可是如果是寫鎖,只能有一個線程持有,並且不可能存在讀鎖和寫鎖同時持有的情況。

    正是因爲不可能有讀鎖和寫鎖同時持有的情況,所以升級寫鎖的過程中,需要等到所有的讀鎖都釋放,此時才能進行升級。

    假設有 A,B 和 C 三個線程,它們都已持有讀鎖。假設線程 A 嘗試從讀鎖升級到寫鎖。那麼它必須等待 B 和 C 釋放掉已經獲取到的讀鎖。如果隨着時間推移,B 和 C 逐漸釋放了它們的讀鎖,此時線程 A 確實是可以成功升級並獲取寫鎖。

    但是我們考慮一種特殊情況。假設線程 A 和 B 都想升級到寫鎖,那麼對於線程 A 而言,它需要等待其他所有線程,包括線程 B 在內釋放讀鎖。而線程 B 也需要等待所有的線程,包括線程 A 釋放讀鎖。這就是一種非常典型的死鎖的情況。誰都願不願意率先釋放掉自己手中的鎖。

     但是讀寫鎖的升級並不是不可能的,也有可以實現的方案,如果我們保證每次只有一個線程可以升級,那麼就可以保證線程安全。只不過最常見的 ReentrantReadWriteLock 對此並不支持。

總結:


  對於 ReentrantReadWriteLock 而言。

插隊策略
    公平策略下,只要隊列裏有線程已經在排隊,就不允許插隊。
非公平策略下:
    如果允許讀鎖插隊,那麼由於讀鎖可以同時被多個線程持有,所以可能造成源源不斷的後面的線程一直插隊成功,導致讀鎖一直不能完全釋放,從而導致寫鎖一直等待,爲了防止“飢餓”,在等待隊列的頭結點是嘗試獲取寫鎖的線程的時候,不允許讀鎖插隊。
    寫鎖可以隨時插隊,因爲寫鎖並不容易插隊成功,寫鎖只有在當前沒有任何其他線程持有讀鎖和寫鎖的時候,才能插隊成功,同時寫鎖一旦插隊失敗就會進入等待隊列,所以很難造成“飢餓”的情況,允許寫鎖插隊是爲了提高效率。
    升降級策略:只能從寫鎖降級爲讀鎖,不能從讀鎖升級爲寫鎖。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章