天天看點

關于Java鎖機制面試官會怎麼問

樂觀鎖與悲觀鎖

悲觀鎖:總是假設最壞的情況,每次去拿資料的時候都認為别人會修改,是以每次在拿資料的時候都會上鎖,這樣别人想拿這個資料就會阻塞直到它拿到鎖。傳統的關系型資料庫裡邊就用到了很多這種鎖機制,比如行鎖,表鎖等,讀鎖,寫鎖等,都是在做操作之前先上鎖。再比如Java裡面的同步原語synchronized關鍵字的實作也是悲觀鎖。

樂觀鎖:顧名思義,就是很樂觀,每次去拿資料的時候都認為别人不會修改,是以不會上鎖,但是在更新的時候會判斷一下在此期間别人有沒有去更新這個資料,可以使用版本号等機制。樂觀鎖适用于多讀的應用類型,這樣可以提高吞吐量,像資料庫提供的類似于write_condition機制,其實都是提供的樂觀鎖。在Java中java.util.concurrent.atomic包下面的原子變量類就是使用了樂觀鎖的一種實作方式CAS實作的。

樂觀鎖的一種實作方式-CAS(Compare and Swap 比較并交換):

鎖存在的問題

Java在JDK1.5之前都是靠synchronized關鍵字保證同步的,這種通過使用一緻的鎖定協定來協調對共享狀态的通路,可以確定無論哪個線程持有共享變量的鎖,都采用獨占的方式來通路這些變量。這就是一種獨占鎖,獨占鎖其實就是一種悲觀鎖,是以可以說 synchronized 是悲觀鎖。

悲觀鎖機制存在以下問題:  

  1. 在多線程競争下,加鎖、釋放鎖會導緻比較多的上下文切換和排程延時,引起性能問題。
  2. 一個線程持有鎖會導緻其它所有需要此鎖的線程挂起。
  3. 如果一個優先級高的線程等待一個優先級低的線程釋放鎖會導緻優先級倒置。

對比于悲觀鎖的這些問題,另一個更加有效的鎖就是樂觀鎖。其實樂觀鎖就是:每次不加鎖而是假設沒有并發沖突而去完成某項操作,如果因為并發沖突失敗就重試,直到成功為止。

樂觀鎖

樂觀鎖( Optimistic Locking)在上文已經說過了,其實就是一種思想。相對悲觀鎖而言,樂觀鎖假設認為資料一般情況下不會産生并發沖突,是以在資料進行送出更新的時候,才會正式對資料是否産生并發沖突進行檢測,如果發現并發沖突了,則讓傳回使用者錯誤的資訊,讓使用者決定如何去做。

上面提到的樂觀鎖的概念中其實已經闡述了它的具體實作細節:主要就是兩個步驟:沖突檢測和資料更新。其實作方式有一種比較典型的就是 Compare and Swap ( CAS )。

CAS

CAS是樂觀鎖技術,當多個線程嘗試使用CAS同時更新同一個變量時,隻有其中一個線程能更新變量的值,而其它線程都失敗,失敗的線程并不會被挂起,而是被告知這次競争中失敗,并可以再次嘗試。   

CAS 操作中包含三個操作數 —— 需要讀寫的記憶體位置(V)、進行比較的預期原值(A)和拟寫入的新值(B)。如果記憶體位置V的值與預期原值A相比對,那麼處理器會自動将該位置值更新為新值B。否則處理器不做任何操作。無論哪種情況,它都會在 CAS 指令之前傳回該位置的值。(在 CAS 的一些特殊情況下将僅傳回 CAS 是否成功,而不提取目前值。)CAS 有效地說明了“ 我認為位置 V 應該包含值 A;如果包含該值,則将 B 放到這個位置;否則,不要更改該位置,隻告訴我這個位置現在的值即可。 ”這其實和樂觀鎖的沖突檢查+資料更新的原理是一樣的。

這裡再強調一下,樂觀鎖是一種思想。CAS是這種思想的一種實作方式。

JAVA對CAS的支援:在JDK1.5 中新增 java.util.concurrent (J.U.C)就是建立在CAS之上的。相對于對于 synchronized 這種阻塞算法,CAS是非阻塞算法的一種常見實作。是以J.U.C在性能上有了很大的提升。

以 java.util.concurrent 中的 AtomicInteger 為例,看一下在不使用鎖的情況下是如何保證線程安全的。主要了解 getAndIncrement 方法,該方法的作用相當于 ++i 操作。

關于Java鎖機制面試官會怎麼問

