Java 8:StampedLock、ReadWriteLock以及synchronized的比較
同步區有點像拜訪你的公公婆婆。你當然是希望待的時間越短越好。說到鎖的話情況也是一樣的,你希望獲取鎖以及進入臨界區域的時間越短越好,這樣才不會造成瓶頸。
對於方法和代碼塊,語言層麵的加鎖機製是synchronized關鍵字,該關鍵字是由HotSpot虛擬機內置的。我們在代碼中分配的每一個對象,如String、Array或者一個完整的JSON文檔,在本地垃圾回收級別都具有內置的加鎖能力。JIT編譯器也是類似的,它在進行字節碼的編譯和反編譯的時候,都取決於特定的某個鎖的具體的狀態和競爭級別。
同步塊的關鍵是:進入臨界區域內的線程不能超過一個 。這一點對於生產者消費者場景中來說非常糟糕,當一些線程獨占地修改某些數據時,而另外一些線程隻是希望讀取數據,這個是可以和別的線程同時進行的。
讀寫鎖(ReadWriteLock)是這種情況最好的解決方案。你指定哪些線程可以阻塞其他線程(寫線程),哪些線程可以與其他線程共享數據(讀線程)。這是一個完美的解決方案?恐怕不是。
讀寫鎖不像同步塊,它不是JVM內建的,它隻不過是段普通的代碼。為了實現加鎖的語義,它得命令CPU原子地或者以特定的順序執行操作,以避免競態條件。這通常都是通過JVM預留的一個後門來實現的——Unsafe類。讀寫鎖使用比較並交換(CAS)操作直接將值設置到內存中去,這是它們線程排隊算法中的一部分。
即便如此,讀寫鎖還是不夠快,並且有時候慢得要死,慢到你覺得就不應該使用它。然而JDK的夥計們並沒有放棄讀寫鎖,現在他們帶來了一個全新的StampedLock。StampedLock使用了一組新的算法以及Java 8 JDK中引入的內存屏障的特性,這使得這個鎖更高效也更健壯。
它兌現了自己的諾言了嗎?讓我們拭目以待。
使用鎖。從表麵上看StampedLock使用起來更複雜。它們使用了一個票據(stamp)的概念,這是一個long值,在加鎖和解鎖操作時,它被用作一張門票。這意味著要解鎖一個操作你需要傳遞相應的的門票。如果傳遞錯誤的門票,那麼可能會拋出一個異常,或者其他意想不到的錯誤。
另外一個值得關注的重要問題是,不像ReadWriteLock,StampedLocks是不可重入的。因此盡管StampedLocks可能更快,但可能產生死鎖。在實踐中,這意味著你應該始終確保鎖以及對應的門票不要逃逸出所在的代碼塊。
long stamp = lock.writeLock(); //blocking lock, returns a stamp try { write(stamp); // this is a bad move, you’re letting the stamp escape } finally { lock.unlock(stamp);// release the lock in the same block - way better }
這個設計還有個讓人無法忍受的地方就是這個long類型的票據對你而言沒有任何意義。我希望鎖操作返回的是一個描述票據的對象——包括它的類型(讀/寫)、加鎖時間、所有者線程等等。這樣處理的話更容易調試和跟蹤日誌。不過這麼做很有可能是故意的,以便阻止開發人員不要將這個戳在代碼裏傳來傳去,同時也減少了分配對象的開銷。
樂觀鎖。StampedLocks最重要的一個新功能就是它的樂觀鎖模式。研究和實踐經驗表明,讀操作是在大多數情況下不會與寫操作競爭。因此,獲取全占的讀鎖可能就如殺雞用牛刀了。一個更好的方法可能是繼續執行讀,並且結束後同時判斷該值是否被修改,如果被修改,你再進行重試,或者升級成一個更重的鎖。
long stamp = lock.tryOptimisticRead(); // non blocking read(); if(!lock.validate(stamp)){ // if a write occurred, try again with a read lock long stamp = lock.readLock(); try { read(); } finally { lock.unlock(stamp); } }
選擇一個鎖,最大的難點之一是其在生產環境中的表現會因應用狀態的不同而有所差異。也就是說你不能憑空選擇使用何種鎖,而是得將代碼執行的具體環境也考慮進來 。
並發讀寫線程的數量將決定你應該使用哪一種鎖——同步塊或者讀寫鎖。如果這些線程數在JVM的執行生命周期內發生改變的話,這個問題就更棘手了,這取決於應用的狀態以及線程的競爭級別。
為了解釋,我對四種模式下的鎖分別進行了壓力測試——在不同競爭級別和讀寫線程組合下的synchronized、讀寫鎖、StampedLock的讀寫鎖以及讀寫樂觀鎖。讀線程將讀取一個計數器的值,寫線程會將它從0增加到1M。
5個讀線程和5個寫線程:5個讀寫線程分別在並發地執行,我們發現StampedLock表現得最好,比synchronized性能高3倍多。讀寫鎖也表現得不錯。這裏奇怪的是樂觀鎖,表麵上看它該是最快的,實際卻是最慢的。
10個讀線程和10個寫線程:接下來,我增加競爭級別提高到10個讀線程和10個寫線程。現在情況開始發生了變化。在同級別執行下,讀寫鎖現在要比StampedLock和synchronized慢一個數量級。請注意,樂觀鎖令人驚訝的仍然比StampedLock的讀寫鎖慢。
16個讀線程和4個寫線程:接下來,我保持同樣的競爭級別,不過將讀寫線程的比重調整了下:16個讀線程和4個寫線程。讀寫鎖再說次說明了為什麼它要被替換掉了——它慢了百倍以上。Stamped以及樂觀鎖都表現得不錯,synchronized也緊隨其後。
19個讀線程和1個寫線程:最後,我看看19個讀線程和1個寫線程會怎樣。注意到結果慢得多了,因為單線程需要更長的時間來完成計數增加操作。在這裏我們得到了一些非常有趣的結果。讀寫鎖需要太多時間來完成。盡管Stamped鎖沒有表現得很好…樂觀鎖明顯是這裏的贏家,打敗了讀寫鎖100倍。即使如此,記住這種鎖定模式可能會失敗,因為這段時間內可能會出現一個寫線程。Synchronized, 我們的老朋友,繼續表現出可靠的結果。
完整的結果可以在這裏找到。硬件:MBP, Core i7。
基礎測試代碼可以在這裏下載。
總結
總體看來, 整體性能表現最好的仍然是內置的同步鎖。但是,這裏並不是說內置的同步鎖會在所有的情況下都執行得最好。這裏主要想表達的是在你將你的代碼投入生產之前,應該基於預期的競爭級別和讀寫線程之間的分配進行測試,再選擇適當一個適當的鎖。否則你會麵臨線上故障的風險。
其他的關於StampedLocks的資料請點擊這裏。
最後更新:2017-05-23 18:03:03