高併發學習之12ReentrantReadWriteLock的實現原理分析

1. 簡介

同樣的在鎖的認識中,我們提到了讀寫鎖ReentrantReadWriteLock,的基本使用,在AQS中,我們分析了鎖的基本實現方式,在上一篇中我們分析了重入的鎖ReentrantLock的實現方式,已經重入鎖支持的兩種模式:公平鎖和非公平鎖的實現機制。
這篇文章我們將分析讀寫鎖ReentrantReadWriteLock的原理。
還是老樣子先看下讀寫鎖的源碼定義:

public class ReentrantReadWriteLock
        implements ReadWriteLock, java.io.Serializable {

 	private static final long serialVersionUID = -6992448646407690164L;
    //定義自己的讀鎖
    private final ReentrantReadWriteLock.ReadLock readerLock;
    //定義自己的寫鎖
    private final ReentrantReadWriteLock.WriteLock writerLock;
    /** Performs all synchronization mechanics */
    final Sync sync;
    abstract static class Sync extends AbstractQueuedSynchronizer {
		......
	}
	//讀寫鎖自己定義的非公平鎖
	static final class NonfairSync extends Sync {
		private static final long serialVersionUID = -8159625535654395037L;
        final boolean writerShouldBlock() {
            return false; // writers can always barge
        }
        final boolean readerShouldBlock() {
            /* As a heuristic to avoid indefinite writer starvation,
             * block if the thread that momentarily appears to be head
             * of queue, if one exists, is a waiting writer.  This is
             * only a probabilistic effect since a new reader will not
             * block if there is a waiting writer behind other enabled
             * readers that have not yet drained from the queue.
             */
            return apparentlyFirstQueuedIsExclusive();
        }
	}
	//讀寫鎖自己定義的公平鎖
	static final class FairSync extends Sync {
        private static final long serialVersionUID = -2274990926593161451L;
        final boolean writerShouldBlock() {
            return hasQueuedPredecessors();
        }
        final boolean readerShouldBlock() {
            return hasQueuedPredecessors();
        }
    }
    //讀寫鎖定義的讀鎖,是共享鎖
    public static class ReadLock implements Lock, Serializable {
        private static final long serialVersionUID = -5992448646407690164L;
        private final ReentrantReadWriteLock.Sync sync;

        protected ReadLock(ReentrantReadWriteLock var1) {
            this.sync = var1.sync;
        }
		//使用的是AQS中共享模式
        public void lock() {
            this.sync.acquireShared(1);
        }
        public boolean tryLock() {
            return this.sync.tryReadLock();
        }
        public void unlock() {
            this.sync.releaseShared(1);
        }
		.......
    }
    //讀寫鎖中的寫鎖,是獨佔鎖
	 public static class WriteLock implements Lock, Serializable {
        private static final long serialVersionUID = -4992448646407690164L;
        private final ReentrantReadWriteLock.Sync sync;

        protected WriteLock(ReentrantReadWriteLock var1) {
            this.sync = var1.sync;
        }
		//獨佔式獲取鎖
        public void lock() {
            this.sync.acquire(1);
        }
        public boolean tryLock() {
            return this.sync.tryWriteLock();
        }
        public void unlock() {
            this.sync.release(1);
        }
		.....
    }

}

其實看過AQS重入鎖分析的朋友看到這裏就已經大致知道了讀寫鎖是什麼原理了,但是我們還是要分析下讀寫鎖
讀寫鎖在同一時刻可以允許多個讀線程訪問,但是在寫線程訪問時,所有的讀線程和其他寫線程均被阻塞。讀寫鎖維護了一對鎖,一個讀鎖和一個寫鎖,通過分離讀鎖和寫鎖,使得併發性相比一般的排他鎖有了很大提升。
除了保證寫操作對讀操作的可見性以及併發性的提升之外,讀寫鎖能夠簡化讀寫交互場景的編程方式。假設在程序中定義一個共享的用作緩存數據結構,它大部分時間提供讀服務(例如查詢和搜索),而寫操作佔有的時間很少,但是寫操作完成之後的更新需要對後續的讀服務可見。
在沒有讀寫鎖支持的時候,如果需要完成上述工作就要使用Java的等待通知機制,就是當寫操作開始時,所有晚於寫操作的讀操作均會進入等待狀態,只有寫操作完成並進行通知之後,所有等待的讀操作才能繼續執行(寫操作之間依靠synchronized關鍵進行同步),這樣做的目的是使讀操作能讀取到正確的數據,不會出現髒讀。改用讀寫鎖實現上述功能,只需要在讀操作時獲取讀鎖,寫操作時獲取寫鎖即可。當寫鎖被獲取到時,後續(非當前寫操作線程)的讀寫操作都會被阻塞,寫鎖釋放之後,所有操作繼續執行,編程方式相對於使用等待通知機制的實現方式而言,變得簡單明瞭。