在沒有鎖的機制下,字段value要借助volatile原語,保證線程間的資料是可見性。這樣在擷取變量的值的時候才能直接讀取。然後來看看 ++i 是怎麼做到的。getAndIncrement 采用了CAS操作,每次從記憶體中讀取資料然後将此資料和 +1 後的結果進行CAS操作,如果成功就傳回結果,否則重試直到成功為止。而 compareAndSet 利用JNI(Java Native Interface)來完成CPU指令的操作:

關于Java鎖機制面試官會怎麼問

那麼比較this == expect,替換this = update,compareAndSwapInt實作這兩個步驟的原子性呢? 參考CAS的原理。

CAS原理:CAS通過調用JNI的代碼實作的。而compareAndSwapInt就是借助C來調用CPU底層指令實作的。下面從分析比較常用的CPU(intel x86)來解釋CAS的實作原理。下面是sun.misc.Unsafe類的compareAndSwapInt()方法的源代碼:

關于Java鎖機制面試官會怎麼問

如上面源代碼所示,程式會根據目前處理器的類型來決定是否為cmpxchg指令添加lock字首。如果程式是在多處理器上運作,就為cmpxchg指令加上lock字首(lock cmpxchg)。反之,如果程式是在單處理器上運作,就省略lock字首(單處理器自身會維護單處理器内的順序一緻性,不需要lock字首提供的記憶體屏障效果)。

CAS缺點:

  1. ABA問題:比如說一個線程one從記憶體位置V中取出A,這時候另一個線程two也從記憶體中取出A,并且two進行了一些操作變成了B,然後two又将V位置的資料變成A,這時候線程one進行CAS操作發現記憶體中仍然是A,然後one操作成功。盡管線程one的CAS操作成功,但可能存在潛藏的問題。如下所示:
    關于Java鎖機制面試官會怎麼問

現有一個用單向連結清單實作的堆棧,棧頂為A,這時線程T1已經知道A.next為B,然後希望用CAS将棧頂替換為B:head.compareAndSet(A,B);在T1執行上面這條指令之前,線程T2介入,将A、B出棧,再pushD、C、A,此時堆棧結構如下圖,而對象B此時處于遊離狀态:

關于Java鎖機制面試官會怎麼問

此時輪到線程T1執行CAS操作,檢測發現棧頂仍為A,是以CAS成功,棧頂變為B,但實際上B.next為null,是以此時的情況變為:

關于Java鎖機制面試官會怎麼問

其中堆棧中隻有B一個元素,C和D組成的連結清單不再存在于堆棧中,平白無故就把C、D丢掉了。從Java1.5開始JDK的atomic包裡提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法作用是首先檢查目前引用是否等于預期引用,并且目前标志是否等于預期标志,如果全部相等,則以原子方式将該引用和該标志的值設定為給定的更新值。

關于Java鎖機制面試官會怎麼問
  1. 循環時間長開銷大:

自旋CAS(不成功,就一直循環執行,直到成功)如果長時間不成功,會給CPU帶來非常大的執行開銷。如果JVM能支援處理器提供的pause指令那麼效率會有一定的提升,pause指令有兩個作用,第一它可以延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源,延遲的時間取決于具體實作的版本,在一些處理器上延遲時間是零。第二它可以避免在退出循環的時候因記憶體順序沖突(memory order violation)而引起CPU流水線被清空(CPU pipeline flush),進而提高CPU的執行效率。

  1. 隻能保證一個共享變量的原子操作:

當對一個共享變量執行操作時,我們可以使用循環CAS的方式來保證原子操作,但是對多個共享變量操作時,循環CAS就無法保證操作的原子性,這個時候就可以用鎖,或者有一個取巧的辦法,就是把多個共享變量合并成一個共享變量來操作。比如有兩個共享變量i=2,j=a,合并一下ij=2a,然後用CAS來操作ij。從Java1.5開始JDK提供了AtomicReference類來保證引用對象之間的原子性,你可以把多個變量放在一個對象裡來進行CAS操作。

CAS與Synchronized的使用情景

1、對于資源競争較少(線程沖突較輕)的情況,使用synchronized同步鎖進行線程阻塞和喚醒切換以及使用者态核心态間的切換操作額外浪費消耗cpu資源;而CAS基于硬體實作,不需要進入核心,不需要切換線程,操作自旋幾率較少,是以可以獲得更高的性能。

2、對于資源競争嚴重(線程沖突嚴重)的情況,CAS自旋的機率會比較大,進而浪費更多的CPU資源,效率低于synchronized。

