Java開發筆記(一百零一)經過加解鎖避免資源衝突

前面介紹瞭如何經過線程同步來避免多線程併發的資源衝突問題,然而添加synchronized的方式只在簡單場合夠用,在一些高級場合就暴露出它的侷限性,包括但不限於下列幾點:
一、synchronized必須用於修飾方法或者代碼塊,也就是必定會有花括號把須要同步的代碼給包裹起來。這樣的話,花括號內外的變量交互比較麻煩,特別是同步代碼塊,多出來的花括號硬生生把原來的代碼隔離開,只好經過局部變量來傳遞數值。
二、synchronized的同步方式很傻,一旦同步方法/代碼塊被某個線程執行,其它線程到了這裏就必須等待前個線程的處理,要是前個線程遲遲不退出同步方法/代碼塊,那麼其它線程只能傻傻的一直等下去。
三、synchronized沒法判斷當前線程處於等待隊列中的哪一個位置,等待隊列要是很長的話,也許走另一條分支更合適,但synchronized是個死腦筋,它不知道等待隊列的詳細狀況,也就無從選擇更優的代碼路徑。
爲此Java又設計了一套鎖機制,經過鎖的對象把加鎖和解鎖操做分離開,從而解決同步方式的弊端。鎖機制提供了好幾把鎖,最多見的名叫可重入鎖ReentrantLock,所謂可重入,字面意思指的是支持從新進入,凡是遇到被當前線程自身鎖住的代碼,則仍然容許進入這塊代碼;但要是遇到被其它線程鎖住的代碼,則不容許進入那塊代碼。換句話說,加鎖不是爲了鎖本身,加鎖是爲了鎖別人,故而可重入鎖又稱做自旋鎖,以前介紹的synchronized也屬於可重入機制。下面是ReentrantLock相關的鎖方法說明:
lock:對可重入鎖加鎖。
unlock:對可重入鎖解鎖。
tryLock:嘗試加鎖。加鎖成功返回true,加鎖失敗返回false。該方法與lock的區別在於:lock方法會一直等待加鎖,而tryLock要求馬上加鎖,要是加鎖失敗(表示以前已經被其它線程加了鎖),就立刻返回false,一會都等不了。
isLocked:判斷該鎖是否被鎖住了。
getQueueLength:獲取有多少個線程正在等待該鎖的釋放。
回到售票線程的例子,如今把同步方式改成加鎖解鎖的實現,修改後的售票代碼示例以下:html

	// 建立一個可重入鎖
	private final static ReentrantLock reentrantLock = new ReentrantLock();

	// 測試經過可重入鎖避免資源衝突
	private static void testReentrantLock() {
		Runnable seller = new Runnable() {
			private Integer ticketCount = 100; // 可出售的車票數量
			
			@Override
			public void run() {
				while (ticketCount > 0) { // 還有餘票可供出售
					reentrantLock.lock(); // 對可重入鎖加鎖
					int count = --ticketCount; // 餘票數量減一
					reentrantLock.unlock(); // 對可重入鎖解鎖
					// 如下打印售票日誌,包括售票時間、售票線程、當前餘票等信息
					String left = String.format("當前餘票爲%d張", count);
					PrintUtils.print(Thread.currentThread().getName(), left);
				}
			}
		};
		new Thread(seller, "售票線程A").start(); // 啓動售票線程A
		new Thread(seller, "售票線程B").start(); // 啓動售票線程B
		new Thread(seller, "售票線程C").start(); // 啓動售票線程C
	}

以上採用鎖機制的代碼,運行起來沒什麼問題。但是實際業務每每不會這麼簡單,好比售票員在售票前還要幫旅客挑選合適的行程,這樣又會消耗必定時間。經過編碼演示的話,可在售票以前打開某個磁盤文件,模擬售票前的準備工做。因而添加模擬代碼後的run方法變成了下面這副模樣:數組

			public void run() {
				while (ticketCount > 0) { // 還有餘票可供出售
					int count = 0;
					// 根據指定路徑構建文件輸出流對象
					try (FileOutputStream fos = new FileOutputStream(mFileName)) {
						reentrantLock.lock(); // 對可重入鎖加鎖
						count = --ticketCount; // 餘票數量減一
						reentrantLock.unlock(); // 對可重入鎖解鎖
						fos.write(new String(""+count).getBytes()); // 把字節數組寫入文件輸出流
					} catch (Exception e) {
						e.printStackTrace();
					}
					// 如下打印售票日誌,包括售票時間、售票線程、當前餘票等信息
					String left = String.format("當前餘票爲%d張", count);
					PrintUtils.print(Thread.currentThread().getName(), left);
				}
			}

 

接着運行上述的模擬代碼,在售票日誌中常常發現如下的負數餘票:多線程

………………………這裏省略前面的日誌……………………
17:12:06.568 售票線程C 當前餘票爲3張
17:12:06.569 售票線程B 當前餘票爲2張
17:12:06.569 售票線程A 當前餘票爲1張
17:12:06.570 售票線程B 當前餘票爲0張
17:12:06.570 售票線程A 當前餘票爲-1張
17:12:06.570 售票線程C 當前餘票爲-2張

 

