最近研究了一下juc包的源碼。
在研究ReentrantReadWriteLock
讀寫鎖的時候,對於其中一些細節的思考和處理以及關於提高效率的設計感到折服,難以遏制想要分享這份心得的念頭,所以在這裏寫一篇小文章做爲記錄。java
本片文章創建在已經瞭解併發相關基礎概念的基礎上,可能不會涉及不少源碼,以思路爲主。
若是文章有什麼紕漏或者錯誤,還請務必指正,預謝。設計模式
首先咱們須要知道獨佔鎖(RenentractLock
)這種基礎的鎖,在juc中是如何實現的:
它基於java.util.concurrent.locks.AbstractQueuedSynchronizer
(AQS)這個抽象類所搭建的同步框架,利用AQS中的一個volatile int
類型變量的CAS操做來表示鎖的佔用狀況,以及一個雙向鏈表的數據結構來存儲排隊中的線程。緩存
簡單地說:一個線程若是想要獲取鎖,就須要嘗試對AQS中的這個volatile int
變量(下面簡稱state
)執行相似comapre 0 and swap 1
的操做,若是不成功就進入同步隊列排隊(自旋和重入之類的細節不展開說了)同時休眠本身(LockSupport.park
),等待佔有鎖的線程釋放鎖時再喚醒它。安全
那麼,若是不考慮重入,state
就是一個簡單的狀態標識:0
表示鎖未被佔用,1
表示鎖被佔用,同步性由volatile
和CAS來保證。數據結構
上面說的是獨佔鎖,state
能夠不嚴謹地認爲只有兩個狀態位。併發
可是若是是讀寫鎖,那這個鎖的基本邏輯應該是:讀和讀共享、讀和寫互斥、寫和寫互斥app
如何實現鎖的共享呢?
若是咱們再也不把state
當作一個狀態,而是當作一個計數器,那彷彿就能夠說得通了:獲取鎖時compare n and swap n+1
,釋放鎖時compare n and swap n-1
,這樣就可讓鎖不被獨佔了。框架
所以,要實現讀寫鎖,咱們可能須要兩個鎖,一個共享鎖(讀鎖),一個獨佔鎖(寫鎖),並且這兩個鎖還須要協做,寫鎖須要知道讀鎖的佔用狀況,讀鎖須要知道寫鎖的佔用狀況。ide
設想中的簡單流程大概以下:性能
設想中的流程很簡單,然而存在一些問題:
關於讀寫互斥:
state
回到0,這樣的話state
可能永遠不會有回到0的一天),這會致使極端狀況下寫鎖根本沒有機會上位,該如何解決這種狀況?對於上面用計數器來實現共享鎖的假設,當任意一個線程想要釋放鎖(即便它並未獲取鎖,由於解鎖的方法是開放的,任何獲取鎖對象的線程均可以執行lock.unlock()
)時,如何判斷它是否有權限執行compare n and swap n-1
?
ThreadLocal
來實現這種權限控制?ThreadLocal
來控制,如何保證性能和效率?ReentrantReadWriteLock
在開始研究ReentrantReadWriteLock
以前,應當先了解兩個概念:
重入性
一個很現實的問題是:咱們時常須要在鎖中加鎖。這多是由代碼複用產生的需求,也可能業務的邏輯就是這樣。
可是無論怎樣,在一個線程已經獲取鎖後,在釋放前再次獲取鎖是一個合理的需求,並且並不生硬。
上文在說獨佔鎖時說到若是不考慮重入的狀況,state
會像boolean
同樣只有兩個狀態位。那麼若是考慮重入,也很簡單,在加鎖時將state
的值累加便可,表示同一個線程重入此鎖的次數,當state
歸零,即表示釋放完畢。
公平、非公平
這裏的公平和非公平是指線程在獲取鎖時的機會是否公平。
咱們知道AQS中有一個FIFO的線程排隊隊列,那麼若是全部線程想要獲取鎖時都來排隊,你們先來後到井井有理,這就是公平;而若是每一個線程都不守秩序,選擇插隊,並且還插隊成功了,那這就是不公平。
可是爲何須要不公平呢?
由於效率。
有兩個因素會制約公平機制下的效率:
咱們之因此會使用鎖、使用併發,可能很大一部分緣由是想要挖掘程序的效率,那麼相應的,對於性能和效率的影響須要更加敏感。
簡單地說,上述的兩點因爲公平帶來的性能損耗極可能讓你的併發失去高效的初衷。
固然這也是和場景密切關聯的,好比說你很是須要避免ABA問題,那麼公平模式很適合你。
具體的再也不展開,能夠參考這篇文章:深刻剖析ReentrantLock公平鎖與非公平鎖源碼實現
回到咱們以前提的問題:
咱們先考慮這種狀況是否實際存在:假設咱們有一個對象,它有兩個實例變量a
和b
,咱們須要在實現:if a = 1 then set b = 2
,或者換個例子,若是有個用戶名字叫張三就給他打錢。
這看上去彷彿是個CAS操做,然而它並非,由於它涉及了兩個變量,CAS操做並不支持這種針對多個變量的疑似CAS的操做。
爲何不支持呢?由於cpu不提供這種針對多個變量的CAS操做指令(至少x86不提供),代碼層面的CAS只是對cpu指令的封裝而已。
爲何cpu不支持呢?能夠,但不必鄙人也不是特別清楚(逃)。
總而言之這種狀況是存在的,可是在併發狀況下若是不加鎖就會有問題:好比先判斷獲得這個用戶確實名叫張三,正準備打錢,忽然中途有人把他的名字改了,那再打這筆錢就有問題了,咱們判斷名字和打錢這兩個行爲中間應當是沒有空隙的。
那麼爲了保證這個操做的正確性,咱們或許能夠在讀以前加一個讀鎖,在我釋放鎖以前,其餘人不得改變任何內容,這就是以前所說的讀寫互斥:讀的期間不許寫。
可是若是照着這個想法,就產生了自相矛盾的地方:都說了讀期間不能寫,那你本身怎麼在寫(打錢)呢?
若是咱們順着這個思路去嘗試解釋「本身讀的期間還在寫」的行爲的正當性,咱們也許能夠設立一個規則:若是讀鎖是我本身持有,則我能夠寫。
然而這會出現其餘的問題:由於讀鎖是共享的,你加了讀鎖,其餘人仍然能夠讀,這是否會有問題呢?假如咱們的打錢操做涉及更多的值的改變,只有這些值所有改變完畢,才能說此時的總體狀態正確,不然在改變完畢以前,讀到的東西都有多是錯的。
再去延伸這個思路彷佛會變得很是艱難,也許會陷入耦合的地獄。
可是實際上咱們不須要這樣作,咱們只須要反過來使用讀寫互斥的概念便可:由於寫寫互斥(寫鎖是獨佔鎖),因此咱們在執行這個先讀後寫的行爲以前,加一個寫鎖,這一樣能防止其餘人來寫,同時還能夠阻止其餘人來讀,從而實現咱們在單線程中讀寫並存的需求。
這就是ReentrantReadWriteLock
中一個重要的概念:鎖降級
對於另外一個子問題:若是在已經獲取寫鎖的期間還要再獲取寫鎖的時候怎麼辦?
這種狀況仍是很常見的,多數是因爲代碼的複用致使,不過相應的處理也很簡單:對寫鎖這個獨佔鎖增長容許單線程重入的規則便可。
若是咱們有兩把鎖,一把讀鎖,一把寫鎖,它們之間想要互通各自加鎖的狀況很簡單——只要去get對方的state
就好了。
可是隻知道state
是不夠的,對於讀的操做來講,它若是隻看到寫鎖沒被佔用,也無論有多少個寫操做還在排隊,就去在讀鎖上+1,那極可能發展成爲問題所說的場景:寫操做永遠沒機會上位。
那麼咱們理想的狀況應該是:讀操做若是發現寫鎖空閒,最好再看看寫操做的排隊狀況如何,酌情考慮放棄這一次競爭,讓寫操做有機會上位。
這也是我理解的,爲何ReentrantReadWriteLock
不設計成兩個互相溝通的、獨立的鎖,而是公用一個鎖(class Sync extends AbstractQueuedSynchronizer
)——由於它們看似獨立,實際上對於耦合的需求很大,它們不只須要溝通鎖的狀況,還要溝通隊列的狀況。
公用一個鎖的具體實現是:使用int state
的高16位表示讀鎖的state
,低16位表示寫鎖的state
,而隊列公用的方式是給每一個節點增長一個標記,代表該節點是一個共享鎖的節點(讀操做)仍是一個獨佔鎖的節點(寫操做)。
上面說到的「酌情放棄這一次競爭」,ReentrantReadWriteLock
中體如今boolean readerShouldBlock()
這個方法裏,這個方法有兩個模式:公平和非公平,咱們來稍微看一點源碼
先看公平模式的實現:
final boolean readerShouldBlock() { return hasQueuedPredecessors(); }
當線程發現本身能夠獲取讀鎖時(寫鎖未被佔用),會調用這個方法,來判斷本身是否應該放棄這次獲取。hasQueuedPredecessors()
這個方法咱們不去看源碼,由於它的意思很顯而易見(實際代碼也是):是否存在排隊中的線程(Predecessor先驅者能夠理解爲先來的)。
若是有,那就放棄競爭去排隊。
在公平模式下,不管讀寫操做,只須要你們都遵照FIFO的秩序,就不會出現問題描述的狀況
再來看看非公平模式下的實現代碼:
final boolean readerShouldBlock() { return apparentlyFirstQueuedIsExclusive(); } final boolean apparentlyFirstQueuedIsExclusive() { // Node表示同步隊列中的一個節點 Node h, s; // head是當前隊列的頭節點的一個公共引用,它是一個沒有實際意義的節點,null or not只能標識隊列是否初始化過 // next是當前節點的下一個節點的引用 // isShared()方法代表這個節點是一個共享鎖(讀鎖)的節點仍是獨佔鎖(寫鎖)的節點 return (h = head) != null && (s = h.next) != null && !s.isShared() && s.thread != null; }
總結一下語義:若是隊列不爲空,且隊列最前面的節點是個獨佔鎖的節點,則放棄競爭。也就是咱們上面說的「根據隊列狀況酌情放棄」。
ThreadLocal
嗎?它會對性能形成影響嗎?通常而言的讀操做的線程對於state
的操做可能只是+1而後-1,而若是發生重入,那就會是n次+1而後n次-1。
可是無論怎樣,每個線程都應當有一份記錄本身持有共享鎖數量的信息,這樣釋放鎖的時候才能知道本身可不能夠去-1。
這也許很簡單,咱們能夠在鎖裏增長一個Map
對象,用相似tid(k)-count(v)
的數據結構來記錄每一個線程的持有數量;也能夠爲每一個線程建立一個ThreadLocal
,讓它們本身拿着。
如今咱們面前有兩條路比較直觀:將全部線程的小計數器維護在一個Map
中,或是每一個線程在ThreadLocal
中維護本身的小計數器。
就這兩條途徑而言,應該是Map
的這一條路比較高效,由於若是選擇ThreadLocal
也許會頻繁進行其內部的ThreadLocalMap
對象的建立和銷燬,這很消耗資源。
然而事實是,ReentranctReadWriteLock
選擇的實現方式是後者,即便用ThreadLocal
來實現,可是爲何選擇這種方式正是我十分好奇的地方,由於根據經驗,必定是利用Map
統一管理小計數器的方式較爲高效,且單個線程針對單個key的value進行+1或者-1的操做應該是知足as-if-serial
原則的,也不存在安全問題。
所以針對兩種不一樣的實現方式進行了一些測試:四線程並行狀況下一千萬次加解鎖時間測試
Map
統一管理實現public static void main(String[] args) { long total = 0; for (int i = 0; i < 30; i++) { total += execute(); } System.out.println(total / 30); } private static long execute() { var map = new HashMap<Long, Integer>(); var readerPool = new ThreadPoolExecutor( 4, 4, 5L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), new CustomizableThreadFactory("readerPool")); var countDown = new CountDownLatch(10000000); for (int i = 0; i < 10000000; i++) { readerPool.execute(() -> { try { countDown.await(); } catch (InterruptedException e) { e.printStackTrace(); return; } mapImplement(map); }); countDown.countDown(); } long startTime = System.currentTimeMillis(); while (readerPool.getCompletedTaskCount() < 10000000) { LockSupport.parkNanos(100); } long total = System.currentTimeMillis() - startTime; System.out.println(readerPool.getCompletedTaskCount() + ", time: " + total); return total; } private static void mapImplement(HashMap<Long, Integer> map) { // lock var tid = Thread.currentThread().getId(); Integer count; if ((count = map.get(tid)) != null) { map.put(tid, count + 1); } else { map.put(tid, 1); } // unlock int afterDecrement = -999; if ((count = map.get(tid)) == null || (afterDecrement = (count - 1)) < 0) { System.out.println("error, count: " + count + ", afterDecrement: " + afterDecrement); return; } map.put(tid, afterDecrement); }
三十次測試過程的平均執行時間爲:2378毫秒,我的認爲這個結果仍是比較樂觀的。
ThreadLocal
各自持有實現// 小計數器實體 static final class HoldCounter { int count; } // threadLocal static final class ThreadLocalHoldCounter extends ThreadLocal<HoldCounter> { @Override public HoldCounter initialValue() { return new HoldCounter(); } } public static void main(String[] args) { long total = 0; for (int i = 0; i < 30; i++) { total += execute(); } System.out.println(total / 30); } private static long execute() { var readHolds = new ThreadLocalHoldCounter(); var readerPool = new ThreadPoolExecutor( 4, 4, 5L, TimeUnit.SECONDS, new LinkedBlockingQueue<>(), new CustomizableThreadFactory("readerPool")); var countDown = new CountDownLatch(10000000); for (int i = 0; i < 10000000; i++) { readerPool.execute(() -> { try { countDown.await(); } catch (InterruptedException e) { e.printStackTrace(); return; } threadLocalImplement(readHolds); }); countDown.countDown(); } long startTime = System.currentTimeMillis(); while (readerPool.getCompletedTaskCount() < 10000000) { LockSupport.parkNanos(100); } long total = System.currentTimeMillis() - startTime; System.out.println(readerPool.getCompletedTaskCount() + ", time: " + total); return total; } private static void threadLocalImplement(ThreadLocalHoldCounter readHolds) { // lock var hc = readHolds.get(); ++hc.count; // unlock hc = readHolds.get(); --hc.count; if (hc.count == 0) { readHolds.remove(); } }
三十次測試過程的平均執行時間爲:3079毫秒
能夠看到,使用Map
集中管理小計數器的實現方式的執行效率要比ThreadLocal
的實現方式快20%以上。
難道我做爲一個Java萌新都能想到的性能差距,Doug Lea這樣的大神會想不到嗎?
固然不會
實際上,ReentranctReadWriteLock
在針對小計數器的具體實現上,增長了相似兩層緩存的設計,大概以下:
// ThreadLocal對象本體 private transient ThreadLocalHoldCounter readHolds; // 二級緩存:最近一次獲取鎖的線程所持有的小計數器對象的引用 private transient HoldCounter cachedHoldCounter; // 一級緩存:首次獲取鎖的線程的線程對象引用以及它的計數 private transient Thread firstReader; private transient int firstReaderHoldCount;
當線程嘗試獲取鎖時,會執行以下的流程:
firstReader == Thread.currentThread()
firstReaderHoldCount
上進行+1(以及執行firstReader = Thread.currentThread()
)cachedHoldCounter.tid == Thread.currentThread().getId()
cachedHoldCounter.count
上進行+1readHolds.get()
進行獲取或初始化,而後再對小計數器進行操做當線程釋放鎖時,執行流程也大體類似,都是先對兩級緩存進行嘗試,逼不得已再去對ThreadLocal
進行操做。
因爲讀操做的實際執行內容通常至關簡單(相似return a
),因此在絕大多數狀況下,線程的加解鎖行爲都會命中一級緩存。
我嘗試在ReentranctReadWriteLock
的加解鎖行爲內埋了幾個計數點來測試兩級緩存的命中率,四線程並行1000萬次加解鎖操做,結果是:
ThreadLocal
本體命中率大概爲1~5% 而執行效率,進行1000萬次加解鎖,循環三十次獲得的平均執行時間是:2027毫秒。
比上面提到的使用Map
實現的方式更要快了15%左右。
雖然說上面的小測試的編碼也好,測試環境也好,都不算特別嚴謹,可是仍是能很是直觀地說明問題的吧。
ReentranctReadWriteLock
實際設計概覽如圖,ReentranctReadWriteLock
中有五個內部類:
Sync
Sync
繼承自AbstractQueuedSynchronizer
,上文提到的volatile int state
以及同步隊列的實際實現都是由AbstractQueuedSynchronizer
這個抽象類提供的,它還提供了一些在鎖的性質不一樣時實現也會不一樣的可重寫方法,Sync
須要作的事情就是將這些通用的方法和規則加以實現和擴充,造成本身想要實現的鎖。Sync
也是咱們上文提到的,同時實現了讀寫兩種性質的鎖的根本。state
、利用公平性避免機會不均衡的問題、分級緩存共享鎖小計數器等特性,均在此類中實現,須要特別關注。NonfairSync
與FairSync
Sync
,它們提供了由Sync
定義的兩個用於進行公平性判斷的方法:boolean writerShouldBlock()
與boolean readerShouldBlock()
。實際使用ReentranctReadWriteLock
時,咱們會經過構造方法選擇須要構造公平仍是非公平的鎖,相應的會經過這兩個子類構造實際的Sync
類的對象,從而影響到加解鎖過程當中的一些判斷。ReadLock
與WriteLock
這兩個類都會持有上面提到的Sync
類的對象的引用,並向用戶(使用者)提供包裝好但實現不一樣的操做,好比:
讀鎖獲取
public void lock() { sync.acquireShared(1); }
寫鎖獲取
public void lock() { sync.acquire(1); }
更多的源碼就再也不贅述了,搜索一下就會有很是多的文章解讀源碼並不是做者懶得貼了。
juc這套併發框架的設計者和創始人Doug Lea,能夠說是java開發者金字塔頂端的巨佬之一了,他所編寫的juc包的代碼,不管是代碼結構的合理性、各類設計模式的使用、代碼的優雅程度都使人歎爲觀止。看完源碼以爲整我的都昇華了
筆者學習了源碼以後,以爲在面對這些充滿了考慮的設計細節時產生的思考,纔是真正可使人獲得長遠的提高的東西。
所以整理出來,做爲心得體會的記錄。若是能夠對其餘小夥伴帶來啓發,那就更好了。最後,若是文章內有什麼紕漏或是錯誤,還請務必指正,再次感謝。