Java併發(7)- 你真的瞭解 ReentrantReadWriteLock 嗎?

knock_小新發表於2018-09-17

引言

在前幾篇文章中瞭解了ReentrantLock、Semaphore與CountDownLatch後,J.U.C包中基於AQS實現的併發工具類還剩一個比較重要的:讀寫鎖ReentrantReadWriteLock。讀寫鎖在Java面試過程中是一個經常性考的題目,他涉及到的知識點比較多,導致很多人不能透徹的理解他。舉幾個面試中常見的問題:

  • ReentrantReadWriteLock和ReentrantLock的區別?
  • 你能用ReentrantReadWriteLock實現一個簡單的快取管理嗎?
  • 你能自己實現一個簡單的讀寫鎖嗎?
  • ReentrantReadWriteLock會發生寫飢餓的情況嗎?如果發生,有沒有比較好的解決辦法?

上面的問題大家都能回答出來嗎?如果都能很好的回答,那麼這篇文章也許對你沒有太大幫助。如果不能,彆著急,下面就來一一分析解答上面的幾個問題。

1. ReentrantReadWriteLock和ReentrantLock的區別?

這個問題大家應該都會回答:ReentrantLock是獨佔鎖,ReentrantReadWriteLock是讀寫鎖。那麼這就引申出下一個問題:ReentrantReadWriteLock中的讀鎖和寫鎖分別是獨佔還是共享的?他們之間的關係又是什麼樣的?要了解透徹這兩個問題,分析原始碼是再好不過的了。

1.1 ReentrantReadWriteLock中的讀鎖和寫鎖的實現方式

使用ReentrantReadWriteLock讀寫鎖的方式,會呼叫readLock()和writeLock()兩個方法,看下他們的原始碼:

public ReentrantReadWriteLock.WriteLock writeLock() { return writerLock; }
public ReentrantReadWriteLock.ReadLock  readLock()  { return readerLock; }
複製程式碼

可以看到用到了WriteLock和ReadLock兩個靜態內部類,他們對鎖的實現如下:

public static class ReadLock implements Lock, java.io.Serializable {
    public void lock() {
        sync.acquireShared(1); //共享
    }

    public void unlock() {
        sync.releaseShared(1); //共享
    }
}

public static class WriteLock implements Lock, java.io.Serializable {
    public void lock() {
        sync.acquire(1); //獨佔
    }

    public void unlock() {
        sync.release(1); //獨佔
    }
}

abstract static class Sync extends AbstractQueuedSynchronizer {}
複製程式碼

看到這裡發現了ReentrantReadWriteLock和ReentrantLock的一個相同點和不同點,相同的是使用了同一個關鍵實現AbstractQueuedSynchronizer,不同的是ReentrantReadWriteLock使用了兩個鎖分別實現了AQS,而且WriteLock和ReentrantLock一樣,使用了獨佔鎖。而ReadLock和Semaphore一樣,使用了共享鎖。再往下的內容估計看過前面幾篇文章的都很熟悉了,獨佔鎖通過state變數的0和1兩個狀態來控制是否有執行緒佔有鎖,共享鎖通過state變數0或者非0來控制多個執行緒訪問。在上面的程式碼中,ReadLock和WriteLock使用了同一個AQS,那麼在ReentrantReadWriteLock中又是怎麼控制讀鎖和寫鎖關係的呢?

1.2 ReadLock和WriteLock共享變數

讀寫鎖定義為:一個資源能夠被多個讀執行緒訪問,或者被一個寫執行緒訪問,但是不能同時存在讀寫執行緒。

通過這句話很容易聯想到通過兩個不同的變數來控制讀寫,當獲取到讀鎖時對讀變數+1,當獲取懂啊寫變數時對寫變數+1。但AQS中並沒有為ReadLock和WriteLock新增額外的變數,還是通過一個state來實現的。那是怎麼做到讀寫分離的呢?來看看下面這段程式碼:1。但AQS中並沒有為ReadLock和WriteLock新增額外的變數,還是通過一個state來實現的。那是怎麼做到讀寫分離的呢?來看看下面這段程式碼:

