Java必會之-鎖底層原理
Java鎖底層原理
當多個執行緒需要訪問某個公共資源的時候,我們知道需要通過加鎖來保證資源的訪問不會出問題。java提供了兩種方式來加鎖,
一種是關鍵字:synchronized,一種是concurrent包下的lock鎖。
synchronized
- synchronized的作用:保證了原子性、可見性、有序性。
為什麼synchronized無法禁止指令重排,卻能保證有序性? 為了進一步提升計算機各方面能力,在硬體層面做了很多優化,如處理器優化和指令重排等,但是這些技術的引入就會導致有序性問題。 我們也知道,最好的解決有序性問題的辦法,就是禁止處理器優化和指令重排,就像volatile中使用記憶體屏障一樣。 雖然很多硬體都會為了優化做一些重排,但是在Java中,不管怎麼排序,都不能影響單執行緒程式的執行結果。這就是as-if-serial語義,所有硬體優化的前提都是必須遵守as-if-serial語義。 再說下synchronized,他是Java提供的鎖,可以通過他對Java中的物件加鎖,並且他是一種排他的、可重入的鎖。 所以,當某個執行緒執行到一段被synchronized修飾的程式碼之前,會先進行加鎖,執行完之後再進行解鎖。在加鎖之後,解鎖之前,其他執行緒是無法再次獲得鎖的,只有這條加鎖執行緒可以重複獲得該鎖。 synchronized通過排他鎖的方式就保證了同一時間內,被synchronized修飾的程式碼是單執行緒執行的。所以呢,這就滿足了as-if-serial語義的一個關鍵前提,那就是單執行緒,因為有as-if-serial語義保證,單執行緒的有序性就天然存在了。
- Synchronized可以把修飾的任何一個非null物件作為"鎖",synchronized的用法有以下三種:
- 修飾例項方法:鎖住的是物件例項this,屬於物件鎖
- 修飾靜態方法:鎖住的是物件class例項,屬於類鎖
- 修飾程式碼塊:鎖住的是括號裡面的物件例項,屬於物件鎖
注意,synchronized內建鎖是一種物件鎖(鎖的是物件而非引用變數),作用粒度是物件,可以用來實現對臨界資源的同步互斥訪問,是可重入的。其可重入最大的作用是避免死鎖,如:子類同步方法呼叫了父類同步方法,如沒有可重入的特性,則會發生死鎖;
synchronized的底層同步原理
synchronized是在軟體層面依賴於JVM,而j.u.c下的lock是依賴於硬體層面。
Synchronized底層原理分為2中:物件鎖和方法鎖,即修飾物件和修飾方法
1.Synchronized修飾物件
如果synchronized修飾的是物件,那麼它是依賴於monitor物件—監視器鎖來實現鎖的機制的。
public class SynchronizeTest { public void method(){ synchronized (this) { // 鎖的是呼叫該method方法的例項物件this for (int i = 0; i < 5; i++) { System.out.println(Thread.currentThread().getName() + "+++++++" + i); } } } }
先編譯上述檔案為class檔案:javac -encoding utf-8 類名.java
反編譯class類檔案: javap -c 類名.class
編譯結果解析:
- monitorenter:每個物件都是一個監視器鎖(monitor)。當monitor被其他執行緒佔用時就會處於鎖定狀態,執行緒執行monitorenter指令時嘗試獲取monitor的所有權,過程如下:
- 如果monitor的進入數為0,則該執行緒將進入monitor,然後將進入monitor的進入數設定為1,該執行緒即為monitor的所有者;
- 如果執行緒已經佔有該monitor,只是重新進入,則進入monitor的進入數加1;
- 如果其他執行緒已經佔用了monitor,則該執行緒進入阻塞狀態,直到monitor的進入數為0,再重新嘗試獲取monitor的所有權;
-
monitorexit:執行monitorexit的執行緒必須是objecter所對應的monitor的所有者。指令執行時,monitor的進入數減1,如果減1後進入數為0,那執行緒退出monitor,不再是這個monitor的所有者。其他被這個monitor阻塞的執行緒可以嘗試去獲取這個 monitor 的所有權。
monitorexit指令如果出現了兩次,第1次為同步正常退出釋放鎖;第2次為發生非同步退出釋放鎖;
通過上面兩段描述,我們應該能很清楚的看出Synchronized的實現原理:
Synchronized的語義底層是通過一個monitor的物件來完成,其實wait/notify等方法也依賴於monitor物件,
這就是為什麼只有在同步的塊或者方法中才能呼叫wait/notify等方法,
否則會丟擲 java.lang.IllegalMonitorStateException 的異常的原因。
Synchronized修飾方法
如果synchronized修飾的是方法,那麼則是通過ACC_SYNCHRONIZED 標示符來進行加鎖的。
public class SynchronizeTest {
public synchronized void method() {
for (int i = 0; i < 5; i++) {
System.out.println(Thread.currentThread().getName() + "+++++++" + i);
}
}
}
反編譯如下:
當方法呼叫時,呼叫指令將會檢查方法的 ACC_SYNCHRONIZED 標誌符是否被設定,如果設定了,執行緒將會先獲取monitor,獲取成功之後才能執行方法體,方法執行完之後再釋放monitor。在方法執行期間,其他任何執行緒都無法再獲得同一個monitor物件。
兩種同步方式本質上沒有區別,只是方法的同步是一種隱式的方式來實現,無需通過位元組碼來完成。兩個指令的執行是JVM通過呼叫作業系統的互斥原語mutex來實現,被阻塞的執行緒會被掛起、等待重新排程,但會導致 “使用者態和核心態” 兩個態之間來回切換,對效能有較大影響。
Synchronized的鎖物件
無論是例項物件(包括例項this和方法)還是類物件。在JVM中,每個物件都是由三部分組成的:物件頭、例項資料、資料填充。synchronized的鎖的資訊都是儲存在物件頭裡。物件組成結構如下:
- 例項資料:存放類的屬性資料資訊,包括父類的屬性資訊;
- 對齊填充:由於虛擬機器要求,物件起始地址必須是8位元組的整數倍。填充資料不是必須存在的,僅僅是為了位元組對齊;
- 物件頭:Java物件頭一般佔有2個機器碼(在32位虛擬機器中,1個機器碼等於4位元組,也就是32bit,在64位虛擬機器中,1個機器碼是8個位元組,也就是64bit),但是,如果物件是陣列型別,則需要3個機器碼,因為JVM虛擬機器可以通過Java物件的元資料資訊確定Java物件的大小,但是無法從陣列的元資料來確認陣列的大小,所以用一塊來記錄陣列長度。
Synchronized用的鎖就是存在Java物件頭裡的,那麼什麼是Java物件頭呢?Hotspot虛擬機器的物件頭主要包括兩部分資料:Mark Word(標記欄位)、Class Pointer(型別指標)。其中 Class Pointer是物件指向它的類元資料的指標,虛擬機器通過這個指標來確定這個物件是哪個類的例項,Mark Word用於儲存物件自身的執行時資料,它是實現輕量級鎖和偏向鎖的關鍵。 Java物件頭具體結構描述如下:
其中Mark Word在預設情況下儲存著物件的雜湊碼(HashCode)、GC分代年齡、鎖狀態標誌、執行緒持有的鎖、偏向執行緒 ID、偏向時間戳等,以下是32位JVM的Mark Word預設儲存結構:
物件頭資訊是與物件自身定義的資料無關的額外儲存成本,但是考慮到虛擬機器的空間效率,Mark Word被設計成一個非固定的資料結構以便在極小的空間記憶體儲存儘量多的資料,它會根據物件的狀態複用自己的儲存空間,也就是說,Mark Word會隨著程式的執行發生變化,可能變化為儲存以下4種資料:
synchronized屬於物件鎖,而任何一個物件都有一個Monitor與之關聯,當且一個Monitor被持有後,它將處於鎖定狀態。在Java虛擬機器(HotSpot)中,Monitor是由ObjectMonitor實現的,其主要資料結構如下(位於HotSpot虛擬機器原始碼ObjectMonitor.hpp檔案,C++實現的):
ObjectMonitor() {
_header = NULL;
_count = 0; //記錄個數
_waiters = 0,
_recursions = 0;
_object = NULL;
_owner = NULL;
_WaitSet = NULL; //處於wait狀態的執行緒,會被加入到_WaitSet
_WaitSetLock = 0 ;
_Responsible = NULL ;
_succ = NULL ;
_cxq = NULL ;
FreeNext = NULL ;
_EntryList = NULL ; //處於等待鎖block狀態的執行緒,會被加入到該列表
_SpinFreq = 0 ;
_SpinClock = 0 ;
OwnerIsThread = 0 ;
結構中有幾個重要的欄位,_count、_owner、_EntryList、_WaitSet。
- count用來記錄執行緒進入加鎖程式碼的次數。
- owner記錄當前持有鎖的執行緒,即持有ObjectMonitor物件的執行緒。
- EntryList是想要持有鎖的執行緒的集合。
- WaitSet 是加鎖物件呼叫wait()方法後,等待被喚醒的執行緒的集合
當多個執行緒訪問同步程式碼塊時:
1> 首先執行緒會進入EntryList集合,然後當執行緒拿到Monitor物件時,進入owner區域,並把Monitor的owner設定為當前執行緒,_owner指向持有ObjectMonitor物件的執行緒,並把計數器count加1.
2> 若執行緒呼叫wait方法,將釋放當前持有的monitor物件,同時owner變數恢復為null,count自減1,同時該執行緒進入WaitSet集合中等待被喚醒;
3> 當前執行緒執行完畢,也將釋放monitor(鎖)並復位count的值,以便其他執行緒進入獲取monitor(鎖);
過程如下圖所示:
Synchronized與等待喚醒:
- 等待喚醒是指呼叫物件的wait、notify、notifyAll方法。呼叫這三個方法時,物件必須被synchronized修飾,因為這三個方法在執行時,必須獲得當前物件的監視器monitor物件。
- 另外,與sleep方法不同的是wait方法呼叫完成後,執行緒將被暫停,但wait方法將會釋放當前持有的監視器鎖(monitor),直到有執行緒呼叫notify/notifyAll方法後方能繼續執行。而sleep方法只讓執行緒休眠並不釋放鎖。notify/notifyAll方法呼叫後,並不會馬上釋放監視器鎖,而是在相應的synchronized程式碼塊或synchronized方法執行結束後才自動釋放鎖。
Synchronized的可重入與中斷:
-
可重入:當多個執行緒請求同一個臨界資源,執行到同一個臨界區時會產生互斥,未獲得資源的執行緒會阻塞。而當一個已獲得臨界資源的執行緒再次請求此資源時並不會發生阻塞,仍能獲取到資源、進入臨界區,這就是重入。Synchronized是可重入的。
-
中斷:與中斷相關的有三個方法:
/** * Interrupt設定一個執行緒為中斷狀態 * Interrupt操作的執行緒處於sleep,wait,join 阻塞等狀態的時候,清除“中斷”狀態,丟擲一個InterruptedException * Interrupt操作的執行緒在可中斷通道上因呼叫某個阻塞的 I/O 操作(serverSocketChannel. accept()、socketChannel.connect、socketChannel.open、 * socketChannel.read、socketChannel.write、fileChannel.read、fileChannel.write),會丟擲一個ClosedByInterruptException **/ public void interrupt(); /** * 判斷執行緒是否處於“中斷”狀態,然後將“中斷”狀態清除 **/ public static boolean interrupted(); /** * 判斷執行緒是否處於“中斷”狀態 **/ public boolean isInterrupted();
在實際使用中,當執行緒正處於呼叫sleep、wait、join方法後,呼叫interrupt會清除執行緒中斷狀態,並丟擲異常。而當執行緒已進入臨界區、正在執行,則需要isInterrupted()或interrupted()與interrupt()配合使用中斷執行中的執行緒。
Sychronized修飾的方法、程式碼塊被多個執行緒請求時,呼叫中斷,正在執行的執行緒響應中斷,正在阻塞的執行緒、執行中的執行緒都會標記中斷狀態,但阻塞的執行緒不會立刻處理中斷,而是在進入臨界區後再響應。
3、synchronized鎖的1.6升級優化
偏向鎖、輕量級鎖、重量級鎖、鎖消除、鎖粗化。
1>鎖的升級只能從低到高,不能從高到低。
鎖的升級過程如下:
2>鎖標誌位變化:
-
無鎖狀態:鎖的物件頭是無鎖狀態,有1bit專門記錄是否為偏向鎖,0代表無鎖,1代表偏向鎖。有2bit位記錄鎖標誌位,
-
偏向鎖:這時執行緒開始佔有鎖物件,偏向鎖的標誌位變為1,23bit位的hashcode存放執行緒A的執行緒ID,2bit位存放epoch(共25bit位),如果在多執行緒併發的環境下(即執行緒A尚未執行完同步程式碼塊,執行緒B發起了申請鎖的申請),如果執行緒B成功拿到鎖,那麼此時還是偏向鎖狀態。
-
輕量級鎖:如果此時執行緒獲取鎖失敗,則轉化為輕量級鎖。首先會線上程A和執行緒B都開闢一塊LockRecord空間,然後把鎖物件複製一份到自己的LockRecord空間下,並且開闢一塊owner空間留作執行鎖使用,並且鎖物件的前30bit位合併,等待執行緒A和執行緒B來修改指向自己的執行緒,假如執行緒A修改成功,則鎖物件頭的前30bit位會存執行緒A的LockRecord的記憶體地址,並且執行緒A的owner也會存一份鎖物件的記憶體地址,形成一個雙向指向的形式。而執行緒B修改失敗,則進入一個自旋狀態,就是持續來修改鎖物件。
-
重量級鎖:如果執行緒B自旋一定次數後,還沒有拿到鎖,這個時候鎖就會升級為重量級鎖,這時我們的執行緒B會由使用者態切換到核心態,申請一個互斥量matux,並且將鎖物件的前30bit指向我們的互斥量地址,並且進入睡眠狀態,然後我們的執行緒A繼續執行直到完成時,當執行緒A想要釋放鎖資源時,發現原來鎖的前30bit位並不是指向自己了,這時執行緒A釋放鎖,並且去喚醒那些處於睡眠狀態的執行緒,鎖升級到重量級鎖。
3>鎖消除:因為Synchronized鎖的是物件,如果每一個執行緒都鎖一個新的物件,那麼這個時候就不需要進行上鎖了,就是所謂的鎖消除,鎖消除的底層實現原理是JVM的逃逸分析原理。如以下程式碼所示:
synchronized (new Object()){
System.out.println("開始處理邏輯");
}
4>鎖粗化:
把很多次鎖的請求合併成一個請求,以降低短時間內大量鎖請求、同步、釋放帶來的效能損耗。
StringBuffer sb = new StringBuffer();
public void lockCoarseningMethod() {
synchronized (Test.class) {
sb.append("1");
}
synchronized (Test.class) {
sb.append("2");
}
synchronized (Test.class) {
sb.append("3");
}
synchronized (Test.class) {
sb.append("4");
}
}
1234567891011121314151617
鎖粗化後:
StringBuffer sb = new StringBuffer();
public void lockCoarseningMethod() {
synchronized (Test.class) {
sb.append("1");
sb.append("2");
sb.append("3");
sb.append("4");
}
}
ReentrantLock
可以實現公平鎖和非公平鎖( 當有執行緒競爭鎖時,當前執行緒會首先嚐試獲得鎖而不是在佇列中進行排隊等候,這對於那些已經在佇列中排隊的執行緒來說顯得不公平,這也是非公平鎖的由來),ReentrantLock預設情況下為非公平鎖。
- ReentrantLock構造方法
/**
* Creates an instance of {@code ReentrantLock}.
* This is equivalent to using {@code ReentrantLock(false)}.
*/
public ReentrantLock() {
sync = new NonfairSync();
}
-
ReentrantLock的非公平鎖類NonfairSync ,裡面有 lock方法
/** * Sync object for non-fair locks */ static final class NonfairSync extends Sync { private static final long serialVersionUID = 7316153563782823691L; /** * Performs lock. Try immediate barge, backing up to normal * acquire on failure. */ final void lock() { if (compareAndSetState(0, 1)) setExclusiveOwnerThread(Thread.currentThread()); else acquire(1); } protected final boolean tryAcquire(int acquires) { return nonfairTryAcquire(acquires); } }
-
而非公平鎖類NonfairSync 繼承了抽象類Sync,Sync又繼承抽象類AbstractQueuedSynchronizer(簡稱AQS)
abstract static class Sync extends AbstractQueuedSynchronizer {...}
-
AQS又繼承了AbstractOwnableSynchronizer(簡稱AOS),AOS主要是儲存獲取當前鎖的執行緒物件,繼承關係:
-
FairSync 與 NonfairSync的區別在於,是不是保證獲取鎖的公平性,因為預設是NonfairSync(非公平性)
AQS
AbstractQueuedSynchronizer(簡稱AQS)是除了java自帶的synchronized關鍵字之外的鎖機制。
AQS的核心思想
如果被請求的共享資源空閒,則將當前請求資源的執行緒設定為有效的工作執行緒,並將共享資源設定為鎖定狀態,如果被請求的共享資源被佔用,那麼就需要一套執行緒阻塞等待以及被喚醒時鎖分配的機制,這個機制AQS是用CLH佇列鎖實現的,即:將暫時獲取不到鎖的執行緒加入到等待(阻塞)佇列中。
CLH(Craig,Landin,and Hagersten)佇列是一個虛擬的雙向佇列,虛擬的雙向佇列即不存在佇列例項,僅存在節點之間的關聯關係。
AQS是將每一條請求共享資源的被阻塞的等待執行緒封裝成一個CLH鎖佇列的一個節點,來實現鎖的分配
簡單來說,AQS就是基於CLH佇列,用volatile修飾共享變數state狀態符,執行緒通過CAS去改變狀態符,成功則獲取鎖成功,失敗進入CLH佇列,等待被喚醒
注意:AQS是通過自旋鎖實現,即:在等待喚醒過程中,經常會使用自旋(類似while(CAS))的方式不停嘗試獲取鎖,直到被其他執行緒獲取成功。
實現了AQS的鎖有:自旋鎖、互斥鎖、讀寫鎖ReentrantReadWriteLock、條件產量、訊號量、柵欄都是AQS的衍生物
AQS實現的具體方法:
如圖示,AQS維護了一個volatile int state和一個FIFO執行緒等待佇列,多執行緒爭用資源被阻塞的時候就會進入這個佇列。state就是共享資源,其訪問方式有如下三種:
getState();setState();compareAndSetState();
AQS 定義了兩種資源共享方式:
1.Exclusive:獨佔,只有一個執行緒能執行,如ReentrantLock
2.Share:共享,多個執行緒可以同時執行,如Semaphore、CountDownLatch、ReadWriteLock,CyclicBarrier
不同的自定義的同步器爭用共享資源的方式也不同。
AQS底層使用了模板方法模式
同步器的設計是基於模板方法模式的,如果需要自定義同步器一般的方式是這樣(模板方法模式很經典的一個應用):
- 使用者繼承AbstractQueuedSynchronizer並重寫指定的方法。(這些重寫方法很簡單,無非是對於共享資源state的獲取和釋放)
- 將AQS組合在自定義同步元件的實現中,並呼叫其模板方法,而這些模板方法會呼叫使用者重寫的方法。
這和我們以往通過實現介面的方式有很大區別,這是模板方法模式很經典的一個運用。
自定義同步器在實現的時候只需要實現共享資源state的獲取和釋放方式即可,至於具體執行緒等待佇列的維護,AQS已經在頂層實現好了。自定義同步器實現的時候主要實現下面幾種方法:
- isHeldExclusively():該執行緒是否正在獨佔資源。只有用到condition才需要去實現它。
tryAcquire(int):獨佔方式。嘗試獲取資源,成功則返回true,失敗則返回false。
tryRelease(int):獨佔方式。嘗試釋放資源,成功則返回true,失敗則返回false。
tryAcquireShared(int):共享方式。嘗試獲取資源。負數表示失敗;0表示成功,但沒有剩餘可用資源;正數表示成功,且有剩餘資源。
tryReleaseShared(int):共享方式。嘗試釋放資源,如果釋放後允許喚醒後續等待結點返回true,否則返回false。
ReentrantLock為例,(可重入獨佔式鎖):state初始化為0,表示未鎖定狀態,A執行緒lock()時,會呼叫tryAcquire()獨佔鎖並將state+1.之後其他執行緒再想tryAcquire的時候就會失敗,直到A執行緒unlock()到state=0為止,其他執行緒才有機會獲取該鎖。A釋放鎖之前,自己也是可以重複獲取此鎖(state累加),這就是可重入的概念。
注意:獲取多少次鎖就要釋放多少次鎖,保證state是能回到零態的。
以CountDownLatch為例,任務分N個子執行緒去執行,state就初始化 為N,N個執行緒並行執行,每個執行緒執行完之後countDown()一次,state就會CAS減一。當N子執行緒全部執行完畢,state=0,會unpark()主呼叫執行緒,主呼叫執行緒就會從await()函式返回,繼續之後的動作。
一般來說,自定義同步器要麼是獨佔方法,要麼是共享方式,他們也只需實現tryAcquire-tryRelease、tryAcquireShared-tryReleaseShared中的一種即可。但AQS也支援自定義同步器同時實現獨佔和共享兩種方式,如ReentrantReadWriteLock。
在acquire() acquireShared()兩種方式下,執行緒在等待佇列中都是忽略中斷的,acquireInterruptibly()/acquireSharedInterruptibly()是支援響應中斷的。
AQS底層資料結構是雙向連結串列,鎖的儲存結構就兩個東西 : 雙向連結串列 + "int型別狀態"
簡單來說,ReentrantLock的實現是一種自旋鎖,通過迴圈呼叫CAS操作來實現加鎖。它的效能比較好也是因為避免了使執行緒從使用者態進入核心態的阻塞狀態。想盡辦法避免執行緒進入核心的阻塞狀態是我們去分析和理解鎖設計的關鍵。
需要注意的是,他們的變數都被transient和volatile修飾。
J.U.C 同步佇列(CLH)
一種FIFO雙向佇列,佇列中每個節點等待前驅結點釋放共享狀態(鎖)被喚醒就可以了
AQS依賴它來完成同步狀態的管理,當前執行緒如果獲取同步狀態失敗時,AQS則會將當前執行緒已經等待狀態等資訊構造成一個節點(Node)並將其加入到CLH同步佇列,同時會阻塞當前執行緒,當同步狀態釋放時,會把首節點喚醒(公平鎖),使其再次嘗試獲取同步狀態。
Node節點
這裡是基於CAS(保證執行緒的安全)來設定尾節點的。
static final class Node {
// 節點分為兩種模式: 共享式和獨佔式
/** 共享式 */
static final Node SHARED = new Node();
/** 獨佔式 */
static final Node EXCLUSIVE = null;
/** 等待執行緒超時或者被中斷、需要從同步佇列中取消等待(也就是放棄資源的競爭),此狀態不會在改變 */
static final int CANCELLED = 1;
/** 後繼節點會處於等待狀態,當前節點執行緒如果釋放同步狀態或者被取消則會通知後繼節點執行緒,使後繼節點執行緒的得以執行 */
static final int SIGNAL = -1;
/** 節點在等待佇列中,執行緒在等待在Condition 上,其他執行緒對Condition呼叫singnal()方法後,該節點加入到同步佇列中。 */
static final int CONDITION = -2;
/**
* 表示下一次共享式獲取同步狀態的時會被無條件的傳播下去。
*/
static final int PROPAGATE = -3;
/**等待狀態*/
volatile int waitStatus;
/**前驅節點 */
volatile Node prev;
/**後繼節點*/
volatile Node next;
/**獲取同步狀態的執行緒 */
volatile Thread thread;
/**連結下一個等待狀態 */
Node nextWaiter;
// 下面一些方法就不貼了
}
入列
如上圖瞭解了同步佇列的結構, 我們在分析其入列操作在簡單不過。無非就是將tail(使用CAS保證原子操作)指向新節點,新節點的prev指向佇列中最後一節點(舊的tail節點),原佇列中最後一節點的next節點指向新節點以此來建立聯絡,來張圖幫助大家理解。
-
addWaiter原始碼:先通過addWaiter(Node node)方法嘗試快速將該節點設定尾成尾節點,設定失敗走enq(final Node node)方法
private Node addWaiter(Node mode) { // 以給定的模式來構建節點, mode有兩種模式 // 共享式SHARED, 獨佔式EXCLUSIVE; Node node = new Node(Thread.currentThread(), mode); // 嘗試快速將該節點加入到佇列的尾部 Node pred = tail; if (pred != null) { node.prev = pred; if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } // 如果快速加入失敗,則通過 anq方式入列 enq(node); return node; }
-
enq:通過“自旋”也就是死迴圈的方式來保證該節點能順利的加入到佇列尾部,只有加入成功才會退出迴圈,否則會一直循序直到成功。
private Node enq(final Node node) { // CAS自旋,直到加入隊尾成功 for (;;) { Node t = tail; if (t == null) { // 如果佇列為空,則必須先初始化CLH佇列,新建一個空節點標識作為Hader節點,並將tail 指向它 if (compareAndSetHead(new Node())) tail = head; } else {// 正常流程,加入佇列尾部 node.prev = t; if (compareAndSetTail(t, node)) { t.next = node; return t; } } } }
-
上述兩個方法都是通過compareAndSetHead(new Node())方法來設定尾節點,以保證節點的新增的原子性(保證節點的新增的執行緒安全。)
出列
同步佇列(CLH)遵循FIFO,首節點是獲取同步狀態的節點,首節點的執行緒釋放同步狀態後,將會喚醒它的後繼節點(next),而後繼節點將會在獲取同步狀態成功時將自己設定為首節點,這個過程非常簡單。如下圖
同步佇列-出列.jpg
設定首節點是通過獲取同步狀態成功的執行緒來完成的(獲取同步狀態是通過CAS來完成),只能有一個執行緒能夠獲取到同步狀態,因此設定頭節點的操作並不需要CAS來保證,只需要將首節點設定為其原首節點的後繼節點並斷開原首節點的next(等待GC回收)應用即可
總結
同步佇列就是一個FIFO雙向對佇列,其每個節點包含獲取同步狀態失敗的執行緒應用、等待狀態、前驅節點、後繼節點、節點的屬性型別以及名稱描述。
其入列操作也就是利用CAS(保證執行緒安全)來設定尾節點,出列就很簡單了直接將head指向新頭節點並斷開老頭節點聯絡就可以了。
參考:https://www.jianshu.com/p/6fc0601ffe34
Lock.lock()
-
lock是Lock介面的方法,它的抽象方法在ReentrantLock類中的Sync類裡,實現方法在 NonfairSync.lock()
abstract void lock();
-
公平鎖的上鎖 FairSync.lock()
public void lock() { sync.lock(); }
-
可以看到公平鎖的lock() 是通過呼叫 NonfairSync.lock() 實現的
-
這裡就是通過CAS(樂觀鎖)去修改state的值(鎖狀態值)。lock的基本操作還是通過樂觀鎖來實現的。
final void lock() { if (compareAndSetState(0, 1)) // 比較鎖狀態值status是否為0,是則修改為1 setExclusiveOwnerThread(Thread.currentThread()); // 通過CAS獲取到鎖了,當前執行緒設定為專有執行緒 else acquire(1); }
-
獲取鎖通過CAS,那麼沒有獲取到鎖,等待獲取鎖是如何實現的?我們可以看一下else分支的邏輯,acquire方法:
- tryAcquire:會嘗試再次通過CAS獲取一次鎖
- addWaiter:將當前執行緒加入上面鎖的雙向連結串列(等待佇列)中
- acquireQueued:通過自旋,判斷當前佇列節點是否可以獲取鎖。
public final void acquire(int arg) {
if (!tryAcquire(arg) &&
acquireQueued(addWaiter(Node.EXCLUSIVE), arg))
selfInterrupt(); // 中斷執行緒,但對於正在執行的執行緒沒有作用
}
-
addWaiter: 將當前執行緒加入上面鎖的雙向連結串列(等待佇列)中, 通過CAS確保能夠線上程安全的情況下,通過尾插法將當前執行緒加入到連結串列的尾部。enq是個自旋+上述邏輯
private Node addWaiter(Node mode) { Node node = new Node(Thread.currentThread(), mode); // Try the fast path of enq; backup to full enq on failure Node pred = tail; if (pred != null) { node.prev = pred; if (compareAndSetTail(pred, node)) { pred.next = node; return node; } } enq(node); return node; }
-
acquireQueued() 自旋+CAS嘗試獲取鎖
當前執行緒到頭部的時候,嘗試CAS更新鎖狀態,如果更新成功表示該等待執行緒獲取成功。從頭部移除。因為等待佇列是從尾進從頭出
final boolean acquireQueued(final Node node, int arg) {
boolean failed = true;
try {
boolean interrupted = false;
for (;;) { // 自旋
final Node p = node.predecessor();
if (p == head && tryAcquire(arg)) { // 還是通過 tryAcquire 的CAS操作嘗試獲取鎖
setHead(node);
p.next = null; // help GC
failed = false;
return interrupted;
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt())
interrupted = true;
}
} finally {
if (failed)
cancelAcquire(node);
}
}
每一個執行緒都在 自旋+CAS
獲得鎖的過程
Lock.unlock()
public void unlock() {
sync.release(1);
}
NonfairSync.release() 該方法在AOS中
public final boolean release(int arg) {
if (tryRelease(arg)) { // 嘗試釋放鎖
Node h = head;
if (h != null && h.waitStatus != 0)
unparkSuccessor(h);
return true;
}
return false;
}
NonfairSync.tryRelease()
釋放鎖就是對AQS中的狀態值State進行修改。同時更新下一個連結串列中的執行緒等待節點。
protected final boolean tryRelease(int releases) {
int c = getState() - releases;
if (Thread.currentThread() != getExclusiveOwnerThread())
throw new IllegalMonitorStateException();
boolean free = false;
if (c == 0) {
free = true;
setExclusiveOwnerThread(null);
}
setState(c);
return free;
}
總結
- lock的儲存結構:一個int型別狀態值(用於鎖的狀態變更)+ 一個雙向連結串列(用於儲存等待中的執行緒)
- lock獲取鎖的過程:本質上是通過CAS來獲取狀態值修改,如果當場沒獲取到,會將該執行緒放線上程等待連結串列中。
- lock釋放鎖的過程:修改狀態值,調整等待連結串列。
可以看到在整個實現過程中,lock大量使用CAS+自旋。因此根據CAS特性,lock建議使用在低鎖衝突的情況下。目前java1.6以後,官方對synchronized做了大量的鎖優化(偏向鎖、自旋、輕量級鎖)。因此在非必要的情況下,建議使用synchronized做同步操作。
鎖實現
簡單說來,AQS會把所有的請求執行緒構成一個CLH佇列,當一個執行緒執行完畢(lock.unlock())時會啟用自己的後繼節點,但正在執行的執行緒並不在佇列中,而那些等待執行的執行緒全 部處於阻塞狀態,經過調查執行緒的顯式阻塞是通過呼叫LockSupport.park()完成,而LockSupport.park()則呼叫 sun.misc.Unsafe.park()本地方法,再進一步,HotSpot在Linux中通過呼叫pthread_mutex_lock函式把 執行緒交給系統核心進行阻塞。
與synchronized相同的是,這也是一個虛擬佇列,不存在佇列例項,僅存在節點之間的前後關係。令人疑惑的是為什麼採用CLH佇列呢?原生的CLH佇列是用於自旋鎖,但Doug Lea把其改造為阻塞鎖。
當有執行緒競爭鎖時,該執行緒會首先嚐試獲得鎖,這對於那些已經在佇列中排隊的執行緒來說顯得不公平,這也是非公平鎖的由來,與synchronized實現類似,這樣會極大提高吞吐量。 如果已經存在Running執行緒,則新的競爭執行緒會被追加到隊尾,具體是採用基於CAS的Lock-Free演算法,因為執行緒併發對Tail呼叫CAS可能會 導致其他執行緒CAS失敗,解決辦法是迴圈CAS直至成功。AQS的實現非常精巧,令人歎為觀止,不入細節難以完全領會其精髓,下面詳細說明實現過程:
AbstractQueuedSynchronizer通過構造一個基於阻塞的CLH佇列容納所有的阻塞執行緒,而對該佇列的操作均通過Lock-Free(CAS)操作,但對已經獲得鎖的執行緒而言,ReentrantLock實現了偏向鎖的功能。
synchronized 的底層也是一個基於CAS操作的等待佇列,但JVM實現的更精細,把等待佇列分為ContentionList和EntryList,目的是為了降低執行緒的出列速度;當然也實現了偏向鎖,從資料結構來說二者設計沒有本質區別。但synchronized還實現了自旋鎖,並針對不同的系統和硬體體系進行了優 化,而Lock則完全依靠系統阻塞掛起等待執行緒。
當然Lock比synchronized更適合在應用層擴充套件,可以繼承 AbstractQueuedSynchronizer定義各種實現,比如實現讀寫鎖(ReadWriteLock),公平或不公平鎖;同時,Lock對 應的Condition也比wait/notify要方便的多、靈活的多。
state值,若為0,意味著此時沒有執行緒獲取到資源
簡述總結:
總體來講執行緒獲取鎖要經歷以下過程(非公平):
1、呼叫lock方法,會先進行cas操作看下可否設定同步狀態1成功,如果成功執行臨界區程式碼
2、如果不成功獲取同步狀態,如果狀態是0那麼cas設定為1.
3、如果同步狀態既不是0也不是自身執行緒持有會把當前執行緒構造成一個節點。
4、把當前執行緒節點CAS的方式放入佇列中,行為上執行緒阻塞,內部自旋獲取狀態。
(acquireQueued的主要作用是把已經追加到佇列的執行緒節點進行阻塞,但阻塞前又通過tryAccquire重試是否能獲得鎖,如果重試成功能則無需阻塞,直接返回。)
5、執行緒釋放鎖,喚醒佇列第一個節點,參與競爭。重複上述。
面試
synchronized和lock的底層區別
synchronized的底層也是一個基於CAS操作的等待佇列,但JVM實現的更精細,把等待佇列分為ContentionList和EntryList,目的是為了降低執行緒的出列速度;當然也實現了偏向鎖,從資料結構來說二者設計沒有本質區別。但synchronized還實現了自旋鎖,並針對不同的系統和硬體體系進行了優化,而**Lock則完全依靠系統阻塞掛起等待執行緒。
當然Lock比synchronized更適合在應用層擴充套件,可以繼承AbstractQueuedSynchronizer定義各種實現,比如實現讀寫鎖(ReadWriteLock),公平或不公平鎖;同時,Lock對應的Condition也比wait/notify要方便的多、靈活的多。
ReentrantLock是一個可重入的互斥鎖,ReentrantLock由最近成功獲取鎖,還沒有釋放的執行緒所擁有
ReentrantLock與synchronized的區別
--ReentrantLock的lock機制有2種,忽略中斷鎖和響應中斷鎖
--synchronized實現的鎖機制是可重入的,主要區別是中斷控制和競爭鎖公平策略
兩者區別:
1.首先synchronized是java內建關鍵字,在jvm層面,Lock是個java類;
2.synchronized無法判斷是否獲取鎖的狀態,Lock可以判斷是否獲取到鎖;
3.synchronized會自動釋放鎖(a 執行緒執行完同步程式碼會釋放鎖 ;b 執行緒執行過程中發生異常會釋放鎖),Lock需在finally中手工釋放鎖(unlock()方法釋放鎖),否則容易造成執行緒死鎖;
4.用synchronized關鍵字的兩個執行緒1和執行緒2,如果當前執行緒1獲得鎖,執行緒2執行緒等待。如果執行緒1阻塞,執行緒2則會一直等待下去,而Lock鎖就不一定會等待下去,如果嘗試獲取不到鎖,執行緒可以不用一直等待就結束了;
5.synchronized的鎖可重入、不可中斷、非公平,而Lock鎖可重入、可判斷、可公平(兩者皆可)
6.Lock鎖適合大量同步的程式碼的同步問題,synchronized鎖適合程式碼少量的同步問題。
synchronized底層實現
synchronized 屬於重量級鎖,效率低下,因為監視器鎖(monitor)是依賴於底層作業系統的 Mutex Lock 來實現的,而作業系統實現執行緒之間的切換時需要從使用者態轉換到核心態,這個狀態之間的轉換需要相對比較長的時間,時間成本相對較高,這也是為什麼早期的 synchronized 效率低的原因。在 Java 6 之後 Java 官方從 JVM 層面對 synchronized 進行了較大優化,所以現在的 synchronized 鎖效率也優化得很不錯了。Java 6 之後,為了減少獲得鎖和釋放鎖所帶來的效能消耗,引入了輕量級鎖和偏向鎖,
Lock底層實現
Lock底層實現基於AQS實現,採用執行緒獨佔的方式,在硬體層面依賴特殊的CPU指令(CAS)。
簡單來說,ReenTrantLock的實現是一種自旋鎖,通過迴圈呼叫CAS操作來實現加鎖。它的效能比較好也是因為避免了使執行緒進入核心態的阻塞狀態。想盡辦法避免執行緒進入核心的阻塞狀態是我們去分析和理解鎖設計的關鍵鑰匙。
volatile底層實現
在JVM底層volatile是採用“記憶體屏障”來實現的。
lock和Monitor的區別
一、lock的底層本身是Monitor來實現的,所以Monitor可以實現lock的所有功能。
二、Monitor有TryEnter的功能,可以防止出現死鎖的問題,lock沒有。