2. 讀寫鎖的事例

老規矩寫事例之前需要先了解讀寫鎖提供的api,在讀寫鎖中僅提供獲取讀鎖和寫鎖的api,其源碼如下

	//讀寫鎖構造函數
	public ReentrantReadWriteLock() {
        this(false);
    }
	//根據設置是公平還是非公平(默認非公平),初始化讀鎖和寫鎖
    public ReentrantReadWriteLock(boolean var1) {
        this.sync = (ReentrantReadWriteLock.Sync)(var1?new ReentrantReadWriteLock.FairSync():new ReentrantReadWriteLock.NonfairSync());
        this.readerLock = new ReentrantReadWriteLock.ReadLock(this);
        this.writerLock = new ReentrantReadWriteLock.WriteLock(this);
    }
	//獲取讀鎖
    public ReentrantReadWriteLock.WriteLock writeLock() {
        return this.writerLock;
    }
	//獲取寫鎖
    public ReentrantReadWriteLock.ReadLock readLock() {
        return this.readerLock;
    }

除此之外讀寫鎖提供了一套可獲取狀態的api,如下:

//返回當前讀鎖被獲取的次數 該次數不等於獲取讀鎖的線程數。例如,僅一個線程 它連續獲取(重進入)了N次讀鎖,那麼佔據讀鎖的線程數是1,但該方法返回N
int getReadLockCount()
//返回當前線程獲取讀鎖的次數 該方法在Java 6 中加入到 ReentrantReadWriteLock,使用ThreadLocal 保存當前線程獲取的次數, 這也使得 Java 6 的實現變得更加複雜
int getReadHoldCount()
//判斷寫鎖是否被獲取
boolean isWriteLocked() 
//返回當前寫鎖被獲取的次數
int getWriteHoldCount() 

下面開始寫一個demo:

public class Cache{
    static Map<String, Object> map = new HashMap<>();
    static ReentrantReadWriteLock rwl = new ReentrantReadWriteLock();
    static Lock readLock = rwl.readLock();
    static Lock writeLock = rwl.writeLock();
    // 獲取一個key對應的value
    public static final Object get(String key) {
        readLock.lock();
        try {
            return map.get(key);
        } finally {
            readLock.unlock();
        }
    }
    // 設置key對應的value,並返回舊的value
    public static final Object put(String key, Object value) {
        writeLock.lock();
        try {
            return map.put(key, value);
        } finally {
            writeLock.unlock();
        }
    }
    // 清空所有的內容
    public static final void clear() {
        writeLock.lock();
        try {
            map.clear();
        } finally {
            writeLock.unlock();
        }
    }
}

在上面示例中,Cache組合一個非線程安全的HashMap作爲緩存的實現,同時使用讀寫鎖的讀鎖和寫鎖來保證Cache是線程安全的。在讀操作get(String key)方法中,需要獲取讀鎖,這使得併發訪問該方法時不會被阻塞。寫操作put(String key,Object value)方法和clear()方法,在更新HashMap時必須提前獲取寫鎖,當獲取寫鎖後,其他線程對於讀鎖和寫鎖的獲取均被阻塞,而只有寫鎖被釋放之後,其他讀寫操作才能繼續。Cache使用讀寫鎖提升讀操作的併發性,也保證每次寫操作對所有的讀寫操作的可見性,同時簡化了編程方式。

3. 讀寫鎖實現分析

上面我們多次提到了獲取讀鎖、獲取寫鎖、獲取寫鎖時,其他線程獲取讀鎖/寫鎖時線程阻塞、寫鎖的釋放,以及我們沒有提到的讀寫鎖的一個特性:鎖降級,現在我們就分析寫其是怎麼做到的。

3.1 讀寫狀態的設計

