Java CAS(CompareAndSet) 樂觀鎖

1.Java線程簡介.
在JDK 5之前,Java語言是靠synchronized關鍵字,保證同步的,這會導致有鎖(後面的章節還會談到鎖).
鎖機制存在以下問題:
(1)在多線程競爭下,加鎖、釋放鎖會導致比較多的上下文切換和調度延時,引起性能問題.
(2)一個線程持有鎖,會導致其它所有需要此鎖的線程掛起.
(3)如果一個優先級高的線程等待一個優先級低的線程釋放鎖會導致優先級倒置,引起性能風險.
volatile是不錯的機制,但是volatile不能保證原子性.因此對於同步最終還是要回到鎖機制上來.
獨佔鎖是一種悲觀鎖,synchronized就是一種獨佔鎖,會導致其它所有需要鎖的線程掛起,等待持有鎖的線程釋放鎖.
而另一個更加有效的鎖就是樂觀鎖.所謂樂觀鎖就是,每次不加鎖而是假設沒有衝突而去完成某項操作,如果因爲衝突失敗就重試,直到成功爲止.
1.CAS介紹
CAS是英文單詞Compare And Swap的縮寫,翻譯過來就是比較並替換.
CAS機制使用了3個基本操作數:內存值V,舊的預期值A,要修改的新值B.
當且僅當預期值A和內存值V相同時,纔會將內存值V,修改爲B,否則什麼都不做.
非阻塞算法 (nonblocking algorithms)

一個線程的失敗或者掛起不應該影響其他線程的失敗或掛起的算法.

現代的CPU提供了特殊的指令,可以自動更新共享數據,而且能夠檢測到其他線程的干擾,而 compareAndSet() 就用這些代替了鎖定.
拿出AtomicInteger來研究在沒有鎖的情況下是如何做到數據正確性的.

private volatile int value;

首先毫無以爲,在沒有鎖的機制下可能需要藉助volatile原語,保證線程間的數據是可見的(共享的).
這樣才獲取變量的值的時候才能直接讀取.

public final int get() {
        return value;
}

AtomicInteger的incrementAndGet的實現,看看++i是怎麼做到的.

public final int incrementAndGet() {
    for (;;) {
        int current = get();
        int next = current + 1;
        if (compareAndSet(current, next))
            return next;
    }
}

首先可以看到ta是通過一個無限循環(spin),直到increment成功爲止.
循環的內容是
1.取得當前值.
2.計算+1後的值.
3.如果當前值還有效(沒有被)的話,設置那個+1後的值.
4.如果設置沒成功(當前值已經無效了,即被別的線程改過了), 再從1開始.
在這裏採用了CAS操作,每次從內存中讀取數據然後,將此數據和+1後的結果,進行CAS操作,如果成功就返回結果,否則重試直到成功爲止.
而compareAndSet利用JNI來完成CPU指令的操作.

public final boolean compareAndSet(int expect, int update) {
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}

整體的過程就是這樣子的,利用CPU的CAS指令,同時藉助JNI來完成Java的非阻塞算法.其它原子操作都是利用類似的特性完成的.其中,

/**
 * Atomically update Java variable to <tt>x</tt> if it is currently
 * holding <tt>expected</tt>.
 * @return <tt>true</tt> if successful
*/
unsafe.compareAndSwapInt(this, valueOffset, expect, update);

類似於如下代碼執行效果,

if (this == expect) {
    this = update;
    return true;
} else {
    return false;
}

那麼問題就來了,成功過程中需要2個步驟;
1.比較this == expect;
2.替換this = update;
compareAndSwapInt如何這兩個步驟的原子性呢? 參考CAS的原理.
1.CAS原理.
CAS通過調用JNI的代碼實現的.
JNI:Java Native Interface爲JAVA本地調用,允許java調用其他語言.
而compareAndSwapInt就是藉助C來調用CPU底層指令實現的.
Unsafe類中的compareAndSwapInt,是一個本地方法,該方法的實現位於unsafe.cpp中,
如果你下載了OpenJDK的源代碼的話在hotspot\src\share\vm\prims\目錄下,可以找到unsafe.cpp,

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSwapInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x))
  UnsafeWrapper("Unsafe_CompareAndSwapInt");
  oop p = JNIHandles::resolve(obj);
  jint* addr = (jint *) index_oop_from_field_offset_long(p, offset);
  return (jint)(Atomic::cmpxchg(x, addr, e)) == e;
UNSAFE_END

1.先想辦法拿到變量value在內存中的地址.
2.通過Atomic::cmpxchg實現比較替換,其中參數x是即將更新的值,參數e是原內存的值.
下面從分析比較常用的CPU(intel x86)來解釋CAS的實現原理.
下面是sun.misc.Unsafe類的compareAndSwapInt()方法的源代碼:

public final native boolean compareAndSwapInt(Object o, long offset,int expected,int x);

