Synchronized 相關問題
Synchronized ,其原理是什么?
Synchronized 是由 JVM 實現的一種實現互斥同步的一種方式,如果你查看被 Synchronized 修飾過的程序塊編譯后的字節碼,會發現,被 Synchronized 修飾過的程序塊,在編譯前后被編譯器生成了 monitorenter 和 monitorexit 兩個字節碼指令
這兩個指令是什么意思呢?
在虛擬機執行到 monitorenter 指令時,首先要嘗試獲取對象的鎖:
如果這個對象沒有鎖定,或者當前線程已經擁有了這個對象的鎖,把鎖的計數器 +1;當執行 monitorexit 指令時將鎖計數器 -1;當計數器為 0 時,鎖就被釋放了。
如果獲取對象失敗了,那當前線程就要阻塞等待,直到對象鎖被另外一個線程釋放為止。
Java 中 Synchronize 通過在對象頭設置標記,達到了獲取鎖和釋放鎖的目的。
問題二:你剛才提到獲取對象的鎖,這個“鎖”到底是什么?如何確定對象的鎖?
“鎖”的本質其實是 monitorenter 和 monitorexit 字節碼指令的一個 Reference 類型的參數,即要鎖定和解鎖的對象。我們知道,使用 Synchronized 可以修飾不同的對象,因此,對應的對象鎖可以這么確定。
-
如果 Synchronized 明確指定了鎖對象,比如 Synchronized(變量名)、Synchronized(this) 等,說明加解鎖對象為該對象。
-
如果沒有明確指定:
若 Synchronized 修飾的方法為非靜態方法,表示此方法對應的對象為鎖對象;
若 Synchronized 修飾的方法為靜態方法,則表示此方法對應的類對象為鎖對象。
注意,當一個對象被鎖住時,對象里面所有用 Synchronized 修飾的方法都將產生堵塞,而對象里非 Synchronized 修飾的方法可正常被調用,不受鎖影響。
問題三:什么是可重入性,為什么說 Synchronized 是可重入鎖?
可重入性是鎖的一個基本要求,是為了解決自己鎖死自己的情況。
比如下面的偽代碼,一個類中的同步方法調用另一個同步方法,假如 Synchronized 不支持重入,進入 method2 方法時當前線程獲得鎖,method2 方法里面執行 method1 時當前線程又要去嘗試獲取鎖,這時如果不支持重入,它就要等釋放,把自己阻塞,導致自己鎖死自己。
對 Synchronized 來說,可重入性是顯而易見的,剛才提到,在執行 monitorenter 指令時,如果這個對象沒有鎖定,或者當前線程已經擁有了這個對象的鎖(而不是已擁有了鎖則不能繼續獲取),就把鎖的計數器 +1,其實本質上就通過這種方式實現了可重入性。
問題四:JVM 對 Java 的原生鎖做了哪些優化?
在 Java 6 之前,Monitor 的實現完全依賴底層操作系統的互斥鎖來實現,也就是我們剛才在問題二中所闡述的獲取/釋放鎖的邏輯。
由於 Java 層面的線程與操作系統的原生線程有映射關系,如果要將一個線程進行阻塞或喚起都需要操作系統的協助,這就需要從用戶態切換到內核態來執行,這種切換代價十分昂貴,很耗處理器時間,現代 JDK 中做了大量的優化。
一種優化是使用自旋鎖,即在把線程進行阻塞操作之前先讓線程自旋等待一段時間,可能在等待期間其他線程已經解鎖,這時就無需再讓線程執行阻塞操作,避免了用戶態到內核態的切換。
現代 JDK 中還提供了三種不同的 Monitor 實現,也就是三種不同的鎖:
-
偏向鎖(Biased Locking)
-
輕量級鎖
-
重量級鎖
這三種鎖使得 JDK 得以優化 Synchronized 的運行,當 JVM 檢測到不同的競爭狀況時,會自動切換到適合的鎖實現,這就是鎖的升級、降級。
-
當沒有競爭出現時,默認會使用偏向鎖。
JVM 會利用 CAS 操作,在對象頭上的 Mark Word 部分設置線程 ID,以表示這個對象偏向於當前線程,所以並不涉及真正的互斥鎖,因為在很多應用場景中,大部分對象生命周期中最多會被一個線程鎖定,使用偏斜鎖可以降低無競爭開銷。
-
如果有另一線程試圖鎖定某個被偏斜過的對象,JVM 就撤銷偏斜鎖,切換到輕量級鎖實現。
-
輕量級鎖依賴 CAS 操作 Mark Word 來試圖獲取鎖,如果重試成功,就使用普通的輕量級鎖;否則,進一步升級為重量級鎖。
問題五:為什么說 Synchronized 是非公平鎖?
非公平主要表現在獲取鎖的行為上,並非是按照申請鎖的時間前后給等待線程分配鎖的,每當鎖被釋放后,任何一個線程都有機會競爭到鎖,這樣做的目的是為了提高執行性能,缺點是可能會產生線程飢餓現象。
問題六:什么是鎖消除和鎖粗化?
-
鎖消除:指虛擬機即時編譯器在運行時,對一些代碼上要求同步,但被檢測到不可能存在共享數據競爭的鎖進行消除。主要根據逃逸分析。
程序員怎么會在明知道不存在數據競爭的情況下使用同步呢?很多不是程序員自己加入的。
-
鎖粗化:原則上,同步塊的作用范圍要盡量小。但是如果一系列的連續操作都對同一個對象反復加鎖和解鎖,甚至加鎖操作在循環體內,頻繁地進行互斥同步操作也會導致不必要的性能損耗。
鎖粗化就是增大鎖的作用域。
Synchronized 顯然是一個悲觀鎖,因為它的並發策略是悲觀的:
不管是否會產生競爭,任何的數據操作都必須要加鎖、用戶態核心態轉換、維護鎖計數器和檢查是否有被阻塞的線程需要被喚醒等操作。
隨着硬件指令集的發展,我們可以使用基於沖突檢測的樂觀並發策略。先進行操作,如果沒有其他線程征用數據,那操作就成功了;
如果共享數據有征用,產生了沖突,那就再進行其他的補償措施。這種樂觀的並發策略的許多實現不需要線程掛起,所以被稱為非阻塞同步。
樂觀鎖的核心算法是 CAS(Compareand Swap,比較並交換),它涉及到三個操作數:內存值、預期值、新值。當且僅當預期值和內存值相等時才將內存值修改為新值。
這樣處理的邏輯是,首先檢查某塊內存的值是否跟之前我讀取時的一樣,如不一樣則表示期間此內存值已經被別的線程更改過,舍棄本次操作,否則說明期間沒有其他線程對此內存值操作,可以把新值設置給此塊內存。
CAS 具有原子性,它的原子性由 CPU 硬件指令實現保證,即使用 JNI 調用 Native 方法調用由 C++ 編寫的硬件級別指令,JDK 中提供了 Unsafe 類執行這些操作。
問題八:樂觀鎖一定就是好的嗎?
樂觀鎖避免了悲觀鎖獨占對象的現象,同時也提高了並發性能,但它也有缺點:
-
樂觀鎖只能保證一個共享變量的原子操作。如果多一個或幾個變量,樂觀鎖將變得力不從心,但互斥鎖能輕易解決,不管對象數量多少及對象顆粒度大小。
-
長時間自旋可能導致開銷大。假如 CAS 長時間不成功而一直自旋,會給 CPU 帶來很大的開銷。
-
ABA 問題。CAS 的核心思想是通過比對內存值與預期值是否一樣而判斷內存值是否被改過,但這個判斷邏輯不嚴謹,假如內存值原來是 A,后來被一條線程改為 B,最后又被改成了 A,則 CAS 認為此內存值並沒有發生改變,但實際上是有被其他線程改過的,這種情況對依賴過程值的情景的運算結果影響很大。解決的思路是引入版本號,每次變量更新都把版本號加一
可重入鎖 ReentrantLock 及其他顯式鎖相關問題
問題一:跟 Synchronized 相比,可重入鎖 ReentrantLock 其實現原理有什么不同?
其實,鎖的實現原理基本是為了達到一個目的:
讓所有的線程都能看到某種標記。
Synchronized 通過在對象頭中設置標記實現了這一目的,是一種 JVM 原生的鎖實現方式,而 ReentrantLock 以及所有的基於 Lock 接口的實現類,都是通過用一個 volitile 修飾的 int 型變量,並保證每個線程都能擁有對該 int 的可見性和原子修改,其本質是基於所謂的 AQS 框架。
問題二:那么請談談 AQS 框架是怎么回事兒?
AQS(AbstractQueuedSynchronizer 類)是一個用來構建鎖和同步器的框架,各種 Lock 包中的鎖(常用的有 ReentrantLock、ReadWriteLock),以及其他如 Semaphore、CountDownLatch,甚至是早期的 FutureTask 等,都是基於 AQS 來構建。
-
AQS 在內部定義了一個 volatile int state 變量,表示同步狀態:當線程調用 lock 方法時 ,如果 state=0,說明沒有任何線程占有共享資源的鎖,可以獲得鎖並將 state=1;如果 state=1,則說明有線程目前正在使用共享變量,其他線程必須加入同步隊列進行等待。
-
AQS 通過 Node 內部類構成的一個雙向鏈表結構的同步隊列,來完成線程獲取鎖的排隊工作,當有線程獲取鎖失敗后,就被添加到隊列末尾。
-
Node 類是對要訪問同步代碼的線程的封裝,包含了線程本身及其狀態叫 waitStatus(有五種不同 取值,分別表示是否被阻塞,是否等待喚醒,是否已經被取消等),每個 Node 結點關聯其 prev 結點和 next 結點,方便線程釋放鎖后快速喚醒下一個在等待的線程,是一個 FIFO 的過程。
-
Node 類有兩個常量,SHARED 和 EXCLUSIVE,分別代表共享模式和獨占模式。所謂共享模式是一個鎖允許多條線程同時操作(信號量 Semaphore 就是基於 AQS 的共享模式實現的),獨占模式是同一個時間段只能有一個線程對共享資源進行操作,多余的請求線程需要排隊等待(如 ReentranLock)。
-
AQS 通過內部類 ConditionObject 構建等待隊列(可有多個),當 Condition 調用 wait() 方法后,線程將會加入等待隊列中,而當 Condition 調用 signal() 方法后,線程將從等待隊列轉移動同步隊列中進行鎖競爭。
-
AQS 和 Condition 各自維護了不同的隊列,在使用 Lock 和 Condition 的時候,其實就是兩個隊列的互相移動。
問題三:請盡可能詳盡地對比下 Synchronized 和 ReentrantLock 的異同。
ReentrantLock 是 Lock 的實現類,是一個互斥的同步鎖。
從功能角度,ReentrantLock 比 Synchronized 的同步操作更精細(因為可以像普通對象一樣使用),甚至實現 Synchronized 沒有的高級功能,如:
-
等待可中斷:當持有鎖的線程長期不釋放鎖的時候,正在等待的線程可以選擇放棄等待,對處理執行時間非常長的同步塊很有用。
-
帶超時的獲取鎖嘗試:在指定的時間范圍內獲取鎖,如果時間到了仍然無法獲取則返回。
-
可以判斷是否有線程在排隊等待獲取鎖。
-
可以響應中斷請求:與 Synchronized 不同,當獲取到鎖的線程被中斷時,能夠響應中斷,中斷異常將會被拋出,同時鎖會被釋放。
-
可以實現公平鎖。
從鎖釋放角度,Synchronized 在 JVM 層面上實現的,不但可以通過一些監控工具監控 Synchronized 的鎖定,而且在代碼執行出現異常時,JVM 會自動釋放鎖定;但是使用 Lock 則不行,Lock 是通過代碼實現的,要保證鎖定一定會被釋放,就必須將 unLock() 放到 finally{} 中。
從性能角度,Synchronized 早期實現比較低效,對比 ReentrantLock,大多數場景性能都相差較大。
但是在 Java 6 中對其進行了非常多的改進,在競爭不激烈時,Synchronized 的性能要優於 ReetrantLock;在高競爭情況下,Synchronized 的性能會下降幾十倍,但是 ReetrantLock 的性能能維持常態。
問題四:ReentrantLock 是如何實現可重入性的?
ReentrantLock 內部自定義了同步器 Sync(Sync 既實現了 AQS,又實現了 AOS,而 AOS 提供了一種互斥鎖持有的方式),其實就是加鎖的時候通過 CAS 算法,將線程對象放到一個雙向鏈表中,每次獲取鎖的時候,看下當前維護的那個線程 ID 和當前請求的線程 ID 是否一樣,一樣就可重入了。
問題五:除了 ReetrantLock,你還接觸過 JUC 中的哪些並發工具?
通常所說的並發包(JUC)也就是 java.util.concurrent 及其子包,集中了 Java 並發的各種基礎工具類,具體主要包括幾個方面:
-
提供了 CountDownLatch、CyclicBarrier、Semaphore 等,比 Synchronized 更加高級,可以實現更加豐富多線程操作的同步結構。
-
提供了 ConcurrentHashMap、有序的 ConcunrrentSkipListMap,或者通過類似快照機制實現線程安全的動態數組 CopyOnWriteArrayList 等,各種線程安全的容器。
-
提供了 ArrayBlockingQueue、SynchorousQueue 或針對特定場景的 PriorityBlockingQueue 等,各種並發隊列實現。
-
強大的 Executor 框架,可以創建各種不同類型的線程池,調度任務運行等。
問題六:請談談 ReadWriteLock 和 StampedLock。
雖然 ReentrantLock 和 Synchronized 簡單實用,但是行為上有一定局限性,要么不占,要么獨占。實際應用場景中,有時候不需要大量競爭的寫操作,而是以並發讀取為主,為了進一步優化並發操作的粒度,Java 提供了讀寫鎖。
讀寫鎖基於的原理是多個讀操作不需要互斥,如果讀鎖試圖鎖定時,寫鎖是被某個線程持有,讀鎖將無法獲得,而只好等待對方操作結束,這樣就可以自動保證不會讀取到有爭議的數據。
ReadWriteLock 代表了一對鎖,下面是一個基於讀寫鎖實現的數據結構,當數據量較大,並發讀多、並發寫少的時候,能夠比純同步版本凸顯出優勢:
讀寫鎖看起來比 Synchronized 的粒度似乎細一些,但在實際應用中,其表現也並不盡如人意,主要還是因為相對比較大的開銷。
所以,JDK 在后期引入了 StampedLock,在提供類似讀寫鎖的同時,還支持優化讀模式。優化讀基於假設,大多數情況下讀操作並不會和寫操作沖突,其邏輯是先試着修改,然后通過 validate 方法確認是否進入了寫模式,如果沒有進入,就成功避免了開銷;如果進入,則嘗試獲取讀鎖。
問題七:如何讓 Java 的線程彼此同步?你了解過哪些同步器?請分別介紹下。
JUC 中的同步器三個主要的成員:CountDownLatch、CyclicBarrier 和 Semaphore,通過它們可以方便地實現很多線程之間協作的功能。
CountDownLatch 叫倒計數,允許一個或多個線程等待某些操作完成。看幾個場景:
-
跑步比賽,裁判需要等到所有的運動員(“其他線程”)都跑到終點(達到目標),才能去算排名和頒獎。
-
模擬並發,我需要啟動 100 個線程去同時訪問某一個地址,我希望它們能同時並發,而不是一個一個的去執行。
用法:CountDownLatch 構造方法指明計數數量,被等待線程調用 countDown 將計數器減 1,等待線程使用 await 進行線程等待。一個簡單的例子:
CyclicBarrier 叫循環柵欄,它實現讓一組線程等待至某個狀態之后再全部同時執行,而且當所有等待線程被釋放后,CyclicBarrier 可以被重復使用。CyclicBarrier 的典型應用場景是用來等待並發線程結束。
CyclicBarrier 的主要方法是 await(),await() 每被調用一次,計數便會減少 1,並阻塞住當前線程。當計數減至 0 時,阻塞解除,所有在此 CyclicBarrier 上面阻塞的線程開始運行。
在這之后,如果再次調用 await(),計數就又會變成 N-1,新一輪重新開始,這便是 Cyclic 的含義所在。CyclicBarrier.await() 帶有返回值,用來表示當前線程是第幾個到達這個 Barrier 的線程。
舉例說明如下:
Semaphore,Java 版本的信號量實現,用於控制同時訪問的線程個數,來達到限制通用資源訪問的目的,其原理是通過 acquire() 獲取一個許可,如果沒有就等待,而 release() 釋放一個許可。
如果 Semaphore 的數值被初始化為 1,那么一個線程就可以通過 acquire 進入互斥狀態,本質上和互斥鎖是非常相似的。但是區別也非常明顯,比如互斥鎖是有持有者的,而對於 Semaphore 這種計數器結構,雖然有類似功能,但其實不存在真正意義的持有者,除非我們進行擴展包裝。
問題八:CyclicBarrier 和 CountDownLatch 看起來很相似,請對比下呢?
它們的行為有一定相似度,區別主要在於:
-
CountDownLatch 是不可以重置的,所以無法重用,CyclicBarrier 沒有這種限制,可以重用。
-
CountDownLatch 的基本操作組合是 countDown/await,調用 await 的線程阻塞等待 countDown 足夠的次數,不管你是在一個線程還是多個線程里 countDown,只要次數足夠即可。 CyclicBarrier 的基本操作組合就是 await,當所有的伙伴都調用了 await,才會繼續進行任務,並自動進行重置。
CountDownLatch 目的是讓一個線程等待其他 N 個線程達到某個條件后,自己再去做某個事(通過 CyclicBarrier 的第二個構造方法 public CyclicBarrier(int parties, Runnable barrierAction),在新線程里做事可以達到同樣的效果)。而 CyclicBarrier 的目的是讓 N 多線程互相等待直到所有的都達到某個狀態,然后這 N 個線程再繼續執行各自后續(通過 CountDownLatch 在某些場合也能完成類似的效果)。