static final int SHARED_SHIFT   = 16;
static final int SHARED_UNIT    = (1 << SHARED_SHIFT);
static final int MAX_COUNT      = (1 << SHARED_SHIFT) - 1;
static final int EXCLUSIVE_MASK = (1 << SHARED_SHIFT) - 1;

/** Returns the number of shared holds represented in count  */
static int sharedCount(int c)    { return c >>> SHARED_SHIFT; }
/** Returns the number of exclusive holds represented in count  */
static int exclusiveCount(int c) { return c & EXCLUSIVE_MASK; }
複製程式碼

這段程式碼在Sync靜態內部類中,這裡有兩個關鍵方法sharedCount和exclusiveCount,通過名字可以看出sharedCount是共享鎖的數量,exclusiveCount是獨佔鎖的數量。共享鎖通過對c像右位移16位獲得,獨佔鎖通過和16位的1與運算獲得。舉個例子,當獲取讀鎖的執行緒有3個,寫鎖的執行緒有1個(當然這是不可能同時有的),state就表示為0000 0000 0000 0011 0000 0000 0000 0001,高16位代表讀鎖,通過向右位移16位(c >>> SHARED_SHIFT)得倒10進位制的3,通過和0000 0000 0000 0000 1111 1111 1111 1111與運算(c & EXCLUSIVE_MASK),獲得10進位制的1。弄懂了著幾個方法,就明白了為什麼通過一個state實現了讀寫共享。

這當中還有一個問題,由於16位最大全1表示為65535,所以讀鎖和寫鎖最多可以獲取65535個。

1.3 WriteLock和ReentrantLock獲取鎖的區別

上面說過,WriteLock也是獨佔鎖,那麼他和ReentrantLock有什麼區別呢?最大的區別就在獲取鎖時WriteLock不僅需要考慮是否有其他寫鎖佔用,同時還要考慮是否有其他讀鎖,而ReentrantLock只需要考慮自身是否被佔用就行了。來看下WriteLock獲取鎖的原始碼:

public void lock() {
    sync.acquire(1);
}

public final void acquire(int arg) {
    if (!tryAcquire(arg) && //嘗試獲取獨佔鎖
        acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) //獲取失敗後排隊
        selfInterrupt();
}

protected final boolean tryAcquire(int acquires) {

    Thread current = Thread.currentThread();
    int c = getState();  //獲取共享變數state
    int w = exclusiveCount(c); //獲取寫鎖數量
    if (c != 0) { //有讀鎖或者寫鎖
        // (Note: if c != 0 and w == 0 then shared count != 0)
        if (w == 0 || current != getExclusiveOwnerThread()) //寫鎖為0(證明有讀鎖),或者持有寫鎖的執行緒不為當前執行緒
            return false;
        if (w + exclusiveCount(acquires) > MAX_COUNT)
            throw new Error("Maximum lock count exceeded");
        // Reentrant acquire
        setState(c + acquires);  //當前執行緒持有寫鎖,為重入鎖,+acquires即可
        return true;
    }
    if (writerShouldBlock() ||
        !compareAndSetState(c, c + acquires)) //CAS操作失敗,多執行緒情況下被搶佔,獲取鎖失敗。CAS成功則獲取鎖成功
        return false;
    setExclusiveOwnerThread(current);
    return true;
}
複製程式碼

這段程式碼是不是很熟悉?和ReentrantLock中獲取鎖的程式碼很相似,差別在於其中呼叫了exclusiveCount方法來獲取是否存在寫鎖,然後通過c != 0和w == 0判斷了是否存在讀鎖。acquireQueued和addWaiter就不詳細解說了,需要了解的可以檢視前面ReentrantLock的文章。 到這裡大家應該對ReentrantReadWriteLock和ReentrantLock的區別應該做到心裡有數了吧。

1.4 ReadLock和Semaphore獲取鎖的區別

WriteLock是獨佔模式,我們比較了它和ReentrantLock獨佔鎖獲取鎖的區別,這裡我們再看看ReadLock在獲取鎖上有什麼不同呢?先看下面的原始碼:

