java中的讀寫鎖


讀寫鎖與ReentrantLock對比

普通的 ReentrantLock可以我們保證了線程安全,但是也浪費了一定的資源,因為如果多個讀操作同時進行,其實並沒有線程安全問題,我們可以允許讓多個讀操作並行,以便提高程序效率。

但是寫操作不是線程安全的,如果多個線程同時寫,或者在寫的同時進行讀操作,便會造成線程安全問題。

我們的讀寫鎖就解決了這樣的問題,它設定了一套規則,既可以保證多個線程同時讀的效率,同時又可以保證有寫入操作時的線程安全。

整體思路是它有兩把鎖,第 1 把鎖是寫鎖,獲得寫鎖之后,既可以讀數據又可以修改數據,而第 2 把鎖是讀鎖,獲得讀鎖之后,只能查看數據,不能修改數據。讀鎖可以被多個線程同時持有,所以多個線程可以同時查看數據。

在讀的地方合理使用讀鎖,在寫的地方合理使用寫鎖,靈活控制,可以提高程序的執行效率。

讀寫鎖的獲取規則

使用讀寫鎖時遵守下面的獲取規則:

  1. 當一個線程已經占有了讀鎖,那么其他線程如果想要申請讀鎖,可以申請成功;
  2. 當一個線程已經占有了讀鎖,而且有其他線程想要申請獲取寫鎖的話,是不能申請成功的,因為讀寫互斥;
  3. 當一個線程已經占有了寫鎖,那么此時其他線程無論是想申請讀鎖還是寫鎖,都無法申請成功。

所以我們用一句話總結:要么是一個或多個線程同時有讀鎖,要么是一個線程有寫鎖,但是兩者不會同時出現。也可以總結為:讀讀共享、其他都互斥(寫寫互斥、讀寫互斥、寫讀互斥)。

使用案例

相比於 ReentrantLock 適用於一般場合,ReadWriteLock 適用於讀多寫少的情況,合理使用可以進一步提高並發效率。

示例

public class ReadWriteLockDemo {
    private static final ReentrantReadWriteLock reentrantReadWriteLock = new ReentrantReadWriteLock(
            false);
    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(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + "釋放讀鎖");
            readLock.unlock();
        }
    }

    private static void write() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到寫鎖,正在寫入");
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + "釋放寫鎖");
            writeLock.unlock();
        }
    }

    public static void main(String[] args) {
        new Thread(() -> read()).start();
        new Thread(() -> read()).start();
        new Thread(() -> write()).start();
        new Thread(() -> write()).start();
    }
}

輸出

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

讀鎖插隊策略

公平與非公平鎖中講到的 ReentrantLock,如果鎖被設置為非公平,那么它是可以在前面線程釋放鎖的瞬間進行插隊的,而不需要進行排隊

public ReentrantReadWriteLock() {
    this(false);
}

/**
 * Creates a new {@code ReentrantReadWriteLock} with
 * the given fairness policy.
 *
 * @param fair {@code true} if this lock should use a fair ordering policy
 */
public ReentrantReadWriteLock(boolean fair) {
    sync = fair ? new FairSync() : new NonfairSync();
    readerLock = new ReadLock(this);
    writerLock = new WriteLock(this);
}

讀寫鎖與ReentrantLock一樣都可以通過參數設置公平非公平鎖,默認非公平鎖

公平鎖

static final class FairSync extends Sync {
    private static final long serialVersionUID = -2274990926593161451L;
    final boolean writerShouldBlock() {
        return hasQueuedPredecessors();
    }
    final boolean readerShouldBlock() {
        return hasQueuedPredecessors();
    }
}

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

非公平

static final class NonfairSync extends Sync {
    private static final long serialVersionUID = -8159625535654395037L;
    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 ReadLockJumpQue {
    private static final ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
    private static final ReentrantReadWriteLock.ReadLock readLock = readWriteLock.readLock();
    private static final ReentrantReadWriteLock.WriteLock writeLock = readWriteLock.writeLock();

    private static void read() {
        readLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到讀鎖,正在讀取");
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + "釋放讀鎖");
            readLock.unlock();
        }
    }

    private static void write() {
        writeLock.lock();
        try {
            System.out.println(Thread.currentThread().getName() + "得到寫鎖,正在寫入");
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        } finally {
            System.out.println(Thread.currentThread().getName() + "釋放寫鎖");
            writeLock.unlock();
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new Thread(() -> read(),"Thread-2").start();
        Thread.sleep(100);
        new Thread(() -> read(),"Thread-4").start();
        Thread.sleep(100);
        new Thread(() -> write(),"Thread-3").start();
        Thread.sleep(100);
        new Thread(() -> read(),"Thread-5").start();
        Thread.sleep(100);
    }
}

總結

ReentrantLock和ReentrantReadWriteLock都有公平鎖和非公平鎖,讀寫鎖對於寫鎖不允許讀鎖插隊,有效避免飢餓

鎖的升降級

讀寫鎖降級功能代碼演示

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

public class CacheData {
    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 而言。

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

java學習交流q群: 513650703


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM