Concurrency(十五: Java中的讀寫鎖)

MenfreXu發表於2019-04-10

讀寫鎖是一個比前文Java中的鎖更加複雜的鎖.想象一下當你有一個應用需要對資源進行讀寫,然而對資源的讀取次數遠大於寫入.當有兩個執行緒對同一個資源進行讀取時並不會有併發問題,所以多個執行緒可以在同一個時間點安全的讀取資源.但是當一個執行緒需要對資源進行寫入時,則其他執行緒的讀寫都不能同時進行.允許多個讀執行緒同時操作但只允許一個寫執行緒寫入資源,這種情況我們可以通過讀寫鎖來解決.

Java5中的java.util.concurrent包中已有讀寫鎖的實現.但我們還是很有必要知道它的底層遠離.

Java中讀寫鎖的實現

首先讓我們理清讀寫資源時所需要的條件:

讀操作 如果沒有執行緒正在進行寫操作並且沒有執行緒請求進行寫操作.

寫操作 如果沒有執行緒正在進行寫操作和讀操作

只有沒有執行緒正在寫入資源或是請求寫入資源時,執行緒就可以進行讀取操作.如果我們確認寫操作比讀操作重要的多,我們需要升級寫操作的優先順序,如果我們沒有這麼做,那麼在讀操作太多頻繁時候,可能會出現飢餓現象.執行緒請求寫入操作會一直阻塞到所有的讀取執行緒釋放讀寫鎖為止.如果新進行的讀執行緒一直搶佔先機,那麼寫執行緒可能會無限期的等待下去.結果就是產生飢餓現象.所以一個執行緒只能在沒有執行緒進行寫操作或是請求進行寫操作時才能進行讀取操作.

寫執行緒只有在沒有執行緒進行讀寫操作是才能進行.除非你想確保執行緒寫入請求的公平性,不然你可以忽略有多少執行緒進行寫操作請求和它們的順序.

基於以上給出的限制條件,我們可以實現一個公平鎖,如下所示:

public class ReadWriteLock {
    private int readers = 0;
    private int writers = 0;
    private int writeRequests = 0;
    
    public synchronized void lockRead() throws InterruptedException {
        while(writers > 0 || writeRequests > 0){
            wait();
        }
        readers++;
    }
    
    public synchronized void unlockRead(){
        readers--;
        notifyAll();
    }
    
    public synchronized void lockWrite() throws InterruptedException {
        writeRequests++;
        while (readers > 0 || writers> 0){
            wait();
        }
        writeRequests--;
        writers++;
    }
    
    public synchronized void unlockWrite(){
        writers--;
        notifyAll();
    }
}
複製程式碼

ReadWrite物件中,一共有兩個lock()方法和兩個unlock方法,一對讀操作的lock()和unlock()方法以及一對寫操作的lock()和unlock()方法.

對於讀操作的限制在lockRead()方法中實現.讀操作只有在一個寫執行緒取得讀寫鎖或是有一到多個寫請求的情況下才會阻塞等待.

對於寫操作的限制在lockWrite()方法中實現.一個執行緒想要進行寫操作首先要進行寫請求(writeRequests++).然後才去檢查是否已經有讀或者寫執行緒取得讀寫鎖.若沒有則取得讀寫所進行寫入操作,如有則進入while迴圈內部呼叫wait()方法進入等待狀態.這個時候當前有多少個寫入請求對本次操作沒有任何影響.

我們可以注意到,跟以往不同的是,我們在unlockRead()和unlockWrite()方法中用notifyAll()來代替notify().我們可以想象一下下面這種情況:

在讀寫鎖中同時有讀執行緒和寫執行緒在等待中.如果通過notify()喚醒的執行緒是讀執行緒時,它會馬上重新進入等待狀態.因為已經有一個寫執行緒在等待中了,即已經存在一個寫請求了.然而在沒有任何寫執行緒被喚醒情況下,將不會發生任何事情.如果換成呼叫notifyAll()的話,會喚醒所有等待中的執行緒,無論讀寫.而不是一個一個喚醒.

呼叫notifyAll()還有一個優勢,即同時存在多個讀執行緒的情況下,如果unlockWrite()被呼叫,所有讀執行緒都能夠被同時喚醒和同時操作,不用一個個來.