可以看到這是個本地方法調用.
這個本地方法在openjdk中依次調用的c++代碼爲:
unsafe.cpp,atomic.cpp和atomicwindowsx86.inline.hpp.
這個本地方法的最終實現在openjdk的如下位置:
openjdk-7-fcs-src-b147-27jun2011\openjdk\hotspot\src\oscpu\windowsx86\vm\ atomicwindowsx86.inline.hpp(對應於windows操作系統,X86處理器).
下面是對應於intel x86處理器的源代碼的片段:

// Adding a lock prefix to an instruction on MP machine
// VC++ doesn't like the lock prefix to be on a single line
// so we can't insert a label after the lock prefix.
// By emitting a lock prefix, we can define a label after it.
#define LOCK_IF_MP(mp) __asm cmp mp, 0  \
                       __asm je L0      \
                       __asm _emit 0xF0 \
                       __asm L0:
inline jint Atomic::cmpxchg  (jint exchange_value, volatile jint*dest, jint compare_value) {
// alternative for InterlockedCompareExchange
  int mp = os::is_MP();
  __asm {
    mov edx, dest
    mov ecx, exchange_value
    mov eax, compare_value
    LOCK_IF_MP(mp)
    cmpxchg dword ptr [edx], ecx
  }
}

如上面源代碼所示,程序會根據當前處理器的類型來決定是否爲cmpxchg指令添加lock前綴.如果程序是在多處理器上運行,就爲cmpxchg指令加上lock前綴(lock cmpxchg).反之,如果程序是在單處理器上運行,就省略lock前綴(單處理器自身會維護單處理器內的順序一致性,不需要lock前綴提供的內存屏障效果).
intel的手冊對lock前綴的說明如下:
1.確保對內存的讀-改-寫操作原子執行.
在Pentium及Pentium之前的處理器中,帶有lock前綴的指令在執行期間會鎖住總線,使得其他處理器暫時無法通過總線訪問內存.很顯然,這會帶來昂貴的開銷.
從Pentium 4,Intel Xeon及P6處理器開始,intel在原有總線鎖的基礎上做了一個很有意義的優化:
如果要訪問的內存區域(area of memory),在lock前綴指令執行期間,已經在處理器內部的緩存中,被鎖定(即包含該內存區域的緩存行當前處於獨佔或以修改狀態),並且該內存區域被完全包含在單個緩存行(cache line)中,那麼處理器將直接執行該指令.
由於在指令執行期間該緩存行會一直被鎖定,其它處理器無法讀/寫,該指令要訪問的內存區域,因此能保證指令執行的原子性.
這個操作過程叫做緩存鎖定(cache locking),緩存鎖定將大大降低lock前綴指令的執行開銷,但是當多處理器之間的競爭程度很高或者指令訪問的內存地址未對齊時,仍然會鎖住總線.
2.禁止該指令與之前和之後的讀和寫指令重排序.
3.把寫緩衝區中的所有數據刷新到內存中.
在這裏可以看到是用嵌入的彙編實現的, 關鍵CPU指令是 cmpxchg
到這裏沒法再往下找代碼了. 也就是說CAS的原子性實際上是CPU實現的. 其實在這一點上還是有排他鎖的. 只是比起用synchronized, 這裏的排他時間要短的多. 所以在多線程情況下性能會比較好.
代碼裏有個alternative for InterlockedCompareExchange
這個InterlockedCompareExchange是WINAPI裏的一個函數, 做的事情和上面這段彙編是一樣的
http://msdn.microsoft.com/en-us/library/windows/desktop/ms683560%28v=vs.85%29.aspx
6. 最後再貼一下x86的cmpxchg指定

Opcode CMPXCHG
CPU: I486+ 
Type of Instruction: User 
Instruction: CMPXCHG dest, src 
Description: Compares the accumulator with dest. If equal the "dest" is loaded with "src", otherwise the accumulator is loaded with "dest". 
Flags Affected: AF, CF, OF, PF, SF, ZF 
CPU mode: RM,PM,VM,SMM 
+++++++++++++++++++++++ 
Clocks: 
CMPXCHG reg, reg 6 
CMPXCHG mem, reg 7 (10 if compartion fails) 