protected final int tryAcquireShared(int unused) {

	Thread current = Thread.currentThread();
	int c = getState();
	if (exclusiveCount(c) != 0 &&
		getExclusiveOwnerThread() != current) //寫鎖不等於0的情況下,驗證是否是當前寫鎖嘗試獲取讀鎖
		return -1;
	int r = sharedCount(c);  //獲取讀鎖數量
	if (!readerShouldBlock() && //讀鎖不需要阻塞
		r < MAX_COUNT &&  //讀鎖小於最大讀鎖數量
		compareAndSetState(c, c + SHARED_UNIT)) { //CAS操作嘗試設定獲取讀鎖 也就是高位加1
		if (r == 0) {  //當前執行緒第一個並且第一次獲取讀鎖,
			firstReader = current;
			firstReaderHoldCount = 1;
		} else if (firstReader == current) { //當前執行緒是第一次獲取讀鎖的執行緒
			firstReaderHoldCount++;
		} else { // 當前執行緒不是第一個獲取讀鎖的執行緒,放入執行緒本地變數
			HoldCounter rh = cachedHoldCounter;
			if (rh == null || rh.tid != getThreadId(current))
				cachedHoldCounter = rh = readHolds.get();
			else if (rh.count == 0)
				readHolds.set(rh);
			rh.count++;
		}
		return 1;
	}
	return fullTryAcquireShared(current);
}ß
複製程式碼

在上面的程式碼中嘗試獲取讀鎖的過程和獲取寫鎖的過程也很相似,不同在於讀鎖只要沒有寫鎖佔用並且不超過最大獲取數量都可以嘗試獲取讀鎖,而寫鎖不僅需要考慮讀鎖是否佔用,也要考慮寫鎖是否佔用。上面的程式碼中firstReader,firstReaderHoldCount以及cachedHoldCounter都是為readHolds(ThreadLocalHoldCounter)服務的,用來記錄每個讀鎖獲取執行緒的獲取次數,方便獲取當前執行緒持有鎖的次數資訊。在ThreadLocal基礎上新增了一個Int變數來統計次數,可以通過他們的實現來理解:

static final class ThreadLocalHoldCounter
	extends ThreadLocal<HoldCounter> { //ThreadLocal變數ß
	public HoldCounter initialValue() {
		return new HoldCounter();
	}
}

static final class HoldCounter {
	int count = 0;           //當前執行緒持有鎖的次數
	// Use id, not reference, to avoid garbage retention
	final long tid = getThreadId(Thread.currentThread());   //當前執行緒ID
}
複製程式碼

2. 你能用ReentrantReadWriteLock實現一個簡單的快取嗎?

先來分析一個簡單快取需要滿足的功能,這裡我們為了實現簡單,不考慮快取過期策略等複雜因素。

  • 快取主要提供兩個功能:讀和寫。
  • 讀時如果快取中存在資料,則立即返回資料。
  • 讀時如果快取中不存在資料,則需要從其他途徑獲取資料,同時寫入快取。
  • 在寫入快取的同時,為了避免其他執行緒同時獲取這個快取中不存在的資料,需要阻塞其他讀執行緒。 下面我們就來通過ReentrantReadWriteLock實現上述功能:
