Lock鎖
鎖是用來控制多個線程訪問共享資源的方式。
一般來說一個鎖可以防止多個線程同時訪問共享資源(但有些鎖可以允許多個線程訪問共享資源,如讀寫鎖)。
在Lock接口出現前,java使用synchronized關鍵字實現鎖的功能,但是在javaSE5之后,並發包中提供了Lock接口(以及其實現類)用來實現鎖的功能。
Lock提供了與synchronized相似的功能,但必須顯示的獲取鎖與釋放鎖,雖然不及隱式操作方便,但是擁有了鎖獲取與釋放的可操作性、可中斷的鎖獲取與超時獲取鎖等多重功能。
提供場景:先獲取鎖A,在獲取鎖B,當獲取鎖B后,釋放鎖A的同時獲取鎖C,當獲取鎖C后,釋放鎖B的同時獲取鎖D,以此類推,可以通過Lock實現。
Lock的使用:
注:在finally中釋放鎖,目的保證獲取鎖之后能夠最終釋放鎖。
不要把獲取鎖的過程寫在try中,因為這樣出現異常時,鎖將會因為異常的拋出而被釋放掉。
隊列同步器
隊列同步器AbstractQueuedSynchronizer(以下簡稱同步器),是構建鎖或者其他同步組件的基本框架。
使用了一個int成員變量表示同步狀態,通過內置的FIFO隊列來完成資源的獲取線程的排隊工作。
同步器的主要使用方法是繼承,子類通過繼承同步器並實現它的抽象方法來管理同步狀態。
在抽象方法的實現過程中對同步狀態進行更改,需要使用到同步器提供的三個方法:getState()、setState(int newState)和compareAndSetState(int expect,int update)來進行操作,這三個方法可以保證狀態的改變是安全的。
子類被推薦定義為自定義同步組件的靜態內部類,同步器自身沒有實現任何同步接口,它僅僅是定義了若干同步狀態獲取和釋放方法來供自定義同步組件使用,同步器即可以支持獨占式獲取同步狀態,也可以支持共享式地獲取同步狀態,這樣方便實現不同類型的同步組件(ReentrantLock、ReentrantReadWriteLock、CountDownLatch等)。
同步器是實現鎖(也可以是任何同步組件)的關鍵:在鎖中聚合同步器,利用同步器實現鎖的語義。
兩者的關系:鎖是面向使用者的,他定義了使用者與鎖交互的接口(比如允許兩個線程並行訪問),隱藏了實現細節;
同步器是面向鎖的實現者,它簡化了鎖的實現方式,屏蔽了同步管理狀態、線程的排隊、等待與喚醒等底層操作。
鎖讓使用者僅僅是調用其方法既可以實現同步效果、同步器讓實現者通過重寫抽象方法進行了隊列的底層操作。他們兩個是使用者和實現者關注不同的領域實現了相同的效果。
隊列同步器的接口與示例
同步器基於模板設計模式實現的,使用者需要繼承同步器並重寫指定的方法,隨后將同步器組合在自定義的同步組件的實現中,並調用同步器提供的模板方法,而這些模板方法會調用使用者重寫的方法。
注:實現同步組件是要依賴於同步器,而實現同步器需要重寫一些方法,然后同步組件調用同步器中的模板方法實現同步效果,而這些模板方法又調用我們重寫的方法來實現功能。
同步器的原理可以使我們只關注於自己需要實現的方法而不需要關注其他的地方
(實際上你想要對流程有一個清晰的認識還是要把所有的都熟悉的,但是在實現同步器時,可以把主要的精力都放在同步器重寫方法上,而不是別的地方)
重寫同步器指定方法時需要使用同步器提供的如下三個方法來訪問或修改同步狀態:
getState():獲取當前同步狀態
setState(int new State):設置當前同步狀態
compareAndState(int expect,int update):使用CAS設置當前狀態,該方法能夠保證狀態設置的原子性。
同步器可重寫的方法:
實現自定義同步組件時,將會調用同步器提供的模板方法,這些模板方法與描述:
注:模板方法基本分為三類:獨占式同步狀態獲取與釋放、共享式同步狀態獲取與釋放和查詢同步隊列中等待線程情況。
以獨占鎖的示例來深入了解一下同步器的工作原理(以便能夠更加深入的理解其他同步組件)。
獨占鎖:同一時刻只有一個線程能夠獲取鎖,而其他獲取鎖的線程只能處於同步隊列中等待,只有獲取鎖的線程釋放了鎖,后續的線程才能夠獲取鎖。
public class Metux implements Lock { //靜態內部類,自定義同步器
private static class Sync extends AbstractQueuedSynchronizer{ //是否處於占用狀態
@Override protected boolean isHeldExclusively() { return getState() == 1; } //當狀態為0的時候獲取鎖
@Override protected boolean tryAcquire(int arg) { if (compareAndSetState(0,1)) { setExclusiveOwnerThread(Thread.currentThread()); return true; } return false; } //釋放鎖,將狀態設置為0
@Override protected boolean tryRelease(int arg) { if (getState() == 0) throw new IllegalMonitorStateException(); setExclusiveOwnerThread(null); setState(0); return true; } //返回一個condition,每個condition都包含了一個condition隊列
Condition newCondition() { return new ConditionObject(); } } //僅需要將操作代理到Sync上即可
private final Sync sync = new Sync(); @Override public void lock() { sync.tryAcquire(1); } @Override public boolean tryLock() { return sync.tryAcquire(1); } @Override public void unlock() { sync.tryRelease(1); } @Override public Condition newCondition() { return sync.newCondition(); } public boolean isLocked() { return sync.isHeldExclusively(); } public boolean hasQueuedThreads() { return sync.hasQueuedThreads(); } @Override public void lockInterruptibly() throws InterruptedException { sync.acquireInterruptibly(1); } @Override public boolean tryLock(long time, TimeUnit unit) throws InterruptedException { return sync.tryAcquireNanos(1,unit.toNanos(time)); } }
獨占鎖Mutex是一個自定義同步組件,它在同一時刻只允許一條線程占有鎖。
Mutex定義了一個靜態內部類,該內部類繼承了同步器並實現了獨占式獲取和釋放同步狀態。
在tryAcquire(int acquire)方法中,如果經過CAS設置成功(同步狀態設置為1),則代表獲取了同步狀態,
而在tryRelease(int release)方法中只是將同步狀態重置為0。
用戶使用Mutex時,並不會直接和內部同步器實現打交道。而是調用Mutex提供的方法,在Mutex的實現中,以獲取鎖的lock()犯法為例:只需要在方法實現中調用同步器的模板方法acquire(int args)即可。
當前線程調用該方法獲取同步狀態失敗后會被加入到同步隊列中等待,這樣就大大降低了實現一個可靠自定義組件的門檻。
隊列同步器的實現分析
同步器完成線程同步的方式:同步隊列、獨占式同步狀態獲取與釋放、共享式同步狀態獲取與釋放以及超時獲取同步狀態等同步器的核心數據結構和模板方法。
1.同步隊列
原理:同步器依賴於內部的同步隊列(一個FIFO雙向隊列)來完成同步狀態的管理,當前線程獲取同步狀態失敗時,同步器會將當前線程以及等待狀態等信息構成一個節點(Node)並將其加入同步隊列,同時阻塞當前線程,當同步狀態釋放時,會將首節點中的線程喚醒,使其再次嘗試獲取同步狀態。
FIFO:first in first output,先入先出。
節點(Node):用來保存獲取同步狀態失敗的線程引用、等待狀態以及前驅和后繼節點信息。
節點是構成同步隊列的基礎,同步器擁有首節點(head)和尾節點(tail),沒有成功獲取同步狀態的線程將會稱為節點加入隊列的尾部。
同步隊列的結構:
注:同步器包含兩個節點類型的引用,一個指向頭節點,一個指向尾節點。
線程加入隊列的過程必須保證線程安全,同步器提供了一個基於CAS的設置尾節點的方法:compareAndSetTail(Node expect,Node update),保證線程安全。
為什么必須保證線程安全:同時有多條線程沒有獲取同步狀態要加入同步隊列,這時如果不是線程安全的,請問誰先誰后呢?所以在此處的這個操作必須是線程安全的
它需要傳遞當前線程“認為”的尾節點和當前節點,只有設置成功后,當前節點才正式與之前的尾節點建立關聯。
同步器將節點加入同步隊列的過程:
注:同步隊列遵循FIFO,首節點是獲取同步狀態成功的節點,首節點的線程在釋放同步狀態時,將會喚醒后繼節點,而后繼節點將會在獲取同步狀態成功時將自己設置為首節點:
在5-3的過程中,設置首節點是通過獲取同步狀態成功的線程完成的,由於只有一個線程能夠獲取到同步狀態,因此設置頭節點的方法並不需要CAS來保障,它只需要將首節點設置成為原首節點的后繼節點並斷開原首節點的next引用即可。
2.獨占式同步狀態獲取與釋放
通過同步器的acquire(int arg)方法可以獲取同步狀態,該方法對中斷不敏感,也就是說由於線程獲取同步狀態失敗后進入同步隊列中,后繼對線程進行中斷操作時,線程不會從同步隊列移除。acquire方法:
上述代碼中完成了同步狀態的獲取、節點構造、加入同步隊列以及同步隊列中自旋等待的相關工作。
主要邏輯:
首先調用自定義同步器實現的tryAcquire(int arg)方法,該方法保證線程安全的獲取同步狀態,如果同步狀態獲取失敗,則構造同步節點(獨占式Node.EXCLUSIVE,同一時刻只能有一個線程成功獲取同步狀態)並通過addWaiter(Node node)方法將該節點加入到同步隊列的尾部,最后調用acquireQueued(Node node,int arg)方法,使得該節點以“死循環”的方式獲取同步狀態。如果獲取不到阻塞節點中的線程,而被阻塞線程的喚醒主要依靠前驅節點的出隊或阻塞線程被中斷來實現。
節點的構造以及加入同步隊列依靠於addWaiter和enq方法:
addwaiter:
enq:
注:上述代碼通過compareAndSetTail(Node expect, Node update)方法來確保節點能夠被線程安全添加。
在enq(final Node node)中,同步器通過死循環的方式來確保節點的添加,在死循環中只有通過CAS將當前節點設置為尾節點之后,當前線程才能從該方法返回,否則的話當前線程不斷地嘗試設置。
可以看出,enq(final Node node)方法將並發添加節點的請求通過CAS變得“串行化”了。(個人認為這里指的是將節點通過入隊的方式,暫時保存了它的一系列狀態。)
節點進入隊列后,就進入了一個自旋狀態,每個節點(或者說每個線程),都在自省觀察,當條件滿足,獲取到同步狀態,就可以從這個自旋過程中退出,否則依舊留在自旋過程中(見代碼):
注:在acquireQueued(final Node node, int arg)方法中,當前線程在“死循環”中嘗試獲取同步狀態,而只有前驅節點是頭節點才能夠嘗試獲取同步狀態,原因如下:
1)頭節點是成功獲取到同步狀態的節點,而頭節點線程獲取到同步狀態后,將會喚醒其后繼節點,后繼節點的線程被喚醒后需要檢查自己的前驅節點是否是頭節點。
2)維護同步隊列的FIFO原則,該方法中節點自旋獲取同步狀態的行為如下圖:
注:由於非首節點線程前驅節點出隊或被中斷而從等待狀態返回,隨后檢查自己的前驅是否是頭節點,如果是則嘗試獲取同步狀態。
可以看到節點與及節點之間在循環檢查的過程中基本上不相互通信,而是簡單地判斷自己的前驅是否為頭節點,這樣就使得節點的釋放符合FIFO,並且對於方便對過早通知進行處理(過早通知指的是前驅節點不是頭節點的線程由於中斷被喚醒)。
獨占式同步狀態獲取流程,也就是acquire(int arg)方法調用流程:
注:在上圖中前驅節點為頭節點且能夠獲取同步狀態與線程進入等待狀態是獲取同步狀態的自旋過程(acquireQueued方法的死循環),當同步狀態獲取成功,當前線程從acquire(int arg)方法返回,這也就代表着當前線程獲得了鎖。
當前線程獲取同步狀態完成相應邏輯后,需要釋放同步狀態,通過調用同步器的release(int arg)方法可以釋放同步狀態,該方法在釋放了同步狀態后,會喚醒其后繼節點(進而使后繼節點重新嘗試獲取同步狀態)。代碼如下:
該方法執行時,會喚醒頭節點的后繼節點線程,unparkSuccerssor(Node node)方法使用LcokSupport(后面講)來喚醒處理等待狀態的線程。
獨占式同步狀態獲取和釋放:
在獲取同步狀態時,同步器會維持一個同步隊列,獲取失敗的線程都會被加入到同步隊列中,並在同步隊列中自旋(判斷自己前驅節點為頭節點)。
移出隊列(停止自旋)的條件是前驅節點為頭節點且成功獲取了同步狀態。在釋放同步狀態時,同步器調用tryRelease(int arg)方法釋放同步狀態,然后喚醒頭節點的后繼節點。
3.共享式同步狀態獲取與釋放
共享式獲取與獨占式獲取最主要的區別在於同一時刻能否有多個線程同時獲取到同步狀態。
以讀寫為例:
如果一個程序對文件進行讀操作時,那么這一時刻對於該文件的寫操作均被堵塞,而讀操作能夠同時訪問。
寫操作要求對資源的獨占,而讀操作是可以共享式的訪問。
注:從圖可以看到,共享是可以在同一時刻所有共享線程對資源進行訪問的,而獨占的話是在同一時刻只有一個線程能夠訪問。
通過調用同步器的acquireShared(int arg)方法可以共享式地獲取同步狀態:
注:在acquireShared(int arg)方法中,同步器調用tryAcquireShared(int arg)方法嘗試獲取同步狀態,tryAcquireShared(int arg)方法返回值為int型,當返回值大於等於0時,表示能夠獲取到同步狀態。
在共享式獲取自選狀態過程中,成功獲取到同步狀態並退出自旋的條件就是tryAcquireShared(int arg)方法的返回值大於等於0。
可以看到在doAcquireShared(int arg)方法的自旋過程中,如果當前節點的前驅為頭節點時,嘗試獲取同步狀態,如果返回值大於等於0,表示該次獲取同步狀態成功,並從自旋過程中退出。
與獨占式相同,共享式獲取也需要釋放同步狀態,通過調用releaseShared(int arg)方法可以釋放同步狀態:
注:該方法釋放同步狀態之后,將會喚醒后續處於等待狀態的節點。對於能夠支持多個線程同時訪問的並發組件,它和獨占式主要區別在於tryReleaseShared(int arg)方法必須確保同步狀態(或者資源數)線程安全釋放,一般都是通過CAS和循環來保證的,因為釋放同步狀態的操作會同時來自多個線程。
4.獨占式超時獲取同步狀態
通過調用同步器的tryAcquireNanos(int arg,long nanosTimeout)方法可以超時獲取同步狀態,即在指定的時間內獲取同步狀態,如果獲取到同步狀態則返回true,否則,返回false。
在java5之后,同步器提供了acquireInterruptibly(int arg)方法,這個方法在等待獲取同步狀態時,如果當前線程被中斷,會立刻返回,並拋出InterruptException異常。(1.5之前並不會)。
超時獲取同步狀態的過程可以被視作響應中斷獲取同步狀態過程的“增強版”。
doAcquireNanos(int arg,long nanosTimeout)方法在支持響應中斷的基礎上,增加了超時獲取的特性。
為了針對超時獲取,主要需要計算出需要睡眠的時間間隔nanosTimeout,為了防止過早通知,nanosTimeout計算公式為:nanosTimeout=now-lastTime,其中now為當前喚醒時間,lastTime為上次喚醒時間,如果nanosTimeout大於0表示超時時間未到,需要繼續睡眠nanosTimeout納秒,反之,表示已經超時。
注:該方法在自旋過程中,當節點的前驅節點為頭節點時嘗試獲取同步狀態,如果獲取成功則從該方法返回,這個過程和獨占式同步獲取的過程類似,但是在同步狀態獲取失敗的處理上不同。
如果當前線程獲取同步狀態失敗,則判斷是否超時(nanosTimeout小於0表示超時),如果沒有超時,重新計算超時間隔nanosTimeout,然后使線程等待nanosTimeout納秒(當已到設置的超時時間,該線程會從LockSupport.parkNanos(Object blocker, long nanos)方法返回)。
如果nanosTimeout小於等於spinForTimeoutThreshold(1000納秒)時,將不會使該線程進行超時等待,而是進入快速的自旋過程。原因:非常短的超時等待無法做到十分精確,如果這時再進行超時等待,相反會讓nanosTimeout的超時從整體上表現的不精確。
因此在超時非常短的場景下,同步器會進入無條件的快速自旋。
獨占式超時獲取同步狀態doAcquireNanos(int arg, long nanosTimeout)與獨占式獲取同步狀態acquire(int args)的主要區別:
未獲取到同步狀態的邏輯。
acquire(int args)在未獲取到同步狀態時,將會使當前線程一直處於等待狀態,
doAcquireNanos(int arg, long nanosTimeout)會使當前線程等待nanosTimeout納秒,如果當前線程在nanosTimeout納秒內沒有獲取到同步狀態,將會從等待邏輯中自動返回。
5.自定義同步組件——TwinsLock
通過自定義自己的同步組件提高對同步器的理解。
同步組件功能:該組件同一時刻最多只允許兩個線程訪問,超過兩個線程的訪問將被阻塞,我們將這個同步工具命名為TwinsLock。
1)確定訪問模式:
TwinsLock能夠在同一時刻支持多個線程的訪問,這是共享式訪問。
同步器應該提供acquireShared(int args)方法與Shared相關的方法。
這就要求TwinsLock必須重寫tryAcquireShared(int args)方法和tryReleaseShared(int args)方法,這樣才能保證同步器的共享式同步狀態的獲取與釋放方法的執行。
2)定義資源數:
TwinsLock在同一時刻允許至多兩個線程的同時訪問,同步資源數為2。設置初始狀態status為2,當一個線程進行獲取時,status減1,該線程釋放,則status加1,狀態的合法范圍0、1、2。
其中0代表兩個線程已經獲取了同步資源,此時再有其他線程對同步狀態進行獲取,該線程只能被阻塞。在同步狀態變更時,需要使用compareAndSet(int expect, int update)方法做原子性保障
3)定義同步器:
自定義同步組件通過組合自定義同步器來完成同步功能,一般情況下自定義同步器會被定義為自定義同步組件的內部類。
public class TwinsLock implements Lock { private final Sync sync = new Sync(2); private static final class Sync extends AbstractQueuedSynchronizer { Sync(int count) { if(count < 0) { throw new IllegalArgumentException("count must large than zero"); } setState(count); } @Override public int tryAcquireShared(int reducecount) { for (;;) { int current = getState(); int newCount = current - reducecount; if (newCount < 0 || compareAndSetState(current,newCount)) { return newCount; } } } @Override protected boolean tryReleaseShared(int returnCount) { for(;;) { int current = getState(); int newCount = current + returnCount; if (compareAndSetState(current,newCount)) { return true; } } } } @Override public void lock() { sync.acquireShared(1); } @Override public void unlock() { sync.releaseShared(1); } //其他接口略
}
注:在上例中TwinsLock實現了Lock接口,提供了面向使用者的接口,使用者調用lock()方法獲取鎖,使用unlock()釋放鎖,而且同一時刻只能有兩個線程獲取到鎖。
TwinsLock同時還包含了一個自定義同步器sync,而該同步器面向線程訪問和同步狀態控制。
以共享式獲取同步狀態為例:同步器會先計算出獲取后的同步狀態,然后通過CAS確保狀態的正確設置,當tryAcquireShared(int reduceCount)方法返回值大於等於0時,當前線程才能獲取同步狀態,對於上層的TwinsLock而言,則表示當前線程獲取鎖。
同步器作為一個橋梁,連接了線程訪問以及同步狀態控制等底層技術與不同並發組件(Lock、CountDownLatch等)的接口語義。
本文內容是書中內容兼具自己的個人看法所成。可能在個人看法上會有諸多問題(畢竟知識量有限,導致認知也有限),如果讀者覺得有問題請大膽提出,我們可以相互交流、相互學習,歡迎你們的到來,心成意足,等待您的評價。