Java 8:StampedLock、ReadWriteLock以及synchronized的比較
原文連結 作者:Tal Weiss 譯者:iDestiny 校對:郭蕾
同步區有點像拜訪你的公公婆婆。你當然是希望待的時間越短越好。說到鎖的話情況也是一樣的,你希望獲取鎖以及進入臨界區域的時間越短越好,這樣才不會造成瓶頸。
對於方法和程式碼塊,語言層面的加鎖機制是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的資料請點選這裡。