public static void ReentrantReadWriteLockCacheSystem() {

	//這裡為了實現簡單,將快取大小設定為4。
	Map<String, String> cacheMap = new HashMap<>(4); 
	ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();

	for (int i = 0; i < 20; i++) { //同時開啟20個執行緒訪問快取

		final String key = String.valueOf(i % 4); 
		Thread thread = new Thread(new Runnable() {

			@Override
			public void run() {
				try {
					//①讀取快取時獲取讀鎖
					readWriteLock.readLock().lock();  
					//獲取讀鎖後通過key獲取快取中的值
					String valueStr = cacheMap.get(key); 
					//快取值不存在
					if (valueStr == null) { 
						//③釋放讀鎖後再嘗試獲取寫鎖
						readWriteLock.readLock().unlock();  
						try {
							//④獲取寫鎖來寫入不存在的key值,
							readWriteLock.writeLock().lock();  
							valueStr = cacheMap.get(key);
							if (valueStr == null) {
								valueStr = key + " --- value";
								cacheMap.put(key, valueStr); //寫入值
								System.out.println(Thread.currentThread().getName() + " --------- put " + valueStr);
							}
							// ⑥鎖降級,避免被其他寫執行緒搶佔後再次更新值,保證這一次操作的原子性
							readWriteLock.readLock().lock(); 
							System.out.println(Thread.currentThread().getName() + " --------- get new " + valueStr);
						} finally {
							readWriteLock.writeLock().unlock(); //⑤釋放寫鎖
						}
						
					} else {
						System.out.println(Thread.currentThread().getName() + " ------ get cache value");
					}
				} finally {
					readWriteLock.readLock().unlock();  //②釋放讀鎖
				}
			}
		}, String.valueOf(i));
		thread.start();
	}
}
複製程式碼

首先執行緒會嘗試去獲取資料,需要獲取讀鎖①,如果存在值,則直接讀取並釋放讀鎖②。如果不存在值,則首先釋放已經獲取的讀鎖③,然後嘗試獲取寫鎖④。獲取到寫鎖之後,再次檢查值,因為此時可能存在其他寫鎖已經更新值,這時只需要讀取,然後釋放寫鎖⑤。如果還是沒有值,則通過其他途徑獲取值並更新然後獲取讀鎖⑥,這一步鎖降級操作是為了直接搶佔讀鎖,避免釋放寫鎖之後再次獲取讀鎖時被其他寫執行緒搶佔,這樣保證了這一次讀取資料的原子性。之後再執行⑤釋放寫鎖和②釋放讀鎖。

執行後輸出結果如下,每次執行可能輸出不同:

//1 --------- put 1 --- value
//1 --------- get new 1 --- value
//0 --------- put 0 --- value
//0 --------- get new 0 --- value
//9 ------ get cache value
//4 ------ get cache value
//2 --------- put 2 --- value
//2 --------- get new 2 --- value
//11 --------- put 3 --- value
//11 --------- get new 3 --- value
//5 ------ get cache value
//13 ------ get cache value
//6 ------ get cache value
//8 ------ get cache value
//7 ------ get cache value
//3 --------- get new 3 --- value
//10 ------ get cache value
//12 ------ get cache value
//14 ------ get cache value
//15 ------ get cache value
//16 ------ get cache value
//17 ------ get cache value
//18 ------ get cache value
//19 ------ get cache value
複製程式碼

3. 你能自己實現一個簡單的讀寫鎖嗎?

經過上面對讀寫鎖原理的初步分析和使用,現在你能自己實現一個簡單的讀寫鎖了嗎?這裡列出了一步步實現一個簡單的讀寫鎖的過程,你可以按這個步驟自己先實現一遍。

  • 1 定義一個讀寫鎖共享變數state
  • 2 state高16位為讀鎖數量,低16位為寫鎖數量。儘量模擬ReentrantReadWriteLock的實現
  • 3 獲取讀鎖時先判斷是否有寫鎖,有則等待,沒有則將讀鎖數量加1
  • 4 釋放讀鎖數量減1,通知所有等待執行緒
  • 5 獲取寫鎖時需要判斷讀鎖和寫鎖是否都存在,有則等待,沒有則將寫鎖數量加1
  • 6 釋放寫鎖數量減1,通知所有等待執行緒 我給出的實現程式碼如下:
public class MyReadWriteLock {

	private int state = 0; //1. 定義一個讀寫鎖共享變數state
	
	//2. state高16位為讀鎖數量
	private int GetReadCount() { 
		return state >>> 16;
	}
	
	//2. 低16位為寫鎖數量
	private int GetWriteCount() { 
		return state & ((1 << 16) - 1);
	}
	
	//3. 獲取讀鎖時先判斷是否有寫鎖,有則等待,沒有則將讀鎖數量加1
	public synchronized void lockRead() throws InterruptedException{  
		
		while (GetWriteCount() > 0) {
			wait();
		}
		
		System.out.println("lockRead ---" + Thread.currentThread().getName()); 
		state = state + (1 << 16);
	}
	