可重入的讀寫鎖

上文中給出的ReadWrite.class示例並不支援可重入.如果一個執行緒多次發起寫入請求,即多次嘗試獲取讀寫鎖,將會陷入阻塞,因為此前已經有一個寫執行緒獲取到讀寫鎖了,那就是它自己.可重入需要考慮以下幾種情況:

  1. 執行緒1獲得讀許可權
  2. 執行緒2請求進行寫操作,但進入阻塞,因為當前有一個讀執行緒正在進行中
  3. 執行緒1再次發起讀請求(嘗試再次獲取讀寫鎖),這次執行緒1陷入阻塞,因為當前已有一個寫請求存在.

這種情況上文給出的ReadWriteLock.class將會陷入跟死鎖類似的境地.無論執行緒讀寫都會陷入阻塞.

為了讓ReadWriteLock.class支援可重入,需要做出一點更改.讀寫操作的可重入性需要分開處理.

讀操作的可重入性

為了讓ReadWiriteLock支援讀操作的可重入性,我們需要補充下面限制:

  • 一個讀執行緒能夠多次進行讀操作,在它已經獲得讀許可權的情況下.

為了確認一個讀執行緒獲得多少次讀寫鎖,我們需要一個Map引用來記錄每一個讀執行緒獲取到讀寫鎖的次數.當決定呼叫執行緒允不允許獲得讀寫鎖時需要檢查Map引用中是否存在該執行緒.對lockRead()和unlockRead()的改寫如下:

public class ReadWriteLock {
    private int writers = 0;
    private int writeRequests = 0;
    private Map<Thread, Integer> readingThreads = new HashMap<>();

    public synchronized void lockRead() throws InterruptedException {
        Thread callingThread = Thread.currentThread();
        while (!canGrantReadAccess(callingThread)) {
            wait();
        }
        readingThreads.put(callingThread, getReadAccessCount(callingThread) + 1);
    }

    public synchronized void unlockRead() {
        Thread callingThread = Thread.currentThread();
        int accessCount = getReadAccessCount(callingThread);
        if (accessCount > 1) {
            readingThreads.put(callingThread, (accessCount - 1));
        } else {
            readingThreads.remove(callingThread);
        }
        notifyAll();
    }

    private boolean canGrantReadAccess(Thread callingThread) {
        if (writers > 0) return false;
        if (isReader(callingThread)) return true;
        return writeRequests == 0;
    }

    private boolean isReader(Thread callingThread) {
        return readingThreads.get(callingThread) != null;
    }

    private int getReadAccessCount(Thread callingThread) {
        int accessCount = 0;
        if (readingThreads.containsKey(callingThread)) {
            accessCount = readingThreads.get(callingThread) + 1;
        }
        return accessCount;
    }
}
複製程式碼

你可以看到,在沒有執行緒對資源進行寫入操作時,讀執行緒可以多次獲取到讀取許可權.更多的,當呼叫執行緒已經獲得過讀操作時將優先其他寫入請求獲取到讀寫鎖.

寫操作的可重入性

寫操作只有在已經獲得寫許可權的情況下才能多次獲取讀寫鎖.對lockWrite()和unlockWrite()的改寫如下:

public class ReadWriteLock {
    private int writerAccess = 0;
    private int writeRequests = 0;
    private Map<Thread, Integer> readingThreads = new HashMap<>();
    private Thread writingThread = null;

    public synchronized void lockWrite() throws InterruptedException {
        writeRequests++;
        Thread callingThread = Thread.currentThread();
        while (!canGrantWriteAccess(callingThread)) {
            wait();
        }
        writeRequests--;
        writerAccess++;
        writingThread = callingThread;
    }

    public synchronized void unlockWrite() {
        writerAccess--;
        if(writerAccess == 0) {
            writingThread = null;
        }
        notifyAll();
    }

    private boolean canGrantWriteAccess(Thread callingThread) {
        if (hasReaders()) return false;
        if (writingThread == null) return true;
        return isWriter(callingThread);
    }

    private boolean hasReaders() {
        return readingThreads.size() > 0;
    }