補充:synchronized在jdk1.6之後,已經改進優化。synchronized的底層實作主要依靠Lock-Free的隊列,基本思路是自旋後阻塞,競争切換後繼續競争鎖,稍微犧牲了公平性,但獲得了高吞吐量。線上程沖突較少的情況下,可以獲得和CAS類似的性能;而線程沖突嚴重的情況下,性能遠高于CAS。

concurrent包的實作

由于java的CAS同時具有 volatile 讀和volatile寫的記憶體語義,是以Java線程之間的通信現在有了下面四種方式:

  1. A線程寫volatile變量,随後B線程讀這個volatile變量。
  2. A線程寫volatile變量,随後B線程用CAS更新這個volatile變量。
  3. A線程用CAS更新一個volatile變量,随後B線程用CAS更新這個volatile變量。
  4. A線程用CAS更新一個volatile變量,随後B線程讀這個volatile變量。

Java的CAS會使用現代處理器上提供的高效機器級别原子指令,這些原子指令以原子方式對記憶體執行讀-改-寫操作,這是在多處理器中實作同步的關鍵(從本質上來說,能夠支援原子性讀-改-寫指令的計算機器,是順序計算圖靈機的異步等價機器,是以任何現代的多處理器都會去支援某種能對記憶體執行原子性讀-改-寫操作的原子指令)。同時,volatile變量的讀/寫和CAS可以實作線程之間的通信。把這些特性整合在一起,就形成了整個concurrent包得以實作的基石。如果我們仔細分析concurrent包的源代碼實作,會發現一個通用化的實作模式:

  1. 首先,聲明共享變量為volatile;  
  2. 然後,使用CAS的原子條件更新來實作線程之間的同步;
  3. 同時,配合以volatile的讀/寫和CAS所具有的volatile讀和寫的記憶體語義來實作線程之間的通信。

AQS,非阻塞資料結構和原子變量類(java.util.concurrent.atomic包中的類),這些concurrent包中的基礎類都是使用這種模式來實作的,而concurrent包中的高層類又是依賴于這些基礎類來實作的。從整體來看,concurrent包的實作示意圖如下:

關于Java鎖機制面試官會怎麼問

JVM中的CAS(堆中對象的配置設定) 

Java調用new object()會建立一個對象,這個對象會被配置設定到JVM的堆中。那麼這個對象到底是怎麼在堆中儲存的呢?

首先,new object()執行的時候,這個對象需要多大的空間,其實是已經确定的,因為java中的各種資料類型,占用多大的空間都是固定的(對其原理不清楚的請自行Google)。那麼接下來的工作就是在堆中找出那麼一塊空間用于存放這個對象。

在單線程的情況下,一般有兩種配置設定政策:

  1. 指針碰撞:這種一般适用于記憶體是絕對規整的(記憶體是否規整取決于記憶體回收政策),配置設定空間的工作隻是将指針像空閑記憶體一側移動對象大小的距離即可。
  2. 空閑清單:這種适用于記憶體非規整的情況,這種情況下JVM會維護一個記憶體清單,記錄哪些記憶體區域是空閑的,大小是多少。給對象配置設定空間的時候去空閑清單裡查詢到合适的區域然後進行配置設定即可。

但是JVM不可能一直在單線程狀态下運作,那樣效率太差了。由于在給一個對象配置設定記憶體的時候不是原子性的操作,至少需要以下幾步:查找空閑清單、配置設定記憶體、修改空閑清單等等,這是不安全的。解決并發時的安全問題也有兩種政策:

  1. CAS:實際上虛拟機采用CAS配合上失敗重試的方式保證更新操作的原子性,原理和上面講的一樣。
  2. TLAB:如果使用CAS其實對性能還是會有影響的,是以JVM又提出了一種更進階的優化政策:每個線程在Java堆中預先配置設定一小塊記憶體,稱為本地線程配置設定緩沖區(TLAB),線程内部需要配置設定記憶體時直接在TLAB上配置設定就行,避免了線程沖突。隻有當緩沖區的記憶體用光需要重新配置設定記憶體的時候才會進行CAS操作配置設定更大的記憶體空間。

虛拟機是否使用TLAB,可以通過-XX:+/-UseTLAB參數來進行配置(jdk5及以後的版本預設是啟用TLAB的)。

原文釋出時間為:2018-07-05

本文來自雲栖社群合作夥伴“

Java架構沉思錄

”,了解相關資訊可以關注“

”。