	//4. 釋放讀鎖數量減1,通知所有等待執行緒
	public synchronized void unlockRead() {  
		state = state - ((1 << 16));
		notifyAll();
	}
	
	//5. 獲取寫鎖時需要判斷讀鎖和寫鎖是否都存在,有則等待,沒有則將寫鎖數量加1
	public synchronized void lockWriters() throws InterruptedException{  
		
		while (GetReadCount() > 0 || GetWriteCount() > 0) {
			wait();
		}
		System.out.println("lockWriters ---" + Thread.currentThread().getName());
		state++;
	}
	
	//6. 釋放寫鎖數量減1,通知所有等待執行緒
	public synchronized void unlockWriters(){  
		
		state--;
		notifyAll();
	}
}
複製程式碼

4. 讀寫鎖會發生寫飢餓的情況嗎?如果發生,有沒有比較好的解決辦法?

在讀寫的過程中,寫操作一般是優先的,不能因為讀操作過多,寫操作一直等待的狀況發生,這樣會導致資料一直得不到更新,發生寫飢餓。現在大家思考一下上面我們實現的簡單讀寫鎖,是否能做到這點呢?答案很明顯,在讀寫執行緒都wait的情況下,通過notifyAll並不能保證寫優先執行。那在這個例子中怎麼改進這一點呢?

這裡我通過新增一箇中間變數來達到這個目的,這個中間變數在獲取寫鎖之前先記錄一個寫請求,這樣一旦notifyAll,優先檢查是否有寫請求,如果有,則讓寫操作優先執行,具體程式碼如下:

public class MyReadWriteLock {

	private int state = 0; //1. 定義一個讀寫鎖共享變數state
	private int writeRequest = 0; //記錄寫請求數量
	
	//2. state高16位為讀鎖數量
	private int GetReadCount() { 
		return state >>> 16;
	}
	
	//2. 低16位為寫鎖數量
	private int GetWriteCount() { 
		return state & ((1 << 16) - 1);
	}
	
	//3. 獲取讀鎖時先判斷是否有寫鎖,有則等待,沒有則將讀鎖數量加1
	public synchronized void lockRead() throws InterruptedException{  
		//寫鎖數量大於0或者寫請求數量大於0的情況下都優先執行寫
		while (GetWriteCount() > 0 || writeRequest > 0) { 
			wait();
		}
		
		System.out.println("lockRead ---" + Thread.currentThread().getName()); 
		state = state + (1 << 16);
	}
	
	//4. 釋放讀鎖數量減1,通知所有等待執行緒
	public synchronized void unlockRead() {  
		state = state - ((1 << 16));
		notifyAll();
	}
	
	//5. 獲取寫鎖時需要判斷讀鎖和寫鎖是否都存在,有則等待,沒有則將寫鎖數量加1
	public synchronized void lockWriters() throws InterruptedException{  
		
		writeRequest++; //寫請求+1
		while (GetReadCount() > 0 || GetWriteCount() > 0) {
			wait();
		}
		writeRequest--; //獲取寫鎖後寫請求-1
		System.out.println("lockWriters ---" + Thread.currentThread().getName());
		state++;
	}
	
	//6. 釋放寫鎖數量減1,通知所有等待執行緒
	public synchronized void unlockWriters(){  
		
		state--;
		notifyAll();
	}
}
複製程式碼

大家可以測試下上面的程式碼看看是不是寫請求都優先執行了呢?現在我們把這個問題放到ReentrantReadWriteLock中來考慮,顯而易見,ReentrantReadWriteLock也會發生寫請求飢餓的情況,因為寫請求一樣會排隊,不管是公平鎖還是非公平鎖,在有讀鎖的情況下,都不能保證寫鎖一定能獲取到,這樣只要讀鎖一直佔用,就會發生寫飢餓的情況。那麼JDK就沒有提供什麼好辦法來解決這個問題嗎?當然是有的,那就是JDK8中新增的改進讀寫鎖---StampedLock,在下一篇文章中將會對StampedLock進行詳細講解。

相關文章