關於CPU的鎖有如下3種:
3.1 處理器自動保證基本內存操作的原子性.
首先處理器會自動保證基本的內存操作的原子性.處理器保證從系統內存當中讀取或者寫入一個字節是原子的,意思是當一個處理器讀取一個字節時,其他處理器不能訪問這個字節的內存地址.奔騰6和最新的處理器能自動保證單處理器對同一個緩存行裏進行16/32/64位的操作是原子的,但是複雜的內存操作處理器不能自動保證其原子性,比如跨總線寬度,跨多個緩存行,跨頁表的訪問.但是處理器提供總線鎖定和緩存鎖定兩個機制來保證複雜內存操作的原子性.
3.2 使用總線鎖保證原子性.
一個機制是通過總線鎖保證原子性.如果多個處理器同時對共享變量進行讀改寫(i++就是經典的讀改寫操作)操作,那麼共享變量就會被多個處理器同時進行操作,這樣讀改寫操作就不是原子的,操作完之後共享變量的值會和期望的不一致,舉個例子:如果i=1,我們進行兩次i++操作,我們期望的結果是3,但是有可能結果是2.如下圖
多核CPU併發執行
原因是有可能多個處理器同時從各自的緩存中讀取變量i,分別進行加一操作,然後分別寫入系統內存當中.那麼想要保證讀改寫共享變量的操作是原子的,就必須保證CPU1讀改寫共享變量的時候,CPU2不能操作緩存了該共享變量內存地址的緩存.
處理器使用總線鎖就是來解決這個問題的.所謂總線鎖就是使用處理器提供的一個LOCK#信號,當一個處理器在總線上輸出此信號時,其他處理器的請求將被阻塞住,那麼該處理器可以獨佔使用共享內存.
3.3 使用緩存鎖保證原子性.
第二個機制是通過緩存鎖定保證原子性.在同一時刻我們只需保證對某個內存地址的操作是原子性即可,但總線鎖定把CPU和內存之間通信鎖住了,這使得鎖定期間,其他處理器不能操作其他內存地址的數據,所以總線鎖定的開銷比較大,最近的處理器在某些場合下使用緩存鎖定代替總線鎖定來進行優化.
頻繁使用的內存會緩存在處理器的L1,L2和L3高速緩存裏,那麼原子操作就可以直接在處理器內部緩存中進行,並不需要聲明總線鎖,在奔騰6和最近的處理器中可以使用“緩存鎖定”的方式來實現複雜的原子性.所謂“緩存鎖定”就是如果緩存在處理器緩存行中內存區域在LOCK操作期間被鎖定,當它執行鎖操作回寫內存時,處理器不在總線上聲言LOCK#信號,而是修改內部的內存地址,並允許它的緩存一致性機制來保證操作的原子性,因爲緩存一致性機制會阻止同時修改被兩個以上處理器緩存的內存區域數據,當其他處理器回寫已被鎖定的緩存行的數據時會起緩存行無效,在例1中,當CPU1修改緩存行中的i時使用緩存鎖定,那麼CPU2就不能同時緩存了i的緩存行.
但是有兩種情況下處理器不會使用緩存鎖定.第一種情況是:當操作的數據不能被緩存在處理器內部,或操作的數據跨多個緩存行(cache line),則處理器會調用總線鎖定.第二種情況是:有些處理器不支持緩存鎖定.對於Inter486和奔騰處理器,就算鎖定的內存區域在處理器的緩存行中也會調用總線鎖定.
以上兩個機制我們可以通過Inter處理器提供了很多LOCK前綴的指令來實現.比如位測試和修改指令BTS,BTR,BTC,交換指令XADD,CMPXCHG和其他一些操作數和邏輯指令,比如ADD(加),OR(或)等,被這些指令操作的內存區域就會加鎖,導致其他處理器不能同時訪問它.
1.CAS缺點.
CAS雖然很高效的解決原子操作,但是CAS仍然存在三大問題.
1.ABA問題.
因爲CAS需要在操作值的時候檢查下值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了.ABA問題的解決思路就是使用版本號.在變量前面追加上版本號,每次變量更新的時候把版本號加一,那麼A-B-A 就會變成1A-2B-3A.
從Java1.5開始JDK的atomic包裏提供了AtomicStampedReference類,來解決ABA問題.
這個類的compareAndSet方法作用是首先檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設置爲給定的更新值.
關於ABA問題參考文檔:
http://blog.hesey.net/2011/09/resolve-aba-by-atomicstampedreference.html
2.循環時間長開銷大.
自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷.
如果JVM能支持處理器提供的pause指令那麼效率會有一定的提升,pause指令有兩個作用,第一它可以延遲流水線執行指令(de-pipeline),使CPU不會消耗過多的執行資源,延遲的時間取決於具體實現的版本,在一些處理器上延遲時間是零.第二它可以避免在退出循環的時候因內存順序衝突(memory order violation)而引起CPU流水線被清空(CPU pipeline flush),從而提高CPU的執行效率.
3.只能保證一個共享變量的原子操作.
當對一個共享變量執行操作時,我們可以使用循環CAS的方式來保證原子操作,但是對多個共享變量操作時,循環CAS就無法保證操作的原子性,這個時候就可以用鎖,或者有一個取巧的辦法,就是把多個共享變量合併成一個共享變量來操作.比如有兩個共享變量i=2,j=a,合併一下ij=2a,然後用CAS來操作ij.從Java1.5開始JDK提供了AtomicReference類來保證引用對象之間的原子性,你可以把多個變量放在一個對象裏來進行CAS操作.
1.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包的實現示意圖如下:
示意圖
參考:
http://www.blogjava.net/mstar/archive/2013/04/24/398351.html
https://blog.csdn.net/u011506543/article/details/82392338

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章