明明每次循環以前都有判斷餘票數量要大於零,爲啥還會出現車票被賣到負數的狀況?真是咄咄怪事。原來在循環開始以後到對餘票減一之間,多了一個打開文件的步驟,正是由於文件的打開操做耗費了一點點時間,致使其它線程在這一瞬間賣掉車票,而當前線程覺得還有餘票可賣,其結果必然致使賣出了早就賣光的車票。譬如當前線程在循環開始前檢查餘票數量爲1,認爲有票可賣,因而開始給旅客選擇車票,誰知別的線程恰好在這空擋賣掉最後一張票,那麼實時的餘票數量減小到0,但是當前線程渾然不知,繼續後面的選票與售票操做,最終又賣掉了一張票,此時餘票數量刷新爲-1。顯然在每次循環開頭檢查餘票不夠保險,還得在選票以後售票以前再檢查一次,務必確保還有餘票才能進行售票操做。
鑑於檢查餘票和售出車票的性質有所不一樣,檢查餘票不會更改餘票變量,因此它屬於讀操做;而售出車票會更改餘票變量,因此它屬於寫操做。理論上能夠同時進行讀操做,但不能同時進行寫操做。更具體地說,A線程在讀的時候,B線程容許讀但不容許寫;A線程在寫的時候,B線程既不容許讀也不容許寫。據此可將鎖再細分爲讀鎖和寫鎖兩類,讀鎖與讀鎖不是互斥關係,而讀鎖與寫鎖是互斥關係,且寫鎖與寫鎖也是互斥關係。總而言之,檢查餘票這項操做適用於讀鎖,售出車票這項操做適用於寫鎖。
Java提供的讀寫鎖工具名叫ReentrantReadWriteLock,意便可重入的讀寫鎖,調用讀寫鎖對象的readLock方法可得到讀鎖對象,調用讀寫鎖對象的writeLock方法可得到寫鎖對象,以後再根據實際狀況分別對讀鎖或者寫鎖進行加鎖和解鎖操做。利用讀寫鎖優化以前的售票邏輯,主要開展如下兩點修改:
一、在售票(餘票數量減一)這一步驟的前面加上寫鎖,該步驟後面解除寫鎖。
二、售票以前補充檢查餘票的判斷語句,並在檢查步驟的前面加上讀鎖,該步驟後面解除讀鎖。
經過讀寫鎖優化修改後的完整售票代碼以下所示:併發

	// 建立一個可重入的讀寫鎖
	private final static ReentrantReadWriteLock readWriteLock = new ReentrantReadWriteLock();
	// 獲取讀寫鎖中的寫鎖
	private final static WriteLock writeLock = readWriteLock.writeLock();
	// 獲取讀寫鎖中的讀鎖
	private final static ReadLock readLock = readWriteLock.readLock();

	// 測試經過讀寫鎖避免資源衝突
	private static void testReadWriteLock() {
		Runnable seller = new Runnable() {
			private Integer ticketCount = 100; // 可出售的車票數量
			
			@Override
			public void run() {
				while (ticketCount > 0) { // 還有餘票可供出售
					int count = 0;
					// 根據指定路徑構建文件輸出流對象
					try (FileOutputStream fos = new FileOutputStream(mFileName)) {
						readLock.lock(); // 對讀鎖加鎖。加了讀鎖以後,其它線程能夠繼續加讀鎖,但不能加寫鎖
						if (ticketCount <= 0) { // 餘票數量爲0,表示已經賣光了,只好關門歇業
							fos.close(); // 關閉文件
							break; // 跳出售票的循環
						}
						readLock.unlock(); // 對讀鎖解鎖
						writeLock.lock(); // 對寫鎖加鎖。一旦加了寫鎖,則其它線程在此既不能讀也不能寫
						count = --ticketCount; // 餘票數量減一
						writeLock.unlock(); // 對寫鎖解鎖
						fos.write(new String(""+count).getBytes()); // 把字節數組寫入文件輸出流
					} catch (Exception e) {
						e.printStackTrace();
					}
					// 如下打印售票日誌,包括售票時間、售票線程、當前餘票等信息
					String left = String.format("當前餘票爲%d張", count);
					PrintUtils.print(Thread.currentThread().getName(), left);
				}
			}
		};
		new Thread(seller, "售票線程A").start(); // 啓動售票線程A
		new Thread(seller, "售票線程B").start(); // 啓動售票線程B
		new Thread(seller, "售票線程C").start(); // 啓動售票線程C
	}

運行上面的讀寫鎖售票代碼,從打印的售票日誌中再也找不到餘票爲負數的狀況了,可見讀寫鎖很好地解決了盲目售票的問題。ide

………………………這裏省略前面的日誌……………………
16:29:44.899 售票線程C 當前餘票爲3張
16:29:44.899 售票線程B 當前餘票爲2張
16:29:44.899 售票線程A 當前餘票爲1張
16:29:44.900 售票線程C 當前餘票爲0張

  

更多Java技術文章參見《Java開發筆記(序)章節目錄工具

相關文章
相關標籤/搜索