標籤:悲觀鎖 樂觀鎖 concurrent 並發 競爭
本片文章嘗試從另一個層面來瞭解我們常見的同步(synchronized)和鎖(lock)機制。如果讀者想深入瞭解並發方面的知識推薦一本書《java並發編程實戰》,非常經典的一本書,英語水平好的同學也可以讀一讀《Concurrent programming in Java - design principles and patterns》由Doug Lea親自操刀,Doug Lea是並發方面的大神,jdk的並發包就是由他完成的。
我們都知道在java中被synchronized修飾的代碼被稱為同步代碼塊,同步代碼塊意味著同一時刻只有一個線程執行,其他線程都被排斥在該同步塊之外,並且訪問也是按照某種順序執行的。實際上synchronized是基於監視器實現的,每一個執行個體和類都擁有一個監視器,通常我們說的“鎖”的動作就是擷取該監視器。因此通常我們講synchronized是基於JVM層面的,使用的是對象內建的鎖。靜態方法鎖住的是該class的監視器,執行個體方法鎖住的是對應執行個體的監視器。同步是使用monitorenter和monitorexit指令實現的,monitorenter嘗試擷取對象的鎖,如果該對象沒被鎖定或者當前線程已經擷取了鎖,則把鎖的計數器+1,同樣monitorexit把鎖的計數器-1。因此synchronized對於同一個線程是可重新進入的。
監視器支援兩種線程:互斥(sync)和協作。java通過對象的鎖實現對臨界區的互斥訪問,使用Object的wait(),notify(),notifyAll()方法來實現。
樂觀鎖和悲觀鎖
這兩個名字很多地方都出現過,所謂的樂觀鎖就是當去做某個修改或其他動作的時候它認為不會有其他線程來做同樣的操作(競爭),這是一種樂觀的態度,通常是基於CAS原子指令來實現的。關於CAS可以參見這篇文章java並發包的CAS操作,CAS通常不會將線程掛起,因此有時效能會好一些。(線程的切換是挺耗效能的一個操作)。
悲觀鎖,根據樂觀鎖的定義很容易理解悲觀鎖是認為肯定有其他線程來爭奪資源,因此不管到底會不會發生爭奪,悲觀鎖總是會先去鎖住資源。
以前的synchronized都是會阻塞線程的,就是說會發生環境切換,從使用者態切換到核心態,由於這種方式有時候太耗費資源,因此後來又出現了自旋鎖,所謂自旋其實就是如果鎖已經被其他線程佔有,當前線程並不會掛起,而是做空操作,自旋其實從某種程度來說是樂觀鎖,因為它總是認為下次會得到鎖的。因此自旋鎖適合在競爭不激烈的情況下使用,據瞭解目前的jvm針對synchronized已經有了這方面的最佳化。
自旋的使用也是分情境的,有可能線程自旋很久也沒擷取到鎖,那麼CPU就白白被浪費了,還不如掛起線程,因此有出現了自適應的自旋鎖,它會更具曆史的自旋是否擷取到鎖的記錄來判斷自旋的時間或者是否需要自旋。
輕量級鎖
輕量級鎖的概念是相對需要互斥操作的重量級鎖而言,輕量級鎖的目的是減少多線程的互斥幾率,並不是要代替互斥。要想瞭解輕量級鎖和後面講到的偏向鎖必須先瞭解下對象頭的記憶體布局。下面這張圖就是Object Header的記憶體布局:
初始都是01表示無鎖,00表示輕量級鎖,10表示重量級鎖等等。在代碼進入同步塊的時候,如果此同步對象沒有被鎖定(鎖標誌位為“01”狀態),虛擬機器首先將在當前線程的棧幀中建立一個名為鎖記錄(Lock Record)的空間,用於儲存鎖對象目前的Mark Word的拷貝(官方把這份拷貝加了一個Displaced首碼,即Displaced Mark Word),然後虛擬機器嘗試利用CAS操作將對象的輕量級指標指向棧的lock record,如果更新成功當前線程擷取到鎖,並且標記為00輕量級鎖。如果這個更新操作失敗了,虛擬機器首先會檢查對象的Mark Word是否指向當前線程的棧幀,如果是就說明當前線程已經擁有了這個對象的鎖,那就可以直接進入同步塊繼續執行,否則說明這個鎖對象已經被其他線程搶佔了。如果有兩條以上的線程爭用同一個鎖,那輕量級鎖就不再有效,要膨脹為重量級鎖,鎖標誌的狀態值變為“10”,Mark Word中儲存的就是指向重量級鎖(互斥量)的指標,後面等待鎖的線程也要進入阻塞狀態。
偏向鎖
偏向鎖就是偏心的意思,當鎖被某個線程第一次擷取到得時候,會在對象頭記錄擷取到該鎖的線程id,以後每次該線程進入同步塊的時候都不需要加鎖,如果一旦有其他線程擷取到該鎖,則偏向鎖模式宣告失敗,鎖撤銷回未鎖定或輕量級鎖狀態。偏向鎖的作用就是完全消除鎖,連CAS操作都不做。
下面來看一下線程在進入同步塊和出同步塊的狀態轉換。
當多個線程同時請求某個對象監視器時,對象監視器會設定幾種狀態用來區分請求的線程:
- Contention List:所有請求鎖的線程將被首先放置到該競爭隊列
- Entry List:Contention List中那些有資格成為候選人的線程被移到Entry List
- Wait Set:那些調用wait方法被阻塞的線程被放置到Wait Set
- OnDeck:任何時刻最多隻能有一個線程正在競爭鎖,該線程稱為OnDeck
- Owner:獲得鎖的線程稱為Owner
- !Owner:釋放鎖的線程
下面是一位網友畫得圖很形象:
新請求的線程會被放置到ContentionList中,當某個Owner釋放鎖的時候,如果EntryList是空則Owner會從ContentionList中移動線程到EntryList。顯然,ContentionList結構其實是個Lock-Free的隊列,因為只有Owner才會從ContentionList取節點。
EntryList與ContentionList邏輯上同屬等待隊列,ContentionList會被線程並發訪問,為了降低對ContentionList隊尾的爭用,而建立EntryList。Owner線程在unlock時會從ContentionList中遷移線程到EntryList,並會指定EntryList中的某個線程(一般為Head)為Ready(OnDeck)線程。Owner線程並不是把鎖傳遞給OnDeck線程,只是把競爭鎖的權利交給OnDeck,OnDeck線程需要重新競爭鎖。這樣做雖然犧牲了一定的公平性,但極大的提高了整體輸送量,在Hotspot中把OnDeck的選擇行為稱之為“競爭切換”。
可重新進入鎖
可重新進入鎖的最大好處是可以避免思索,因為對於已經擷取到鎖的線程,不需要再一次去擷取鎖了,只需要將計數器+1即可,實際上synchronized也是可重新進入鎖的一種。但是本節我們要講的是並發包中的ReentrantLock及其實現。synchronized是JVM層面提供的鎖,而在java的語言層面jdk也為我們提供了非常優秀的鎖,這些鎖都在java.util.concurren包中。
先來看一下JVM提供的鎖和並發包中的鎖有哪些區別:
1.synchronized的加鎖和釋放都是由JVM提供,不需要我們關注,而lock的加鎖和釋放全部由我們去控制,通常釋放鎖的動作要在finally中實現。
2.synchronized只有一個狀態條件,也就是每個對象只有一個監視器,如果需要多個Condition的組合那麼synchronized是無法滿足的,而lock則提供了多條件的互斥,非常靈活。
3.ReentrantLock 擁有Synchronized相同的並發性和記憶體語義,此外還多了 鎖投票,定時鎖等候和中斷鎖等候。
在講解ReentrantLock之前,先來看下不AtomicInteger原始碼大體瞭解下它的實現原理。
/** * Atomically increments by one the current value. * * @return the updated value */ //該方法類似同步版本的i++,先將當前值+1,然後返回, //可以看到是一個for迴圈,只有當compareAndSet成功才會返回 //那麼什麼時候成功呢? public final int incrementAndGet() { for (;;) { int current = get();//volatile類型的變數,因此每次擷取都是最新值 int next = current + 1;//加1操作 if (compareAndSet(current, next))//關鍵的是if中的方法 //如果compareAndSet成功,則整個加操作成功,如果失敗,則說明有其他線程已經修改了value //那麼會進行下一輪的加1操作,直到成功 return next; } }/** * Gets the current value. * * @return the current value */ //get方法很簡單,返回value,這個value是類的成員變數,並且是volatile的 public final int get() { return value; } /** * Atomically sets the value to the given updated value * if the current value {@code ==} the expected value. * * @param expect the expected value * @param update the new value * @return true if successful. False return indicates that * the actual value was not equal to the expected value. */ public final boolean compareAndSet(int expect, int update) { //繼續跟蹤unsafe的方法,發現並沒提供,實際上該方法是個基於本地類庫的原子方法,使用一個指令即可完成操作。//如果記憶體中的值和預期的值相同,也就是沒有其他線程修改過該值,則更新該值為預期的值,返回成功,否則返回失敗return unsafe.compareAndSwapInt(this, valueOffset, expect, update); }
可以預見的是如果競爭非常激烈,則失敗的機率會大大增加,效能也會受到影響。實際上並發包中的鎖大多是基於CAS操作完成的,本節打算講解可重新進入鎖,但是需要瞭解的東西還非常多,只好重新寫一篇來介紹ReentrantLock了。
深入理解java同步、鎖機制