前面介紹瞭如何經過線程同步來避免多線程併發的資源衝突問題,然而添加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開發筆記(序)章節目錄》工具