Java中的Lock和ReadWriteLock原理淺析

前言

Java除了提供synchronized關鍵字來實現線程同步,還提供了一些鎖相關的類來實現線程同步。Lock和ReadWriteLock就是兩個鎖的根接口,使用Lock來實現線程同步,比使用synchronized關鍵字更加靈活,程序員們有更多的可操作空間。在此之前,我們先扒一扒鎖的可重入性。

鎖的可重入性

synchronized同步塊是可重入的,我們平時好像很難見到不可重入的鎖。我覺得要想說清楚啥叫可重入、啥叫不可重入還是上個代碼比較直觀:

synchronized(Test1.class) {
	System.out.println("第一次獲取鎖");
	synchronized(Test1.class) {
		System.out.println("第二次獲取鎖");
	}
}

運行上面代碼,輸出:
第一次獲取鎖
第二次獲取鎖

這就證明synchronized關鍵字是可重入的,如果不是,程序會一直阻塞在第二個同步代碼塊獲取監視器Test1.class那裏,而不會進入第二個同步代碼塊。
要想知道啥是不可重入鎖,我估計得自己實現一個,如下:

package lihao.thread;

import java.util.concurrent.TimeUnit;

public class MyFirstLock {
	
	private volatile boolean isLocked = false;
	
	public synchronized void lock() throws InterruptedException{
		while(isLocked){
			wait();
		}
		isLocked = true;
	}

	public synchronized void unlock(){
		isLocked = false;
		notify();
	}
	
	public static void main(String[] args) {
		MyFirstLock lock = new MyFirstLock();
		Runnable task = new Runnable() {
			@Override
			public void run() {
				try {
					lock.lock();
				} catch (InterruptedException e1) {
					e1.printStackTrace();
				}
				String threadName = Thread.currentThread().getName();
				System.out.println(threadName + "開始執行任務");
				try {
					TimeUnit.SECONDS.sleep(3);
				} catch (InterruptedException e) {
					e.printStackTrace();
				}
				System.out.println(threadName + "執行任務結束");
				lock.unlock();
			}
		};
		new Thread(task, "A").start();
		new Thread(task, "B").start();
	}

}

如上,我們實現了一個鎖,並且運行了一下主函數證明它是有用的。接下來看它是不是可重入的,我們把主函數改成這樣:

public static void main(String[] args) {
	MyFirstLock lock = new MyFirstLock();
	Runnable task = new Runnable() {
		@Override
		public void run() {
			try {
				lock.lock();
			} catch (InterruptedException e1) {
				e1.printStackTrace();
			}
			System.out.println("第一次加鎖");
			
				try {
					lock.lock();
				} catch (InterruptedException e1) {
					e1.printStackTrace();
				}
				System.out.println("第二次加鎖");
				lock.unlock();
			
			lock.unlock();
		}
	};
	new Thread(task, "A").start();
}

改完後運行主函數,發現只打印了“第一次加鎖”,然後程序就阻塞了,這種現象就叫做鎖不可重入。
當前的判斷條件是隻有當isLocked爲false時lock操作才被允許,而沒有考慮是哪個線程鎖住了它。爲了讓這個Lock類具有可重入性,我們需要對它做一點小的改動:

private volatile boolean isLocked = false;
	
private Thread lockedBy = null;

private int lockedCount = 0;

public synchronized void lock() throws InterruptedException{
	Thread callingThread = Thread.currentThread();
	while(isLocked && lockedBy != callingThread){
		wait();
	}
	isLocked = true;
	lockedCount++;
	lockedBy = callingThread;
}

public synchronized void unlock(){
	if (Thread.currentThread() == lockedBy) {
		lockedCount--;
		System.out.println(lockedCount);
		if (lockedCount == 0) {
			isLocked = false;
			lockedBy = null;
			notify();
		}
	}
}

現在,MyFirstLock是可重入鎖了。

Lock接口

該接口位於java.util.concurrent.locks包下,它有一個官方實現類叫ReentrantLock,顧名思義是一個可重入鎖。Lock接口有一個比較有特色的的方法叫tryLock(),它有一個boolean類型的返回值,true代表加鎖成功,並且tryLock還可以設置超時時間。使用tryLock()可以避免在加鎖時等待時間過長,尤其是在併發量比較高時,給tryLock設置合理的超時時間,可以避免大量線程阻塞:

boolean lockRsp = false;
try {
	lockRsp = lock.tryLock(2, TimeUnit.SECONDS);
} catch (InterruptedException e) {
	e.printStackTrace();
}
if (!lockRsp) {
	return;
}

有時如果每個線程都使用同一把鎖加鎖,會導致效率非常低下,因爲如果鎖已經被某個線程獲取到了,其他線程只能等待鎖釋放。在某些場景下,我們可以對此進行一些優化。假設我有一個系統,系統裏每個用戶只操作自己的資源,也就是說用戶之間不存在競態條件,但是同一個用戶的請求會產生競態條件,這時可以給每個用戶分配一把鎖,而不是所有用戶使用同一把鎖,這樣用戶之間就可以互不影響:

