您好,登錄后才能下訂單哦!
小編給大家分享一下java并發包JUC同步器框架AQS的示例分析,相信大部分人都還不怎么了解,因此分享這篇文章給大家參考一下,希望大家閱讀完這篇文章后大有收獲,下面讓我們一起去了解一下吧!
通過JCP的JSR166規范,Java的1.5版本引入了j.u.c包,這個包提供了一系列支持中等程度并發的類。這些組件是一系列的同步器(抽象數據類型(ADT))。這些同步器主要維護著以下幾個功能:內部同步狀態的管理(例如:表示一個鎖的狀態是獲取還是釋放),同步狀態的更新和檢查操作,且至少有一個方法會導致調用線程在同步狀態被獲取時阻塞,以及在其他線程改變這個同步狀態時解除線程的阻塞。上述的這些的實際例子包括:互斥排它鎖的不同形式、讀寫鎖、信號量、屏障、Future、事件指示器以及傳送隊列等。
幾乎任一同步器都可以用來實現其他形式的同步器。例如,可以用可重入鎖實現信號量或者用信號量實現可重入鎖。但是,這樣做帶來的復雜性,開銷,不靈活使其至多只能是個二流工程。且缺乏吸引力。如果任何這樣的構造方式不能在本質上比其他形式更簡潔,那么開發者就不應該隨意地選擇其中的某個來構建另一個同步器。取而代之,JSR166建立了一個小框架,AQS類。這個框架為構造同步器提供一種通用的機制,并且被j.u.c包中大部分類使用,同時很多用戶也用它來定義自己的同步器。
在這篇論文的下面部分會討論這個框架的需求、設計與實現背后的主要思路、示例用法,以及性能指標的一些測量。
同步器一般包含兩種方法,一種是acquire,另一種是release。acquire操作阻塞調用的線程,直到或除非同步狀態允許其繼續執行。而release操作則是通過某種方式改變同步狀態,使得一或多個被acquire阻塞的線程繼續執行。
j.u.c包中并沒有對同步器的API做一個統一的定義。因此,有一些類定義了通用的接口(如Lock),而另外一些則定義了其專有的版本。因此在不同的類中,acquire和release操作的名字和形式會各有不同。例如:Lock.lock,Semaphore.acquire,CountDownLatch.await和FutureTask.get,在這個框架里,這些方法都是acquire操作。但是,J.U.C為支持一系列常見的使用選項,在類間都有個一致約定。在有意義的情況下,每一個同步器都支持下面的操作:
阻塞和非阻塞(例如tryLock)同步。
可選的超時設置,讓調用者可以放棄等待
通過中斷實現的任務取消,通常是分為兩個版本,一個acquire可取消,而另一個不可以。
同步器的實現根據其狀態是否獨占而有所不同。獨占狀態的同步器,在同一時間只有一個線程可以通過阻塞點,而共享狀態的同步器可以同時有多個線程在執行。一般鎖的實現類往往只維護獨占狀態,但是,例如計數信號量在數量許可的情況下,允許多個線程同時執行。為了使框架能得到廣泛應用,這兩種模式都要支持。
j.u.c包里還定義了Condition接口,用于支持監控形式的await/signal操作,這些操作與獨占模式的Lock類有關,且Condition的實現天生就和與其關聯的Lock類緊密相關。
Java內置鎖(使用synchronized的方法或代碼塊)的性能問題一直以來都在被人們關注,并且已經有一系列的文章描述其構造(例如引文[1],[3])。然而,大部分的研究主要關注的是在單核處理器上大部分時候使用于單線程上下文環境中時,如何盡量降低其空間(因為任何的Java對象都可以當成是鎖)和時間的開銷。對于同步器來說這些都不是特別重要:程序員僅在需要的時候才會使用同步器,因此并不需要壓縮空間來避免浪費,并且同步器幾乎是專門用在多線程設計中(特別是在多核處理器上),在這種環境下,偶爾的競爭是在意料之中的。因此,常規的JVM鎖優化策略主要是針對零競爭的場景,而其它場景則使用缺乏可預見性的“慢速路徑(slow paths)” ,所以常規的JVM鎖優化策略并不適用于嚴重依賴于J.U.C包的典型多線程服務端應用。
這里主要的性能目標是可伸縮性,即在大部分情況下,即使,或特別在同步器有競爭的情況下,穩定地保證其效率。在理想的情況下,不管有多少線程正試圖通過同步點,通過同步點的開銷都應該是個常量。在某一線程被允許通過同步點但還沒有通過的情況下,使其耗費的總時間最少,這是主要目標之一。然而,這也必須考慮平衡各種資源,包括總CPU時間的需求,內存負載以及線程調度的開銷。例如:獲取自旋鎖通常比阻塞鎖所需的時間更短,但是通常也會浪費CPU時鐘周期,并且造成內存競爭,所以使用的并不頻繁。
實現同步器的這些目標包含了兩種不同的使用類型。大部分應用程序是最大化其總的吞吐量,容錯性,并且最好保證盡量減少饑餓的情況。然而,對于那些控制資源分配的程序來說,更重要是去維持多線程讀取的公平性,可以接受較差的總吞吐量。沒有任何框架可以代表用戶去決定應該選擇哪一個方式,因此,應該提供不同的公平策略。
無論同步器的內部實現是多么的精雕細琢,它還是會在某些應用中產生性能瓶頸。因此,框架必須提供相應的監視工具讓用戶發現和緩和這些瓶頸。至少需要提供一種方式來確定有多少線程被阻塞了。
同步器背后的基本思想非常簡單。
acquire操作如下:
while (synchronization state does not allow acquire) { enqueue current thread if not already queued; possibly block current thread; } dequeue current thread if it was queued;
release操作如下:
update synchronization state; if (state may permit a blocked thread to acquire) unblock one or more queued threads;
為了實現上述操作,需要下面三個基本組件的相互協作:
同步狀態的原子性管理;
線程的阻塞與解除阻塞;
隊列的管理;
創建一個框架分別實現這三個組件是有可能的。但是,這會讓整個框架既難用又沒效率。例如:存儲在隊列節點的信息必須與解除阻塞所需要的信息一致,而暴露出的方法的簽名必須依賴于同步狀態的特性。
同步器框架的核心決策是為這三個組件選擇一個具體實現,同時在使用方式上又有大量選項可用。這里有意地限制了其適用范圍,但是提供了足夠的效率,使得實際上沒有理由在合適的情況下不用這個框架而去重新建造一個。
AQS類使用單個int
(32位)來保存同步狀態,并暴露出getState
、setState
以及compareAndSet
操作來讀取和更新這個狀態。這些方法都依賴于j.u.c.atomic包的支持,這個包提供了兼容JSR133中volatile
在讀和寫上的語義,并且通過使用本地的compare-and-swap或load-linked/store-conditional指令來實現compareAndSetState
,使得僅當同步狀態擁有一個期望值的時候,才會被原子地設置成新值。
將同步狀態限制為一個32位的整形是出于實踐上的考量。雖然JSR166也提供了64位long
字段的原子性操作,但這些操作在很多平臺上還是使用內部鎖的方式來模擬實現的,這會使同步器的性能可能不會很理想。當然,將來可能會有一個類是專門使用64位的狀態的。然而現在就引入這么一個類到這個包里并不是一個很好的決定
(譯者注:
JDK1.6中已經包含java.util.concurrent.locks.AbstractQueuedLongSynchronizer類
即使用 long 形式維護同步狀態的一個 AbstractQueuedSynchronizer
版本)。目前來說,32位的狀態對大多數應用程序都是足夠的。在j.u.c包中,只有一個同步器類可能需要多于32位來維持狀態,那就是CyclicBarrier
類,所以,它用了鎖(該包中大多數更高層次的工具亦是如此)。
基于AQS的具體實現類必須根據暴露出的狀態相關的方法定義tryAcquire
和tryRelease
方法,以控制acquire和release操作。當同步狀態滿足時,tryAcquire
方法必須返回true
,而當新的同步狀態允許后續acquire時,tryRelease
方法也必須返回true
。這些方法都接受一個int
類型的參數用于傳遞想要的狀態。例如:可重入鎖中,當某個線程從條件等待中返回,然后重新獲取鎖時,為了重新建立循環計數的場景。很多同步器并不需要這樣一個參數,因此忽略它即可。
在JSR166之前,阻塞線程和解除線程阻塞都是基于Java內置監視器,沒有基于Java API可以用來創建同步器。唯一可以選擇的是Thread.suspend
和Thread.resume
,但是它們都有無法解決的競態問題,所以也沒法用:當一個非阻塞的線程在一個正準備阻塞的線程調用suspend
前調用了resume
,這個resume
操作將不會有什么效果。
j.u.c包有一個LockSuport
類,這個類中包含了解決這個問題的方法。方法LockSupport.park
阻塞當前線程除非/直到有個LockSupport.unpark
方法被調用(unpark
方法被提前調用也是可以的)。unpark
的調用是沒有被計數的,因此在一個park
調用前多次調用unpark
方法只會解除一個park
操作。另外,它們作用于每個線程而不是每個同步器。一個線程在一個新的同步器上調用park操作可能會立即返回,因為在此之前可能有“剩余的”unpark
操作。但是,在缺少一個unpark
操作時,下一次調用park就會阻塞。雖然可以顯式地消除這個狀態(譯者注:就是多余的unpark
調用),但并不值得這樣做。在需要的時候多次調用park
會更高效。
這個簡單的機制與有些用法在某種程度上是相似的,例如Solaris-9的線程庫,WIN32中的“可消費事件”,以及Linux中的NPTL線程庫。因此最常見的運行Java的平臺上都有相對應的有效實現。(但目前Solaris和Linux上的Sun Hotspot JVM參考實現實際上是使用一個pthread的condvar來適應目前的運行時設計的)。park
方法同樣支持可選的相對或絕對的超時設置,以及與JVM的Thread.interrupt
結合 ,可通過中斷來unpark
一個線程。
整個框架的關鍵就是如何管理被阻塞的線程的隊列,該隊列是嚴格的FIFO隊列,因此,框架不支持基于優先級的同步。
同步隊列的最佳選擇是自身沒有使用底層鎖來構造的非阻塞數據結構,目前,業界對此很少有爭議。而其中主要有兩個選擇:一個是Mellor-Crummey和Scott鎖(MCS鎖)[9]的變體,另一個是Craig,Landin和Hagersten鎖(CLH鎖)[5][8][10]的變體。一直以來,CLH鎖僅被用于自旋鎖。但是,在這個框架中,CLH鎖顯然比MCS鎖更合適。因為CLH鎖可以更容易地去實現“取消(cancellation)”和“超時”功能,因此我們選擇了CLH鎖作為實現的基礎。但是最終的設計已經與原來的CLH鎖有較大的出入,因此下文將對此做出解釋。
CLH隊列實際上并不那么像隊列,因為它的入隊和出隊操作都與它的用途(即用作鎖)緊密相關。它是一個鏈表隊列,通過兩個字段head
和tail
來存取,這兩個字段是可原子更新的,兩者在初始化時都指向了一個空節點。
一個新的節點,node,通過一個原子操作入隊:
do { pred = tail; } while(!tail.compareAndSet(pred, node));
每一個節點的“釋放”狀態都保存在其前驅節點中。因此,自旋鎖的“自旋”操作就如下:
while (pred.status != RELEASED); // spin
自旋后的出隊操作只需將head字段指向剛剛得到鎖的節點:
head = node;
CLH鎖的優點在于其入隊和出隊操作是快速、無鎖的,以及無障礙的(即使在競爭下,某個線程總會贏得一次插入機會而能繼續執行);且探測是否有線程正在等待也很快(只要測試一下head是否與tail相等);同時,“釋放”狀態是分散的(譯者注:幾乎每個節點都保存了這個狀態,當前節點保存了其后驅節點的“釋放”狀態,因此它們是分散的,不是集中于一塊的。),避免了一些不必要的內存競爭。
在原始版本的CLH鎖中,節點間甚至都沒有互相鏈接。自旋鎖中,pred
變量可以是一個局部變量。然而,Scott和Scherer證明了通過在節點中顯式地維護前驅節點,CLH鎖就可以處理“超時”和各種形式的“取消”:如果一個節點的前驅節點取消了,這個節點就可以滑動去使用前面一個節點的狀態字段。
為了將CLH隊列用于阻塞式同步器,需要做些額外的修改以提供一種高效的方式定位某個節點的后繼節點。在自旋鎖中,一個節點只需要改變其狀態,下一次自旋中其后繼節點就能注意到這個改變,所以節點間的鏈接并不是必須的。但在阻塞式同步器中,一個節點需要顯式地喚醒(unpark
)其后繼節點。
AQS隊列的節點包含一個next
鏈接到它的后繼節點。但是,由于沒有針對雙向鏈表節點的類似compareAndSet
的原子性無鎖插入指令,因此這個next
鏈接的設置并非作為原子性插入操作的一部分,而僅是在節點被插入后簡單地賦值:
pred.next = node;
next
鏈接僅是一種優化。如果通過某個節點的next
字段發現其后繼結點不存在(或看似被取消了),總是可以使用pred
字段從尾部開始向前遍歷來檢查是否真的有后續節點。
第二個對CLH隊列主要的修改是將每個節點都有的狀態字段用于控制阻塞而非自旋。在同步器框架中,僅在線程調用具體子類中的tryAcquire
方法返回true
時,隊列中的線程才能從acquire
操作中返回;而單個“released”位是不夠的。但仍然需要做些控制以確保當一個活動的線程位于隊列頭部時,僅允許其調用tryAcquire
;這時的acquire
可能會失敗,然后(重新)阻塞。這種情況不需要讀取狀態標識,因為可以通過檢查當前節點的前驅是否為head
來確定權限。與自旋鎖不同,讀取head
以保證復制時不會有太多的內存競爭( there is not enough memory contention reading head to warrant replication.)。然而,“取消”狀態必須存在于狀態字段中。
隊列節點的狀態字段也用于避免沒有必要的park
和unpark
調用。雖然這些方法跟阻塞原語一樣快,但在跨越Java和JVM runtime以及操作系統邊界時仍有可避免的開銷。在調用park
前,線程設置一個“喚醒(signal me)”位,然后再一次檢查同步和節點狀態。一個釋放的線程會清空其自身狀態。這樣線程就不必頻繁地嘗試阻塞,特別是在鎖相關的類中,這樣會浪費時間等待下一個符合條件的線程去申請鎖,從而加劇其它競爭的影響。除非后繼節點設置了“喚醒”位(譯者注:源碼中為-1),否則這也可避免正在release的線程去判斷其后繼節點。這反過來也消除了這些情形:除非“喚醒”與“取消”同時發生,否則必須遍歷多個節點來處理一個似乎為null的next
字段。
同步框架中使用的CLH鎖的變體與其他語言中的相比,主要區別可能是同步框架中使用的CLH鎖需要依賴垃圾回收管理節點的內存,這就避免了一些復雜性和開銷。但是,即使依賴GC也仍然需要在確定鏈接字段不再需要時將其置為null。這往往可以與出隊操作一起完成。否則,無用的節點仍然可觸及,它們就沒法被回收。
其它一些更深入的微調,包括CLH隊列首次遇到競爭時才需要的初始空節點的延遲初始化等,都可以在J2SE1.5的版本的源代碼文檔中找到相應的描述。
拋開這些細節,基本的acquire
操作的最終實現的一般形式如下(互斥,非中斷,無超時):
if(!tryAcquire(arg)) { node = create and enqueue new node; pred = node's effective predecessor; while (pred is not head node || !tryAcquire(arg)) { if (pred's signal bit is set) pard() else compareAndSet pred's signal bit to true; pred = node's effective predecessor; } head = node; }
release
操作:
if(tryRelease(arg) && head node's signal bit is set) { compareAndSet head's bit to false; unpark head's successor, if one exist }
acquire
操作的主循環次數依賴于具體實現類中tryAcquire
的實現方式。另一方面,在沒有“取消”操作的情況下,每一個組件的acquire
和release
都是一個O(1)的操作,忽略park
中發生的所有操作系統線程調度。
支持“取消”操作主要是要在acquire
循環里的park
返回時檢查中斷或超時。由超時或中斷而被取消等待的線程會設置其節點狀態,然后unpark
其后繼節點。在有“取消”的情況下,判斷其前驅節點和后繼節點以及重置狀態可能需要O(n)的遍歷(n是隊列的長度)。由于“取消”操作,該線程再也不會被阻塞,節點的鏈接和狀態字段可以被快速重建。
AQS框架提供了一個ConditionObject
類,給維護獨占同步的類以及實現Lock
接口的類使用。一個鎖對象可以關聯任意數目的條件對象,可以提供典型的管程風格的await
、signal
和signalAll
操作,包括帶有超時的,以及一些檢測、監控的方法。
通過修正一些設計決策,ConditionObject
類有效地將條件(conditions)與其它同步操作結合到了一起。該類只支持Java風格的管程訪問規則,這些規則中,僅當當前線程持有鎖且要操作的條件(condition)屬于該鎖時,條件操作才是合法的(一些替代操作的討論參考[4])。這樣,一個ConditionObject
關聯到一個ReentrantLock
上就表現的跟內置的管程(通過Object.wait
等)一樣了。兩者的不同僅僅在于方法的名稱、額外的功能以及用戶可以為每個鎖聲明多個條件。
ConditionObject
使用了與同步器一樣的內部隊列節點。但是,是在一個單獨的條件隊列中維護這些節點的。signal
操作是通過將節點從條件隊列轉移到鎖隊列中來實現的,而沒有必要在需要喚醒的線程重新獲取到鎖之前將其喚醒。
基本的await
操作如下:
create and add new node to conditon queue; release lock; block until node is on lock queue; re-acquire lock;
signal
操作如下:
transfer the first node from condition queue to lock queue;
因為只有在持有鎖的時候才能執行這些操作,因此他們可以使用順序鏈表隊列操作來維護條件隊列(在節點中用一個nextWaiter
字段)。轉移操作僅僅把第一個節點從條件隊列中的鏈接解除,然后通過CLH插入操作將其插入到鎖隊列上。
實現這些操作主要復雜在,因超時或Thread.interrupt
導致取消了條件等待時,該如何處理。“取消”和“喚醒”幾乎同時發生就會有競態問題,最終的結果遵照內置管程相關的規范。JSR133修訂以后,就要求如果中斷發生在signal
操作之前,await方法必須在重新獲取到鎖后,拋出InterruptedException
。但是,如果中斷發生在signal
后,await
必須返回且不拋異常,同時設置線程的中斷狀態。
為了維護適當的順序,隊列節點狀態變量中的一個位記錄了該節點是否已經(或正在)被轉移。“喚醒”和“取消”相關的代碼都會嘗試用compareAndSet
修改這個狀態。如果某次signal
操作修改失敗,就會轉移隊列中的下一個節點(如果存在的話)。如果某次“取消”操作修改失敗,就必須中止此次轉移,然后等待重新獲得鎖。后面的情況采用了一個潛在的無限的自旋等待。在節點成功的被插到鎖隊列之前,被“取消”的等待不能重新獲得鎖,所以必須自旋等待CLH隊列插入(即compareAndSet
操作)被“喚醒”線程成功執行。這里極少需要自旋,且自旋里使用Thread.yield
來提示應該調度某一其它線程,理想情況下就是執行signal的那個線程。雖然有可能在這里為“取消”實現一個幫助策略以幫助插入節點,但這種情況實在太少,找不到合適的理由來增加這些開銷。在其它所有的情況下,這個基本的機制都不需要自旋或yield
,因此在單處理器上保持著合理的性能。
AQS類將上述的功能結合到一起,并且作為一種基于“模版方法模式”[6]的基類提供給同步器。子類只需定義狀態的檢查與更新相關的方法,這些方法控制著acquire和 release操作。然而,將AQS的子類作為同步器ADT并不適合,因為這個類必須提供方法在內部控制acquire和release的規則,這些都不應該被用戶所看到。所有java.util.concurrent包中的同步器類都聲明了一個私有的繼承了AbstractQueuedSynchronizer
的內部類,并且把所有同步方法都委托給這個內部類。這樣各個同步器類的公開方法就可以使用適合自己的名稱。
下面是一個最簡單的Mutex
類的實現,它使用同步狀態0表示解鎖,1表示鎖定。這個類并不需要同步方法中的參數,因此這里在調用的時候使用0作為實參,方法實現里將其忽略。
class Mutex { class Sync extends AbstractQueuedSynchronizer { public boolean tryAcquire(int ignore) { return compareAndSetState(0, 1); } public boolean tryRelease(int ignore) { setState(0); return true; } } private final Sync sync = new Sync(); public void lock() { sync.acquire(0); } public void unlock() { sync.release(0); } }
這個例子的一個更完整的版本,以及其它用法指南,可以在J2SE的文檔中找到。還可以有一些變體。如,tryAcquire可以使用一種“test-and-test-and-set”策略,即在改變狀態值前先對狀態進行校驗。
令人詫異的是,像互斥鎖這樣性能敏感的東西也打算通過委托和虛方法結合的方式來定義。然而,這正是現代動態編譯器一直在重點研究的面向對象設計結構。編譯器擅長將這方面的開銷優化掉,起碼會優化頻繁調用同步器的那些代碼。
AbstractQueuedSynchronizer
類也提供了一些方法用來協助策略控制。例如,基礎的acquire方法有可超時和可中斷的版本。雖然到目前為止,我們的討論都集中在像鎖這樣的獨占模式的同步器上,但AbstractQueuedSynchronizer
類也包含另一組方法(如acquireShared
),它們的不同點在于tryAcquireShared
和tryReleaseShared
方法能夠告知框架(通過它們的返回值)尚能接受更多的請求,最終框架會通過級聯的signal(cascading signals)喚醒多個線程。
雖然將同步器序列化(持久化存儲或傳輸)一般來說沒有太大意義,但這些類經常會被用于構造其它類,例如線程安全的集合,而這些集合通常是可序列化的。AbstractQueuedSynchronizer
和ConditionObject
類都提供了方法用于序列化同步狀態,但不會序列化潛在的被阻塞的線程,也不會序列化其它內部暫時性的簿記(bookkeeping)變量。即使如此,在反序列化時,大部分同步器類也只僅將同步狀態重置為初始值,這與內置鎖的隱式策略一致 —— 總是反序列化到一個解鎖狀態。這相當于一個空操作,但仍必須顯式地支持以便final
字段能夠反序列化。
盡管同步器是基于FIFO隊列的,但它們并不一定就得是公平的。可以注意到,在基礎的acquire算法(3.3節)中,tryAcquire
是在入隊前被執行的。因此一個新的acquire線程能夠“竊取”本該屬于隊列頭部第一個線程通過同步器的機會。
可闖入的FIFO策略通常會提供比其它技術更高的總吞吐率。當一個有競爭的鎖已經空閑,而下一個準備獲取鎖的線程又正在解除阻塞的過程中,這時就沒有線程可以獲取到這個鎖,如果使用闖入策略,則可減少這之間的時間間隔。與此同時,這種策略還可避免過分的,無效率的競爭,這種競爭是由于只允許一個(第一個)排隊的線程被喚醒然后嘗試acquire操作導致的。在只要求短時間持有同步器的場景中,創建同步器的開發者可以通過定義tryAcquire
在控制權返回之前重復調用自己若干次,來進一步凸顯闖入的效果。
可闖入的FIFO同步器只有概率性的公平屬性。鎖隊列頭部一個解除了阻塞的線程擁有一次無偏向的機會(譯者注:即不會偏向隊頭的線程也不會偏向闖入的線程)來贏得與闖入的線程之間的競爭,如果競爭失敗,要么重新阻塞要么進行重試。然而,如果闖入的線程到達的速度比隊頭的線程解除阻塞快,那么在隊列中的第一個線程將很難贏得競爭,以至于幾乎總要重新阻塞,并且它的后繼節點也會一直保持阻塞。對于短暫持有的同步器來說,在隊列中第一個線程被解除阻塞期間,多處理器上很可能發生過多次闖入(譯者注:即闖入的線程的acquire
操作)和release
了。正如下文所提到的,最終結果就是保持一或多個線程的高進展速度的同時,仍至少在一定概率上避免了饑餓的發生。
當有更高的公平性需求時,實現起來也很簡單。如果需要嚴格的公平性,程序員可以把tryAcquire方法定義為,若當前線程不是隊列的頭節點(可通過getFirstQueuedThread
方法檢查,這是框架提供的為數不多的幾個檢測方法之一),則立即失敗(返回false)。
一個更快,但非嚴格公平的變體可以這樣做,若隊列為空(判斷的瞬間),仍然允許tryAcquire
執行成功。在這種情況下,多個線程同時遇到一個空隊列時可能會去競爭以使自己第一個獲得鎖,這樣,通常至少有一個線程是無需入隊列的。java.util.concurrent
包中所有支持公平模式的同步器都采用了這種策略。
盡管公平性設置在實踐中很有用,但是它們并沒有保障,因為Java Language Specification沒有提供這樣的調度保證。例如:即使是嚴格公平的同步器,如果一組線程永遠不需要阻塞來達到互相等待,那么JVM可能會決定純粹以順序方式運行它們。在實際中,單處理器上,在搶占式上下文切換之前,這樣的線程有可能是各自運行了一段時間。如果這樣一個線程正持有某個互斥鎖,它將很快會被切換回來,僅是為了釋放其持有的鎖,然后會繼續阻塞,因為它知道有另外一個線程需要這把鎖,這更增加了同步器可用但沒有線程能來獲取之間的間隔。同步器公平性設置在多處理器上的影響可能會更大,因為在這種環境下會產生更多的交錯,因此一個線程就會有更多的機會發現鎖被另一個線程請求。
在高競爭下,當保護的是短暫持有鎖的代碼體時,盡管性能可能會較差,但公平鎖仍然能有效地工作。例如,當公平性鎖保護的是相對長的代碼體和/或有著相對長的鎖間(inter-lock)間隔,在這種情況下,闖入只能帶來很小的性能優勢,但卻可能會大大增加無限等待的風險。同步器框架將這些工程決策留給用戶來確定。
下面是java.util.concurrent
包中同步器定義方式的概述:
ReentrantLock
類使用AQS同步狀態來保存鎖(重復)持有的次數。當鎖被一個線程獲取時,ReentrantLock
也會記錄下當前獲得鎖的線程標識,以便檢查是否是重復獲取,以及當錯誤的線程(譯者注:如果線程不是鎖的持有者,在此線程中執行該鎖的unlock
操作就是非法的)試圖進行解鎖操作時檢測是否存在非法狀態異常。ReentrantLock
也使用了AQS提供的ConditionObject,還向外暴露了其它監控和監測相關的方法。ReentrantLock
通過在內部聲明兩個不同的AbstractQueuedSynchronizer
實現類(提供公平模式的那個禁用了闖入策略)來實現可選的公平模式,在創建ReentrantLock實例的時候根據設置(譯者注:即ReentrantLock
構造方法中的fair
參數)使用相應的AbstractQueuedSynchronizer
實現類。
ReentrantReadWriteLock
類使用AQS同步狀態中的16位來保存寫鎖持有的次數,剩下的16位用來保存讀鎖的持有次數。WriteLock
的構建方式同ReentrantLock
。ReadLock
則通過使用acquireShared
方法來支持同時允許多個讀線程。
Semaphore
類(計數信號量)使用AQS同步狀態來保存信號量的當前計數。它里面定義的acquireShared方法會減少計數,或當計數為非正值時阻塞線程;tryRelease
方法會增加計數,可能在計數為正值時還要解除線程的阻塞。
CountDownLatch
類使用AQS同步狀態來表示計數。當該計數為0時,所有的acquire操作(譯者注:acquire操作是從aqs的角度說的,對應到CountDownLatch
中就是await
方法)才能通過。
FutureTask
類使用AQS同步狀態來表示某個異步計算任務的運行狀態(初始化、運行中、被取消和完成)。設置(譯者注:FutureTask
的set
方法)或取消(譯者注:FutureTask
的cancel
方法)一個FutureTask
時會調用AQS的release
操作,等待計算結果的線程的阻塞解除是通過AQS的acquire
操作實現的。
SynchronousQueues
類(一種CSP(Communicating Sequential Processes)形式的傳遞)使用了內部的等待節點,這些節點可以用于協調生產者和消費者。同時,它使用AQS同步狀態來控制當某個消費者消費當前一項時,允許一個生產者繼續生產,反之亦然。
java.util.concurrent
包的使用者當然也可以為自定義的應用定義自己的同步器。例如,那些曾考慮到過的,但沒有采納進這個包的同步器包括提供WIN32事件各種風格的語義類,二元信號量,集中管理的鎖以及基于樹的屏障。
雖然AQS框架除了支持互斥鎖外,還支持其它形式的同步方式,但鎖的性能是最容易測量和比較的。即使如此,也還存在許多不同的測量方式。這里的實驗主要是設計來展示鎖的開銷和吞吐量。
在每個測試中,所有線程都重復的更新一個偽隨機數,該隨機數由nextRandom(int seed)
方法計算:
int t = (seed % 127773) * 16807 - (seed / 127773) * 2836; return (t > 0) ? t : t + 0x7fffffff;
在每次迭代中,線程以概率S在一個互斥鎖下更新共享的生成器,否則(譯者注:概率為1-S)更新其自己局部的生成器,此時是不需要鎖的。如此,鎖占用區域的耗時是短暫的,這就使線程持有鎖期間被搶占時的外界干擾降到了最小。這個函數的隨機性主要是為了兩個目的:確定是否需要使用鎖(這個生成器足以應付這里的需求),以及使循環中的代碼不可能被輕易地優化掉。
這里比較了四種鎖:內置鎖,用的是synchronized
塊;互斥鎖,用的是像第四節例子中的那樣簡單的Mutex類;可重入鎖,用的是ReentrantLock
;以及公平鎖,用的是ReentrantLock
的公平模式。所有測試都運行在J2SE1.5 JDK build46(大致與beta2相同)的server模式下。在收集測試數據前,測試程序先運行20次非競爭的測試,以排除JVM“預熱”(譯者注:更多關于“預熱”的內容,參見:Java 理論與實踐: 動態編譯與性能測量)過程的影響。除了公平模式下的測試只跑了一百萬次迭代,其它每個線程中的測試都運行了一千萬次迭代。
該測試運行在四個X86機器和四個UltraSparc機器上。所有X86機器都運行的是RedHat基于NPTL 2.4內核和庫的Linux系統。所有的UltraSparc機器都運行的是Solaris-9。測試時所有系統的負載都很輕。根據該測試的特征,并不要求系統完全空閑(譯者注:即測試時操作系統上有其它較輕的負載也不會影響本次測試的結果。)。“4P”這個名字反映出雙核超線程的Xeon更像是4路機器,而不是2路機器。這里沒有將測試數據規范化。如下所示,同步的相對開銷與處理器的數量、類型、速度之間不具備簡單的關系。
表1 測試的平臺
名字 | 處理器數量 | 類型 | 速度(Mhz) |
---|---|---|---|
1P | 1 | Pentium3 | 900 |
2P | 2 | Pentium3 | 1400 |
2A | 2 | Athlon | 2000 |
4P | 2HT | Pentium4/Xeon | 2400 |
1U | 1 | UltraSparc2 | 650 |
4U | 4 | UltraSparc2 | 450 |
8U | 8 | UltraSparc3 | 750 |
24U | 24 | UltraSparc3 | 750 |
無競爭情況下的開銷是通過僅運行一個線程,將概率S為1時的每次迭代時間減去概率S為0(訪問共享內存的概率為0)時的每次迭代時間得到的(譯者注:這里的“概率S”即前文提到的“概率S”,概率為0時是沒有鎖操作的,概率為1時是每次都有鎖操作,因此將概率為1時的耗時減去概率為0時的耗時就是整個鎖操作的開銷。)。表2以納秒為單位顯示了非競爭場景下每次鎖操作的開銷。Mutex類最接近于框架的基本耗時,可重入鎖的額外開銷是記錄當前所有者線程和錯誤檢查的耗時,對于公平鎖來說還包含開始時檢查隊列是否為空的耗時。
表格2也展示與內置鎖的“快速路徑(fast path)”對比,tryAcquire
的耗時。這里的差異主要反映出了各鎖和機器中使用的不同的原子指令以及內存屏障的耗時。在多處理器上,這些指令常常是完全優于所有其它指令的。內置鎖和同步器類之間的主要差別,顯然是由于Hotspot鎖在鎖定和解鎖時都使用了一次compareAndSet
,而同步器的acquire
操作使用了一次compareAndSet
,但release
操作用的是一次volatile
寫(即,多處理器上的一次內存屏障以及所有處理器上的重排序限制)。每個鎖的絕對的和相對耗時因機器的不同而不同。
表2 無競爭時的單鎖開銷(單位:納秒)
機器 | 內置 | 互斥 | 可重入 | 公平可重入 |
---|---|---|---|---|
1P | 18 | 9 | 31 | 37 |
2P | 58 | 71 | 77 | 81 |
2A | 13 | 21 | 31 | 30 |
4P | 116 | 95 | 109 | 117 |
1U | 90 | 40 | 58 | 67 |
4U | 122 | 82 | 100 | 115 |
8U | 160 | 83 | 103 | 123 |
24U | 161 | 84 | 108 | 119 |
從另一個極端看,表3展示了概率S為1,運行256個并發線程時產生了大規模的鎖競爭下每個鎖的開銷。在完全飽和的情況下,可闖入的FIFO鎖比內置鎖的開銷少了一個數量級(也就是更大的吞吐量),比公平鎖更是少了兩個數量級。這表現出即使有著極大的競爭,在維持線程進展方面可闖入FIFO策略的效率。
表3也說明了即使在內部開銷比較低的情況下,公平鎖的性能也完全是由上下文切換的時間所決定的。列出的時間大致上都與各平臺上線程阻塞和解除線程阻塞的時間相稱。
此外,后面增加的一個實驗(僅使用機器4P)表明,對于這里用到的短暫持有的鎖,公平參數的設置在總差異中的影響很小。這里將線程終止時間間的差異記錄成一個粗粒度的離散量數。在4P的機器上,公平鎖的時間度量的標準差平均為0.7%,可重入鎖平均為6.0%。作為對比,為模擬一個長時間持有鎖的場景,測試中使每個線程在持有鎖的情況下計算了16K次隨機數。這時,總運行時間幾乎是相同的(公平鎖:9.79s,可重入鎖:9.72s)。公平模式下的差異依然很小,標準差平均為0.1%,而可重入鎖上升到了平均29.5%。
表格3 飽和時的單鎖開銷(單位:納秒)
機器 | 內置 | 互斥 | 可重入 | 公平可重入 |
---|---|---|---|---|
1P | 521 | 46 | 67 | 8327 |
2P | 930 | 108 | 132 | 14967 |
2A | 748 | 79 | 84 | 33910 |
4P | 1146 | 188 | 247 | 15328 |
1U | 879 | 153 | 177 | 41394 |
4U | 2590 | 347 | 368 | 30004 |
8U | 1274 | 157 | 174 | 31084 |
24U | 1983 | 160 | 182 | 32291 |
大部分同步器都是用于無競爭和極大競爭之間的。這可以用實驗在兩個方面進行檢查,通過修改固定個線程的競爭概率,和/或通過往擁有固定競爭概率的線程集合里增加更多的線程。為了說明這些影響,測試運行在不同的競爭概率和不同的線程數目下,都用的是可重入鎖。附圖使用了一個slowdown度量標準。
這里,t是總運行時間,b是一個線程在沒有競爭或同步下的基線時間,n是線程數,p是處理器數,S是共享訪問的比例(譯者注:即前面的競爭概率S)。計算結果是實際執行時間與理想執行時間(通常是無法得到的)的比率,理想執行時間是通過使用Amdahl’s法則計算出來的。理想時間模擬了一次沒有同步開銷,沒有因鎖爭用而導致線程阻塞的執行過程。即使這樣,在很低的競爭下,相比理想時間,有一些測試結果卻表現出了很小的速度增長,大概是由于基線和測試之間的優化、流水線等方面有著輕微的差別。
圖中用以2為底的對數為比例進行了縮放。例如,值為1表示實際時間是理想時間的兩倍,4表示慢16倍。使用對數就不需要依賴一個隨意的基線時間(這里指的是計算隨機數的時間),因此,基于不同底數計算的結果表現出的趨勢應該是類似的。這些測試使用的競爭概率從1/128(標識為“0.008”)到1,以2的冪為步長,線程的數量從1到1024,以2的冪的一半為步長。
在單處理器(1P和1U)上,性能隨著競爭的上升而下降,但不會隨著線程數的增加而下降。多處理器在遭遇競爭時,性能下降的更快。根據多處理器相關的圖表顯示,開始出現的峰值處雖然只有幾個線程的競爭,但相對性能通常卻最差。這反映出了一個性能的過渡區域,在這里闖入的線程和被喚醒的線程都準備獲取鎖,這會讓它們頻繁的迫使對方阻塞。在大部分時候,過渡區域后面會緊接著一個平滑區域,因為此時幾乎沒有空閑的鎖,所以會與單處理器上順序執行的模式差不多;在多處理器機器上會較早進入平滑區域。例如,請注意,在滿競爭(標識為“1.000”)下這些值表示,在處理器越少的機器上,會有更糟糕的相對速度下降。
根據這些結果,可以針對阻塞(park/unpark)做進一步調優以減少上下文切換和相關的開銷,這會給本框架帶來小但顯著的提升。此外,在多處理器上為短時間持有的但高競爭的鎖采用某種形式的適應性自旋,可以避免這里看到的一些波動,這對同步器類大有裨益。雖然在跨不同上下文時適應性自旋很難很好的工作,但可以使用本框架為遇到這類使用配置的特定應用構建一個自定義形式的鎖。
以上是“java并發包JUC同步器框架AQS的示例分析”這篇文章的所有內容,感謝各位的閱讀!相信大家都有了一定的了解,希望分享的內容對大家有所幫助,如果還想學習更多知識,歡迎關注億速云行業資訊頻道!
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。