讀寫鎖同樣依賴自定義同步器來實現同步功能,而讀寫狀態就是其同步器的同步狀態。回想ReentrantLock中自定義同步器的實現,同步狀態表示鎖被一個線程重複獲取的次數,而讀寫鎖的自定義同步器需要在同步狀態(一個整型變量)上維護多個讀線程和一個寫線程的狀態,使得該狀態的設計成爲讀寫鎖實現的關鍵。
如果在一個整型變量上維護多種狀態,就一定需要“按位切割使用”這個變量,讀寫鎖將變量切分成了兩個部分,高16位表示讀,低16位表示寫,劃分方式如圖所示。讀寫鎖狀態的劃分方式
當前同步狀態表示一個線程已經獲取了寫鎖,且重進入了兩次,同時也連續獲取了兩次讀鎖。讀寫鎖是如何迅速確定讀和寫各自的狀態呢?答案是通過位運算
假設當前同步狀態值爲S,寫狀態等於 S & 0x0000FFFF(將高16位全部抹去),讀狀態等於S>>>16(無符號補0右移16位)。當寫狀態增加1時,等於S+1,當讀狀態增加1時,等於S+(1<<16),也就是S+0x00010000。
根據狀態的劃分能得出一個推論:S不等於0時,當寫狀態(S&0x0000FFFF)等於0時,則讀狀態(S>>>16)大於0,即讀鎖已被獲取。具體可以看下源碼。

3.2 寫鎖的獲取與釋放

寫鎖是一個支持重進入的獨佔鎖。如果當前線程已經獲取了寫鎖,則增加寫狀態。如果當前線程在獲取寫鎖時,讀鎖已經被獲取(讀狀態不爲0)或者該線程不是已經獲取寫鎖的線程,則當前線程進入等待狀態,獲取寫鎖的代碼如下:

//寫鎖獲取同步狀態
 protected final boolean tryAcquire(int acquires) {
            Thread current = Thread.currentThread();
            int c = getState();
            //獲取同步狀態被獨佔持有的數量
            int w = exclusiveCount(c);
            //已經有線程持有同步狀態
            if (c != 0) {
                // 存在讀鎖或者當前獲取線程不是已經獲取寫鎖的線程
                //w==0 表示已經有線程持有同步狀態,且不是寫鎖持有
                if (w == 0 || current != getExclusiveOwnerThread())
                    return false;
                if (w + exclusiveCount(acquires) > MAX_COUNT)
                    throw new Error("Maximum lock count exceeded");
                // Reentrant acquire
                setState(c + acquires);
                return true;
            }
            if (writerShouldBlock() ||
                !compareAndSetState(c, c + acquires))
                return false;
            setExclusiveOwnerThread(current);
            return true;
        }

該方法除了重入條件(當前線程爲獲取了寫鎖的線程)之外,增加了一個讀鎖是否存在的判斷。如果存在讀鎖,則寫鎖不能被獲取,原因在於:讀寫鎖要確保寫鎖的操作對讀鎖可見,如果允許讀鎖在已被獲取的情況下對寫鎖的獲取,那麼正在運行的其他讀線程就無法感知到當前寫線程的操作。因此,只有等待其他讀線程都釋放了讀鎖,寫鎖才能被當前線程獲取,而寫鎖一旦被獲取,則其他讀寫線程的後續訪問均被阻塞。
寫鎖的釋放與ReentrantLock的釋放過程基本類似,每次釋放均減少寫狀態,當寫狀態爲0時表示寫鎖已被釋放,從而等待的讀寫線程能夠繼續訪問讀寫鎖,同時前次寫線程的修改對後續讀寫線程可見。

//寫鎖釋放	
 protected final boolean tryRelease(int releases) {
            if (!isHeldExclusively())
                throw new IllegalMonitorStateException();
            int nextc = getState() - releases;
            boolean free = exclusiveCount(nextc) == 0;
            if (free)
                setExclusiveOwnerThread(null);
            setState(nextc);
            return free;
        }
3.3 讀鎖的獲取與釋放