    private boolean isWriter(Thread callingThread) {
        return writingThread == callingThread;
    }
}
複製程式碼

我們需要將當前取得讀寫鎖的讀執行緒引用起來,以便決定當前呼叫執行緒是否已經獲得過寫許可權,允許多次獲取讀寫鎖.

寫操作到讀操作的可重入性

有時候一個執行緒寫操作完成後需要進行讀操作.我們允許一個已經獲得寫許可權的執行緒獲得讀許可權.同一個執行緒同時進行讀寫並不會有併發問題.我們需要對canGrantReadAccess()進行如下改寫:

 	private boolean canGrantReadAccess(Thread callingThread) {
        if(isWriter(callingThread)) return true;
        if (writers > 0) return false;
        if (isReader(callingThread)) return true;
        return writeRequests == 0;
    }
複製程式碼

一個完整的讀寫鎖實現

下面是一個完整的讀寫鎖例項.

public class ReadWriteLock {
    private int writerAccess = 0;
    private int writeRequests = 0;
    private Map<Thread, Integer> readingThreads = new HashMap<>();
    private Thread writingThread = null;

    public synchronized void lockRead() throws InterruptedException {
        Thread callingThread = Thread.currentThread();
        while (!canGrantReadAccess(callingThread)) {
            wait();
        }
        readingThreads.put(callingThread, getReadAccessCount(callingThread) + 1);
    }

    public synchronized void unlockRead() {
        Thread callingThread = Thread.currentThread();
        int accessCount = getReadAccessCount(callingThread);
        if (accessCount > 1) {
            readingThreads.put(callingThread, (accessCount - 1));
        } else {
            readingThreads.remove(callingThread);
        }
        notifyAll();
    }

    private boolean canGrantReadAccess(Thread callingThread) {
        if(isWriter(callingThread)) return true;
        if (writerAccess > 0) return false;
        if (isReader(callingThread)) return true;
        return writeRequests == 0;
    }

    private boolean isReader(Thread callingThread) {
        return readingThreads.get(callingThread) != null;
    }

    private int getReadAccessCount(Thread callingThread) {
        int accessCount = 0;
        if (readingThreads.containsKey(callingThread)) {
            accessCount = readingThreads.get(callingThread) + 1;
        }
        return accessCount;
    }

    public synchronized void lockWrite() throws InterruptedException {
        writeRequests++;
        Thread callingThread = Thread.currentThread();
        while (!canGrantWriteAccess(callingThread)) {
            wait();
        }
        writeRequests--;
        writerAccess++;
        writingThread = callingThread;
    }

    public synchronized void unlockWrite() {
        writerAccess--;
        if(writerAccess == 0) {
            writingThread = null;
            notifyAll();
        }
    }

    private boolean canGrantWriteAccess(Thread callingThread) {
        if (hasReaders()) return false;
        if (writingThread == null) return true;
        return isWriter(callingThread);
    }

    private boolean hasReaders() {
        return readingThreads.size() > 0;
    }

    private boolean isWriter(Thread callingThread) {
        return writingThread == callingThread;
    }
}
複製程式碼

在finally語句中呼叫unlock()

當使用ReadWriteLock來保證臨界區程式碼的同步時,臨界區中的程式碼可能會丟擲異常。所以很有必要在finally語句中來呼叫unlockRead()和unlockWrite()方法。無論執行緒執行的程式碼異常與否,始終能夠釋放它所持有的讀寫鎖,以讓其他執行緒能正常執行。如下所示:

        lock.lockWrite();
        try {
            //do critical section code, which may throw exception
        } finally {
            lock.unlockWrite();
        }
複製程式碼

我們使用這個小結構來保證即使臨界區程式碼丟擲異常,執行緒也能正常釋放掉所持有的讀寫鎖。如果我們沒有這麼做,一旦臨界區程式碼出現異常,執行緒將無法釋放讀寫鎖,以至於其他呼叫了該讀寫鎖的lockRead()和lockWrite()方法的執行緒將會永遠等待下去。

該系列博文為筆者複習基礎所著譯文或理解後的產物,複習原文來自Jakob Jenkov所著Java Concurrency and Multithreading Tutorial

相關文章