private ConcurrentHashMap<Long, ReentrantLock> map = new ConcurrentHashMap<>();
	
public void doSomeThing(Long userId) {
	ReentrantLock newLock = new ReentrantLock();
	//putIfAbsent,如果key存在就返回原來的value,不會覆蓋原來的值;
	//如果key不存在就放入新的鍵值對,返回值是null
    ReentrantLock oldLock = map.putIfAbsent(userId, newLock);
     if (oldLock == null) {
         newLock.lock();
     } else {
         oldLock.lock();
     }

     try {
         //業務邏輯
     } finally {
         ReentrantLock lockInUse = map.get(userId);
         if (lockInUse != null && lockInUse.isHeldByCurrentThread()) {
             lockInUse.unlock();
         }
     }
}

不過這樣寫的前提是:執行業務邏輯消耗的時間預計比ConcurrentHashMap的putIfAbsent()消耗的時間要長,否則根據用戶來分段加鎖也沒啥意義,因爲ConcurrentHashMap本身也是線程安全的,所以也涉及到線程同步的問題,雖然它效率很高。

ReadWriteLock接口

假設你的程序中涉及到對一些共享資源的讀和寫操作,且寫操作沒有讀操作那麼頻繁。在沒有寫操作的時候,兩個線程同時讀一個資源沒有任何問題,所以應該允許多個線程能在同時讀取共享資源。但是如果有一個線程想去寫這些共享資源,就不應該再有其它線程對該資源進行讀或寫。這就需要一個讀/寫鎖來解決這個問題。
ReadWriteLock也是java.util.concurrent.locks包下的一個接口,他跟Lock接口沒啥直接關係。讀寫鎖比較複雜,所以這裏要扒一扒它的原理。

思路以及簡單實現

先讓我們對讀寫訪問資源的條件做個概述:
讀取 :沒有線程正在做寫操作,且沒有線程在請求寫操作。
寫入 :沒有線程正在做讀寫操作。

如果某個線程想要讀取資源,只要沒有線程正在對該資源進行寫操作且沒有線程請求對該資源的寫操作即可。我們假設對寫操作的請求比對讀操作的請求更重要,就要提升寫請求的優先級。此外,如果讀操作發生的比較頻繁,我們又沒有提升寫操作的優先級,那麼就會產生“飢餓”現象。請求寫操作的線程會一直阻塞,直到所有的讀線程都從ReadWriteLock上解鎖了。如果一直保證新線程的讀操作權限,那麼等待寫操作的線程就會一直阻塞下去,結果就是發生“飢餓”。因此,只有當沒有線程正在鎖住ReadWriteLock進行寫操作,且沒有線程請求該鎖準備執行寫操作時,才能保證讀操作繼續。

當其它線程沒有對共享資源進行讀操作或者寫操作時,某個線程就有可能獲得該共享資源的寫鎖,進而對共享資源進行寫操作。有多少線程請求了寫鎖以及以何種順序請求寫鎖並不重要,除非你想保證寫鎖請求的公平性。
根據上面的敘述,我們可以先實現一個簡單的讀寫鎖:

package lihao.thread;

public class MyReadWriteLock {
	
	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() throws InterruptedException{
        writers--;
        notifyAll();
    }
}

需要注意的是,在兩個釋放鎖的方法(unlockRead,unlockWrite)中,都調用了notifyAll方法,而不是notify。要解釋這個原因,我們可以想象下面一種情形:

如果有線程在等待獲取讀鎖,同時又有線程在等待獲取寫鎖。如果這時其中一個等待讀鎖的線程被notify方法喚醒,但因爲此時仍有請求寫鎖的線程存在(writeRequests>0),所以被喚醒的線程會再次進入阻塞狀態。然而,等待寫鎖的線程一個也沒被喚醒,就像什麼也沒發生過一樣(信號丟失現象)。如果用的是notifyAll方法,所有的線程都會被喚醒,然後判斷能否獲得其請求的鎖。

用notifyAll還有一個好處。如果有多個讀線程在等待讀鎖且沒有線程在等待寫鎖時,調用unlockWrite()後,所有等待讀鎖的線程都能立馬成功獲取讀鎖 , 而不是一次只允許一個。

讀/寫鎖的重入

上面實現的讀/寫鎖(ReadWriteLock) 是不可重入的,當一個已經持有寫鎖的線程再次請求寫鎖時,就會被阻塞。原因是已經有一個寫線程了——就是它自己。此外,考慮下面的例子:
1.Thread 1 獲得了讀鎖
2.Thread 2 請求寫鎖,但因爲Thread 1 持有了讀鎖,所以寫鎖請求被阻塞。
3.Thread 1 再想請求一次讀鎖,但因爲Thread 2處於請求寫鎖的狀態,所以想再次獲取讀鎖也會被阻塞。