讀鎖是一個支持重進入的共享鎖,它能夠被多個線程同時獲取,在沒有其他寫線程訪問(或者寫狀態爲0)時,讀鎖總會被成功地獲取,而所做的也只是(線程安全的)增加讀狀態。如果當前線程已經獲取了讀鎖,則增加讀狀態。如果當前線程在獲取讀鎖時,寫鎖已被其他線程獲取,則進入等待狀態。獲取讀鎖的實現從Java 5到Java 6變得複雜許多,主要原因是新增了一些功能,例如getReadHoldCount()方法,作用是返回當前線程獲取讀鎖的次數。讀狀態是所有線程獲取讀鎖次數的總和,而每個線程各自獲取讀鎖的次數只能選擇保存在ThreadLocal中,由線程自身維護,這使獲取讀鎖的實現變得複雜。因此,這裏將獲取讀鎖的代碼做了刪減,保留必要的部分,如代碼所示:

protected final int tryAcquireShared(int unused) {
	for (;;) {
		int c = getState();
		int nextc = c + (1 << 16);
		if (nextc < c)
			throw new Error("Maximum lock count exceeded");
		if (exclusiveCount(c) != 0 && owner != Thread.currentThread())
			return -1;
		if (compareAndSetState(c, nextc))
			return 1;
	}
}

在tryAcquireShared(int unused)方法中,如果其他線程已經獲取了寫鎖,則當前線程獲取讀鎖失敗,進入等待狀態。如果當前線程獲取了寫鎖或者寫鎖未被獲取,則當前線程(線程安全,依靠CAS保證)增加讀狀態,成功獲取讀鎖。
讀鎖的每次釋放(線程安全的,可能有多個讀線程同時釋放讀鎖)均減少讀狀態,減少的值(1<<16)。

3.4 鎖降級

鎖降級指的是寫鎖降級成爲讀鎖。如果當前線程擁有寫鎖,然後將其釋放,最後再獲取讀鎖,這種分段完成的過程不能稱之爲鎖降級。
鎖降級是指把持住(當前擁有的)寫鎖,再獲取到讀鎖,隨後釋放(先前擁有的)寫鎖的過程
接下來看一個鎖降級的示例。因爲數據不常變化,所以多個線程可以併發地進行數據處理,當數據變更後,如果當前線程感知到數據變化,則進行數據的準備工作,同時其他處理線程被阻塞,直到當前線程完成數據的準備工作,如代碼所示。

public void processData() {
	readLock.lock();
	if (!update) {
		// 必須先釋放讀鎖
		readLock.unlock();
		// 鎖降級從寫鎖獲取到開始
		writeLock.lock();
		try {
			if (!update) {
				// 準備數據的流程(略)
				update = true;
			}
			readLock.lock();
		} finally {
			writeLock.unlock();
		}
		// 鎖降級完成,寫鎖降級爲讀鎖
	}
	try {
		// 使用數據的流程(略)
	} finally {
		readLock.unlock();
	}
}

上述示例中,當數據發生變更後,update變量(布爾類型且volatile修飾)被設置爲false,此時所有訪問processData()方法的線程都能夠感知到變化,但只有一個線程能夠獲取到寫鎖,其他線程會被阻塞在讀鎖和寫鎖的lock()方法上。當前線程獲取寫鎖完成數據準備之後,再獲取讀鎖,隨後釋放寫鎖,完成鎖降級。
鎖降級中讀鎖的獲取是否必要呢?答案是必要的。主要是爲了保證數據的可見性,如果
當前線程不獲取讀鎖而是直接釋放寫鎖,假設此刻另一個線程(記作線程T)獲取了寫鎖並修
改了數據,那麼當前線程無法感知線程T的數據更新。如果當前線程獲取讀鎖,即遵循鎖降級
的步驟,則線程T將會被阻塞,直到當前線程使用數據並釋放讀鎖之後,線程T才能獲取寫鎖進
行數據更新。
RentrantReadWriteLock不支持鎖升級(把持讀鎖、獲取寫鎖,最後釋放讀鎖的過程)。目的
也是保證數據可見性,如果讀鎖已被多個線程獲取,其中任意線程成功獲取了寫鎖並更新了
數據,則其更新對其他獲取到讀鎖的線程是不可見的。

4 總結

  • 讀寫鎖內部定義兩個鎖:讀鎖(共享式)、寫鎖(獨佔式)。
  • 理論上每個線程都可以獲取到讀鎖。
  • 同一時刻只有一個線程才能獲取到寫鎖,當寫鎖被持有時,所有的讀鎖、寫鎖都會被阻塞
  • 讀鎖、寫鎖狀態被一個int變量修試,一個int是32位,高16位表示讀,低16位表示寫,通過位運算確定讀和寫的各自狀態。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章