這兩天一直在準備面試,看了很多篇關于鎖的介紹的博客,今天就總結一下。
首先需要知道幾個名詞:
- 公平鎖/非公平鎖
- 可重入鎖
- 獨享鎖/共享鎖
- 互斥鎖/讀寫鎖
- 樂觀鎖/悲觀鎖
- 分段鎖
- 偏向鎖/輕量級鎖/重量級鎖
- 自旋鎖
公平鎖/非公平鎖:
公平鎖是指多個線程按照申請鎖的順序來獲取鎖。
非公平鎖是指多個線程獲取鎖的順序并不是按照申請鎖的順序,有可能后申請的線程比先申請的線程優先獲取鎖。有可能,會造成優先級反轉或者饑餓現象。
可重入鎖:
可重入鎖又名遞歸鎖,是指在同一個線程在外層方法獲取鎖的時候,在進入內層方法會自動獲取鎖。
// 此處用代碼演示了可重入鎖的代碼層意思
synchronized void setA() throws Exception{
Thread.sleep(1000);
setB(); // 因為獲取了setA()的鎖(即獲取了方法外層的鎖)、此時調用setB()將會自動獲取setB()的鎖,如果不自動獲取的話方法B將不會執行
}
synchronized void setB() throws Exception{
Thread.sleep(1000);
}
獨享鎖/共享鎖:
獨享鎖是指該鎖一次只能被一個線程所持有。
共享鎖是指該鎖可被多個線程所持有。
互斥鎖/讀寫鎖
上面講的獨享鎖/共享鎖就是一種廣義的說法,互斥鎖/讀寫鎖就是具體的實現。
樂觀鎖/悲觀鎖
樂觀鎖與悲觀鎖不是指具體的什么類型的鎖,而是指看待并發同步的角度。
悲觀鎖認為對于同一個數據的并發操作,一定是會發生修改的,哪怕沒有修改,也會認為修改。因此對于同一個數據的并發操作,悲觀鎖采取加鎖的形式。悲觀的認為,不加鎖的并發操作一定會出問題。
樂觀鎖則認為對于同一個數據的并發操作,是不會發生修改的。在更新數據的時候,會采用嘗試更新,不斷重新的方式更新數據。樂觀的認為,不加鎖的并發操作是沒有事情的。
從上面的描述我們可以看出,悲觀鎖適合寫操作非常多的場景,樂觀鎖適合讀操作非常多的場景,不加鎖會帶來大量的性能提升。
悲觀鎖在JAVA中的使用,就是利用各種鎖。
樂觀鎖在Java中的使用,是無鎖編程,常常采用的是CAS算法,典型的例子就是原子類,通過CAS自旋實現原子操作的更新。
重量級鎖是悲觀鎖的一種,自旋鎖、輕量級鎖與偏向鎖屬于樂觀鎖
分段鎖
分段鎖其實是一種鎖的設計,并不是具體的一種鎖,對于ConcurrentHashMap而言,其并發的實現就是通過分段鎖的形式來實現高效的并發操作。
我們以ConcurrentHashMap來說一下分段鎖的含義以及設計思想,ConcurrentHashMap中的分段鎖稱為Segment,它即類似于HashMap(JDK7與JDK8中HashMap的實現)的結構,即內部擁有一個Entry數組,數組中的每個元素又是一個鏈表;同時又是一個ReentrantLock(Segment繼承了ReentrantLock)。
當需要put元素的時候,并不是對整個hashmap進行加鎖,而是先通過hashcode來知道他要放在那一個分段中,然后對這個分段進行加鎖,所以當多線程put的時候,只要不是放在一個分段中,就實現了真正的并行的插入。
但是,在統計size的時候,可就是獲取hashmap全局信息的時候,就需要獲取所有的分段鎖才能統計。
分段鎖的設計目的是細化鎖的粒度,當操作不需要更新整個數組的時候,就僅僅針對數組中的一項進行加鎖操作。
偏向鎖/輕量級鎖/重量級鎖
這三種鎖是指鎖的狀態,并且是針對Synchronized。在Java 5通過引入鎖升級的機制來實現高效Synchronized。這三種鎖的狀態是通過對象監視器在對象頭中的字段來表明的。
偏向鎖是指一段同步代碼一直被一個線程所訪問,那么該線程會自動獲取鎖。降低獲取鎖的代價。
偏向鎖的適用場景
始終只有一個線程在執行同步塊,在它沒有執行完釋放鎖之前,沒有其它線程去執行同步塊,在鎖無競爭的情況下使用,一旦有了競爭就升級為輕量級鎖,升級為輕量級鎖的時候需要撤銷偏向鎖,撤銷偏向鎖的時候會導致stop the word操作;
在有鎖的競爭時,偏向鎖會多做很多額外操作,尤其是撤銷偏向所的時候會導致進入安全點,安全點會導致stw,導致性能下降,這種情況下應當禁用;
輕量級鎖是指當鎖是偏向鎖的時候,被另一個線程所訪問,偏向鎖就會升級為輕量級鎖,其他線程會通過自旋的形式嘗試獲取鎖,不會阻塞,提高性能。
重量級鎖是指當鎖為輕量級鎖的時候,另一個線程雖然是自旋,但自旋不會一直持續下去,當自旋一定次數的時候,還沒有獲取到鎖,就會進入阻塞,該鎖膨脹為重量級鎖。重量級鎖會讓其他申請的線程進入阻塞,性能降低。
自旋鎖
在Java中,自旋鎖是指嘗試獲取鎖的線程不會立即阻塞,而是采用循環的方式去嘗試獲取鎖,這樣的好處是減少線程上下文切換的消耗,缺點是循環會消耗CPU。
自旋鎖原理非常簡單,如果持有鎖的線程能在很短時間內釋放鎖資源,那么那些等待競爭鎖的線程就不需要做內核態和用戶態之間的切換進入阻塞掛起狀態,它們只需要等一等(自旋),等持有鎖的線程釋放鎖后即可立即獲取鎖,這樣就避免用戶線程和內核的切換的消耗。
自旋鎖盡可能的減少線程的阻塞,適用于鎖的競爭不激烈,且占用鎖時間非常短的代碼塊來說性能能大幅度的提升,因為自旋的消耗會小于線程阻塞掛起再喚醒的操作的消耗
但是如果鎖的競爭激烈,或者持有鎖的線程需要長時間占用鎖執行同步塊,這時候就不適合使用自旋鎖了,因為自旋鎖在獲取鎖前一直都是占用cpu做無用功,同時有大量線程在競爭一個鎖,會導致獲取鎖的時間很長,線程自旋的消耗大于線程阻塞掛起操作的消耗,其它需要cpu的線程又不能獲取到cpu,造成cpu的浪費。
熟悉幾個概念以后我們開始詳細說一下java中的鎖:
我們所熟知的Java鎖機制無非就是Sychornized 鎖 和 Lock鎖
Synchronized是基于JVM來保證數據同步的,而Lock則是在硬件層面,依賴特殊的CPU指令實現數據同步的
- Synchronized,它就是一個:非公平,悲觀,獨享,互斥,可重入的重量級鎖
- ReentrantLock,它是一個:默認非公平但可實現公平的,悲觀,獨享,互斥,可重入,重量級鎖。
- ReentrantReadWriteLocK,它是一個,默認非公平但可實現公平的,悲觀,寫獨享,讀共享,讀寫,可重入,重量級鎖。
Synchronized的作用:
在JDK1.5之前都是使用synchronized關鍵字保證同步的
它可以把任意一個非NULL的對象當作鎖。
- 作用于方法時,鎖住的是對象的實例(this);
- 當作用于靜態方法時,鎖住的是Class實例,又因為Class的相關數據存儲在永久帶PermGen(jdk1.8則是metaspace),永久帶是全局共享的,因此靜態方法鎖相當于類的一個全局鎖,會鎖所有調用該方法的線程;
- synchronized作用于一個對象實例時,鎖住的是所有以該對象為鎖的代碼塊。
Synchronized的實現:
實現如下圖所示;
它有多個隊列,當多個線程一起訪問某個對象監視器的時候,對象監視器會將這些線程存儲在不同的容器中。
- Contention List:競爭隊列,所有請求鎖的線程首先被放在這個競爭隊列中;
- Entry List:Contention List中那些有資格成為候選資源的線程被移動到Entry List中;
- Wait Set:哪些調用wait方法被阻塞的線程被放置在這里;
- OnDeck:任意時刻,最多只有一個線程正在競爭鎖資源,該線程被成為OnDeck;
- Owner:當前已經獲取到所資源的線程被稱為Owner;
- !Owner:當前釋放鎖的線程。
ContentionList并不是真正意義上的一個隊列。僅僅是一個虛擬隊列,它只有Node以及對應的Next指針構成,并沒有Queue的數據結構。每次新加入Node會在隊頭進行,通過CAS改變第一個節點為新增節點,同時新增階段的next指向后續節點,而取數據都在隊列尾部進行。
JVM每次從隊列的尾部取出一個數據用于鎖競爭候選者(OnDeck),但是并發情況下,ContentionList會被大量的并發線程進行CAS訪問,為了降低對尾部元素的競爭,JVM會將一部分線程移動到EntryList中作為候選競爭線程。Owner線程會在unlock時,將ContentionList中的部分線程遷移到EntryList中,并指定EntryList中的某個線程為OnDeck線程(一般是最先進去的那個線程)。Owner線程并不直接把鎖傳遞給OnDeck線程,而是把鎖競爭的權利交給OnDeck,OnDeck需要重新競爭鎖。這樣雖然犧牲了一些公平性,但是能極大的提升系統的吞吐量,在JVM中,也把這種選擇行為稱之為“競爭切換”。
OnDeck線程獲取到鎖資源后會變為Owner線程,而沒有得到鎖資源的仍然停留在EntryList中。如果Owner線程被wait方法阻塞,則轉移到WaitSet隊列中,直到某個時刻通過notify或者notifyAll喚醒,會重新進去EntryList中。
處于ContentionList、EntryList、WaitSet中的線程都處于阻塞狀態,該阻塞是由操作系統來完成的(linux內核下采用pthread_mutex_lock內核函數實現的)。該線程被阻塞后則進入內核調度狀態,會導致系統在用戶和內核之間進行來回切換,嚴重影響鎖的性能。為了緩解上述性能問題,JVM引入了自旋鎖(上邊已經介紹過自旋鎖)。原理非常簡單,如果Owner線程能在很短時間內釋放鎖資源,那么哪些等待競爭鎖的線程可以稍微等一等(自旋)而不是立即阻塞,當Owner線程釋放鎖后可立即獲取鎖,進而避免用戶線程和內核的切換。但是Owner可能執行的時間會超過設定的閾值,爭用線程在一定時間內還是獲取不到鎖,這是爭用線程會停止自旋進入阻塞狀態?;舅悸肪褪窍茸孕却欢螘r間看能否成功獲取,如果不成功再執行阻塞,盡可能的減少阻塞的可能性,這對于占用鎖時間比較短的代碼塊來說性能能大幅度的提升!
Synchronized在線程進入ContentionList時,等待的線程會先嘗試自旋獲取鎖,如果獲取不到就進入ContentionList,這明顯對于已經進入隊列的線程是不公平的,還有一個不公平的事情就是自旋獲取鎖的線程還可能直接搶占OnDeck線程的鎖資源。
Lock鎖簡介:
與synchronized不同的是,Lock鎖是純Java實現的,與底層的JVM無關。在
java.util.concurrent.locks包中有很多Lock的實現類,常用的有ReentrantLock、ReadWriteLock(實現類ReentrantReadWriteLock),其實現都依賴java.util.concurrent.AbstractQueuedSynchronizer類(簡稱AQS)
Lock的實現:
獲取鎖:首先判斷當前狀態是否允許獲取鎖,如果是就獲取鎖,否則就阻塞操作或者獲取失敗,也就是說如果是獨占鎖就可能阻塞,如果是共享鎖就可能失敗。另外如果是阻塞線程,那么線程就需要進入阻塞隊列。當狀態位允許獲取鎖時就修改狀態,并且如果進了隊列就從隊列中移除。
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;
釋放鎖:這個過程就是修改狀態位,如果有線程因為狀態位阻塞的話,就喚醒隊列中的一個或者更多線程。
update synchronization state; if (state may permit a blocked thread to acquire) unlock one or more queued threads;
阻塞和喚醒,JDK1.5之前的API中并沒有阻塞一個線程,然后在將來的某個時刻喚醒它(wait/notify是基于synchronized下才生效的,在這里不算),JDK5之后利用JNI在LockSupport 這個類中實現了相關的特性!
3、 有序隊列:在AQS中采用CLH隊列來解決隊列的有序問題。
我們來看下ReentrantLock的調用過程
經過源碼分析,我們看到ReentrantLock把所有的Lock都委托給Sync類進行處理,該類繼承自AQS,其類關系圖如下
其中Sync又有兩個final static的子類NonfairSync和FairSync用于支持非公平鎖和公平鎖。我們先來挑一個看下對應Reentrant.lock()的調用過程(默認為非公平鎖)
這些模版很難讓我們直觀的看到整個調用過程,但是通過上面的過程圖和
AbstractQueuedSynchronizer的注釋可以看出,AbstractQueuedSynchronizer抽象了大多數Lock的功能,而只把tryAcquire(int)委托給子類進行多態實現。tryAcquire用于判斷對應線程事都能夠獲取鎖,無論成功與否,AbstractQueuedSynchronizer都將處理后面的流程。
簡單來講,AQS會把所有請求鎖的線程組成一個CLH的隊列,當一個線程執行完畢釋放鎖(Lock.unlock())的時候,AQS會激活其后繼節點,正在執行的線程不在隊列當中,而那些等待的線程全部處于阻塞狀態,經過源碼分析,我們可以清楚的看到最終是通過LockSupport.park()實現的,而底層是調用sun.misc.Unsafe.park()本地方法,再進一步,HotSpot在Linux中中通過調用pthread_mutex_lock函數把線程交給系統內核進行阻塞。其運行示意圖如下
與synchronized相同的是,這個也是一個虛擬隊列,并不存在真正的隊列示例,僅存在節點之前的前后關系。(注:原生的CLH隊列用于自旋鎖,JUC將其改造為阻塞鎖)。和synchronized還有一點相同的是,就是當獲取鎖失敗的時候,不是立即進行阻塞,而是先自旋一段時間看是否能獲取鎖,這對那些已經在阻塞隊列里面的線程顯然不公平(非公平鎖的實現,公平鎖通過有序隊列強制線程順序進行),但會極大的提升吞吐量。如果自旋還是獲取失敗了,則創建一個節點加入隊列尾部,加入方法仍采用CAS操作,并發對隊尾CAS操作有可能會發生失敗,AQS是采用自旋循環的方法,知道CAS成功!下面我們來看下鎖的實現細節!
鎖的實現依賴與lock()方法,Lock()方法首先是調用acquire(int)方法,不管是公平鎖還是非公平鎖
public final void acquire(int arg) { if (!tryAcquire(arg) && acquireQueued(addWaiter(Node.EXCLUSIVE), arg)) selfInterrupt();}
Acquire()方法默認首先調用tryAcquire(int)方法,而此時公平鎖和不公平鎖的實現就不一樣了。
1、
Sync.NonfairSync.TryAcquire(非公平鎖)
nonfairTryAcquire方法是lock方法間接調用的第一個方法,每次調用都會首先調用這個方法,我們來看下對應的實現代碼:
final boolean nonfairTryAcquire(int acquires) { final Thread current = Thread.currentThread(); int c = getState(); if (c == 0) { if (compareAndSetState(0, acquires)) { setExclusiveOwnerThread(current); return true; } } else if (current == getExclusiveOwnerThread()) { int nextc = c + acquires; if (nextc < 0) // overflow throw new Error("Maximum lock count exceeded"); setState(nextc); return true; } return false; }
該方法首先會判斷當前線程的狀態,如果c==0 說明沒有線程正在競爭鎖。(反過來,如果c!=0則說明已經有其他線程已經擁有了鎖)。如果c==0,則通過CAS將狀態設置為acquires(獨占鎖的acquires為1),后續每次重入該鎖都會+1,每次unlock都會-1,當數據為0時則釋放鎖資源。其中精妙的部分在于:并發訪問時,有可能多個線程同時檢測到c為0,此時執行compareAndSetState(0, acquires))設置,可以預見,如果當前線程CAS成功,則其他線程都不會再成功,也就默認當前線程獲取了鎖,直接作為running線程,很顯然這個線程并沒有進入等待隊列。如果c!=0,首先判斷獲取鎖的線程是不是當前線程,如果是當前線程,則表明為鎖重入,繼續+1,修改state的狀態,此時并沒有鎖競爭,也非CAS,因此這段代碼也非常漂亮的實現了偏向鎖。