上面這種情形使用前面的ReadWriteLock就會被鎖定——一種類似於死鎖的情形。不會再有線程能夠成功獲取讀鎖或寫鎖了。
爲了讓ReadWriteLock可重入,需要對它做一些改進。下面會分別處理讀鎖的重入和寫鎖的重入。

讀鎖重入

爲了讓ReadWriteLock的讀鎖可重入,我們要先爲讀鎖重入建立規則:
要保證某個線程中的讀鎖可重入,要麼滿足獲取讀鎖的條件(沒有寫或寫請求),要麼已經持有讀鎖(不管是否有寫請求)。

要確定一個線程是否已經持有讀鎖,可以用一個map來存儲已經持有讀鎖的線程以及對應線程獲取讀鎖的次數,當需要判斷某個線程能否獲得讀鎖時,就利用map中存儲的數據進行判斷。下面是方法lockRead和unlockRead修改後的的代碼:

package lihao.thread;

import java.util.HashMap;
import java.util.Map;

public class MyReadWriteLock {
	
	private Map<Thread, Integer> readingThreads = new HashMap<>();
	
	private int writers = 0;
	
	private int writeRequests = 0;
	
	public synchronized void lockRead() throws InterruptedException {
		Thread callingThread = Thread.currentThread();
		while (canGrantReadAccess(callingThread)) {
			wait();
		}
		readingThreads.put(callingThread, getReadAccessCount(callingThread) + 1);
	}
	
	private boolean canGrantReadAccess(Thread callingThread) {
		if (writers > 0) 
			return false;
		if (isReader(callingThread))
			return true;
		if (writeRequests > 0)
			return false;
		return true;
	}
	
	private boolean isReader(Thread callingThread) {
		return readingThreads.get(callingThread) != null;
	}
	
	private int getReadAccessCount(Thread callingThread) {
		Integer count = readingThreads.get(callingThread);
		return count == null ? 0 : count.intValue();
	}
	
	public synchronized void unlockRead() {
		Thread callingThread = Thread.currentThread();
		int accessedCount = getReadAccessCount(callingThread);
		if (accessedCount != 0) {
			if (accessedCount == 1) {
				readingThreads.remove(callingThread);
				notifyAll();
			} else {
				readingThreads.put(callingThread, (accessedCount - 1));
			}
		}
	}
}

代碼中我們可以看到,只有在沒有線程擁有寫鎖的情況下才允許讀鎖的重入。此外,重入的讀鎖比寫鎖優先級高。

寫鎖重入

僅當一個線程已經持有寫鎖,才允許寫鎖重入(再次獲得寫鎖)。下面是方法lockWrite和unlockWrite修改後的的代碼:

package lihao.thread;

import java.util.HashMap;
import java.util.Map;

public class MyReadWriteLock {
	
	private Map<Thread, Integer> readingThreads = new HashMap<>();
	
	private int writeAccess = 0;
	
	private int writeRequests = 0;
	
	private Thread writingThread = null;
	
	public synchronized void lockRead() throws InterruptedException {
		Thread callingThread = Thread.currentThread();
		while (canGrantReadAccess(callingThread)) {
			wait();
		}
		readingThreads.put(callingThread, getReadAccessCount(callingThread) + 1);
	}
	
	private boolean canGrantReadAccess(Thread callingThread) {
		if (writeAccess > 0) 
			return false;
		if (isReader(callingThread))
			return true;
		if (writeRequests > 0)
			return false;
		return true;
	}
	
	private boolean isReader(Thread callingThread) {
		return readingThreads.get(callingThread) != null;
	}
	
	private int getReadAccessCount(Thread callingThread) {
		Integer count = readingThreads.get(callingThread);
		return count == null ? 0 : count.intValue();
	}
	
	public synchronized void unlockRead() {
		Thread callingThread = Thread.currentThread();
		int accessedCount = getReadAccessCount(callingThread);
		if (accessedCount != 0) {
			if (accessedCount == 1) {
				readingThreads.remove(callingThread);
			} else {
				readingThreads.put(callingThread, (accessedCount - 1));
			}
			notifyAll();
		}
	}
	
	public synchronized void lockWrite() throws InterruptedException{
		writeRequests++;
		Thread callingThread = Thread.currentThread();
        while(!canGrantWriteAccess(callingThread)){
            wait();
        }
        writeRequests--;
        writeAccess++;
        writingThread = callingThread;
	}
	
	private boolean canGrantWriteAccess(Thread callingThread) {
		if (haveReaders())
			return false;
		if (writingThread == null)
			return true;
		if (!isWriter(callingThread)) 
			return false;
		return true;
	}
	
	private boolean haveReaders() {
		return readingThreads.size() > 0;
	}
	
	private boolean isWriter(Thread callingThread) {
		return writingThread == callingThread;
	}
	
    public synchronized void unlockWrite() throws InterruptedException{
    	if (writeAccess > 0) {
    		writeAccess--;
    		if (writeAccess == 0) {
    			writingThread = null;
    		}
    		notifyAll();
    	}
    }
}

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