Java併發程式設計之Java CAS操作

AndyandJennifer發表於2018-09-22

該文章屬於《Java併發程式設計》系列文章,如果想了解更多,請點選《Java併發程式設計之總目錄》

前言

在上一篇文章中我們描述過,物理機計算機的資料快取不一致的時候,我們一般採用兩種方式來處理。一,通過匯流排加鎖的形式,二,通過快取一致性協議來操作。而體現快取一致性的正是CAS操作,CAS操作在整個Java併發框架中起著非常重要的作用。如果大家能把CAS的由來和原理徹底搞清楚,我相信對於其他關於Java中併發的問題都能迎刃而解。

快取鎖

Java併發程式設計之Java記憶體模型文章中,在物理機計算機中當處理器中資料快取不一致的時候,一般採用匯流排鎖。但是匯流排鎖把CPU和記憶體之前的通訊鎖住了,那麼在鎖定期間,其他的處理器是不能操作其他記憶體地址的資料。所以匯流排鎖的開銷比較大, 所以隨著技術的進步,現在計算機已經採用了快取鎖來替代匯流排鎖來進行效能的優化。

快取鎖的原理

cpu快取記憶體.jpg

我們都知道在CPU資料處理中,頻繁使用的記憶體會快取在處理器的L1、L2和L3快取記憶體裡,那麼資料的操作都在處理器內部快取中進行。並不需要宣告匯流排鎖,在目前的處理器中可以使用“快取鎖定”的方式來處理資料不一致的情況,這裡所謂的“快取鎖定”是指記憶體區域如果被快取在處理器的快取中,並且在操作期間被鎖定,那麼當它執行鎖操作會寫到記憶體時,處理器並不會像鎖匯流排的那樣宣告LOCK#訊號,而是修改其對應的記憶體地址。同時最重要的是其允許快取一致性來保證資料的一致性。

快取一致性核心思想:在多處理器下,為了保證各個處理器的快取是一致的,就會實現快取一致性協議,每個處理通過嗅探在匯流排上傳播的資料來檢查自己的快取的值是不是過期了,當處理器發現自己快取的資料對應的記憶體地址被修改,就會將當前處理器快取的資料處理為無效,當處理器對這個資料進行修改的操作的時候,會重新從系統記憶體中把資料讀到處理器快取中。

快取鎖與CAS(Compare-and-Swap)的關係

為了實現快取鎖,在物理計算機中,Intel處理器提供了很多Lock字首(注意是帶Lock字首,字首,字首)的指令。例如,位測試和修改指令:BTS、BTR、BTC;交換指令XADD、CMPXCHG,以及其他一些運算元和邏輯指令(如ADD、OR)等,被這些指令操作的記憶體區域就會加鎖,導致其他處理器不能同時訪問它。(不同的處理器實現快取鎖的指令不同,在sparc-TSO使用casa指令,而在ARM和PowerPc架構下,則需要使用一對ldrex/strex指令。)

而在Java中涉及到快取鎖的主要是CAS操作,CAS操作正是使用了不同處理器下提供的快取鎖的指令。

CAS(Compare-and-Swap)簡介

CAS指令需要三個運算元,分別是記憶體地址(在Java記憶體模型中可以簡單理解為主記憶體中變數的記憶體地址)、舊值(在Java記憶體模型中,可以理解工作記憶體中快取的主記憶體的變數的值)和新值。CAS操作執行時,當且僅當主記憶體對應的值等於舊值時,處理器用新值去更新舊值,否則它就不執行更新。但是無論是否更新了主記憶體中的值,都會返回舊值,上述的處理過程是一個原子操作。

對於概念類的東西,大家理解起來比較困難,這裡簡單舉個例子如下圖所示:

CAS操作與快取一致性.png
在上圖中,分別有兩條執行緒A與B,其中執行緒A優先與執行緒B執行a++操作,,執行緒A工作記憶體快取a的值為10,主記憶體中的a的值也為10,這個時候如果進行CAS操作,會與主記憶體中的a的值進行對比,如果相等會將執行a++操作後的值也就是11同步到主記憶體中,這個時候主記憶體中的值為11。當執行緒A執行完後,執行緒B接著執行,可是執行緒B中工作記憶體中快取的a的值為8,根據快取一致性原則。會重新去主記憶體讀取a的值(11),此時執行緒B中工作記憶體中快取的a的值為11,接著執行a++運算後a的值為12,此時將a的值12同步到主記憶體中。

CAS在Java中的實現

在Java中,CAS操作由sun.misc.Unsafe類裡面的compareAndSwapInt()和compareAndSwapLong(),compareAndSwapObject幾個方法實現。這裡我們就使用compareAndSwapInt來講解,具體程式碼如下:

  //native層
	  private static final jdk.internal.misc.Unsafe theInternalUnsafe
	   = jdk.internal.misc.Unsafe.getUnsafe();
   
	 
    public final boolean compareAndSwapInt(Object o, long offset,
                                           int expected,
                                           int x) {
        return theInternalUnsafe.compareAndSetInt(o, offset, expected, x);
    }
複製程式碼

在sun.misc.Unsafe方法中,compareAndSwapInt有4個引數,第一個引數object是當前物件,第二個引數offest表示該變數在記憶體中的偏移地址(CAS底層是根據記憶體偏移位置來獲取的),第三個引數expected為舊值,第四個引數x為新值。在該方法具體的細節是交給jdk.internal.misc.Unsafe類的compareAndSetInt()方法來處理的。繼續檢視theInternalUnsafe下的compareAndSetInt()方法。

	public final native boolean compareAndSetInt(Object o, long offset,
                                                 int expected,
                                                 int x);
複製程式碼

在jdk.internal.misc.Unsafe中的compareAndSetInt也是一個本地方法。

    @HotSpotIntrinsicCandidate
    public final native boolean compareAndSetInt(Object o, long offset,
                                                 int expected,
                                                 int x);
複製程式碼

這裡具體的本地方法是在hotspot下的unsafe.cpp類具體實現的。compareAndSetInt呼叫unsafe.cpp中的JNI方法具體實現如下:

UNSAFE_ENTRY(jboolean, Unsafe_CompareAndSetInt(JNIEnv *env, jobject unsafe, jobject obj, jlong offset, jint e, jint x)) {
  oop p = JNIHandles::resolve(obj);
  if (p == NULL) {
    volatile jint* addr = (volatile jint*)index_oop_from_field_offset_long(p, offset);
    return RawAccess<>::atomic_cmpxchg(x, addr, e) == e;
  } else {
    assert_field_offset_sane(p, offset);
    return HeapAccess<>::atomic_cmpxchg_at(x, p, (ptrdiff_t)offset, e) == e;
  }
} UNSAFE_END
複製程式碼

unsafe.cpp最終會呼叫atomic.cpp而atomic.cpp會根據不同的處理呼叫不同的處理器指令,這裡我們還是以Intel的處理器為例,atomic.cpp最終會呼叫atomic_windows_x86.cpp中的operator()方法。(這裡我省略了unsafe.cpp與atomic.cpp的內部細節,本身這裡對C++也不是很很熟,不想誤導大家,如果大家對原始碼比較感興趣,這裡把相關jdk原始碼分享給大家jdk原始碼)。

atomic_windows_x86.cpp中operator()方法具體如下:

template<>
template<typename T>
inline T Atomic::PlatformCmpxchg<4>::operator()(T exchange_value,
                                                T volatile* dest,
                                                T compare_value,
                                                atomic_memory_order order) const {
  STATIC_ASSERT(4 == sizeof(T));
  // alternative for InterlockedCompareExchange
  __asm {
    mov edx, dest 
    mov ecx, exchange_value
    mov eax, compare_value
    lock cmpxchg dword ptr [edx], ecx
  }
}
複製程式碼

簡單的對atomic_windows_x86.cpp中的operator()的方法進行介紹,第一個引數exchange_value為新值,第二個引數volatile* dest為變數記憶體地址(也就是主記憶體中變數地址),第三個引數compare_value為舊值(也就是工作記憶體中快取的變數值)。其中在方法中,asm是C++中的關鍵字,主要作用為啟動內聯彙編,同時其能寫在任何C++合法語句之處。它不能單獨出現,必須接彙編指令、一組被大括號包含的指令或一對空括號。

那麼針對於operrator中的彙編語句塊進行分析,要內容分為四個部分(這裡我們就把edx,ecx,eax當做儲存資料的容器):

  1. mov edx, dest 將變數的記憶體地址賦值到edx中。
  2. mov ecx, exchange_value 將新值賦值到ecx中。
  3. mov eax,compare_value 將舊值賦值到eax中。
  4. lock cmpxchg dword ptr [edx], ecx ,在瞭解該語句之前,我們先說三個知識點:

cmpxchg彙編指令:主要操作邏輯是比較eax與第一運算元的值,如果相等,那麼第二運算元的值裝載到第一運算元,如果不相等,那麼第一運算元的值裝載到eax中,其中cmpxchg 格式如下:cmpxchg 第一運算元,第二個運算元。舉個例子:

eax對應的值與第一運算元的值相等
int main(){
	int a=0,b=0,c=0;
 
	__asm{
		mov eax,100; //eax 賦值為100
		mov a,eax; //將eax的值賦值給變數a,那麼a的值為100
	}
	cout << "a := " << a << endl;//列印a的值
	b = 99;
	c = 11;
	__asm{
		mov ebx,b //ebx賦值為99
		cmpxchg c,ebx// eax為100,c為11,不相等,那麼eax的值為11
		mov a,eax //將eax的值賦值給變數a,那麼a最終的值為11
	}
	cout << "b := " << b << endl;//列印b的值
	cout << "c := " << c << endl;//列印c的值
	cout << "a := " << a << endl;//列印a的值
	return 0;
}
複製程式碼

對應輸出結果為a= 100,b=99,c =99,a =11。

eax對應的值與第一運算元的值不相等
#include<iostream>
using namespace std;
int main(){
	int a=0,b=0,c=0;
 
	__asm{
		mov eax,100;
		mov a,eax// a的值為99
	}
	cout << "a := " << a << endl;//列印a的值
	b = 99;
	c = 99;
	__asm{
		mov eax,99 //eax 值為99
		mov ebx,777// ebx 值為777
		cmpxchg c,ebx// 比較eax與c的值,相等 那麼c對應的值為ebx的值,也就是777
		mov a,eax//將eax的值賦值給變數a 
	}
	cout << "b := " << b << endl;//列印b的值
	cout << "c := " << c << endl;//列印c的值
	cout << "a := " << a << endl;//列印a的值
	return 0;
}
複製程式碼

對應輸出結果為a= 100,b=99,c =777,a =99。

dword彙編指令:dword ptr [edx] 簡單來說,就是獲取edx中記憶體地址中的具體的資料值。

lock彙編指令:lock指令做的事情比較多。這裡要分為三個部分。

  • 在Pentium及之前的處理器中,帶有lock字首的指令在執行期間會鎖住匯流排。在新的處理器中,Intel使用快取鎖定來保證指令執行的原子性,快取鎖定將大大降低lock字首指令的執行開銷。
  • 禁止該指令與前面和後面的讀寫指令重排序。
  • 把寫緩衝區的所有資料重新整理到記憶體中。 額外提一句。上面的第2點和第3點所具有的記憶體屏障效果,保證了CAS同時具有volatile讀和volatile寫的記憶體語義。

在瞭解了上訴知識點後,我們再來理解語句4就很好理解了。如果主記憶體中的值與舊值(也就是工作記憶體中快取的變數值)不同,那麼工作記憶體中的快取的變數值(也就是舊值)就為主記憶體中的值。如果相同。那麼主記憶體中的值就為最新的值。

CAS會出現的三大問題

雖然通過CAS操作可以很好的提高我們在處理資料的時候的效率,但是任然會出現許多問題。但是Java的開發團隊已經為我們提供了一些處理方案,現在我們就來看看CAS有哪三大問題。

ABA問題

因為CAS需要在操作值的時候,檢查值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。ABA問題的解決思路就是使用版本號。在變數前面追加上版本號,每次變數更新的時候把版本號加1,那麼A→B→A就會變成1A→2B→3A。從Java 1.5開始,JDK的Atomic包裡提供了一個類AtomicStampedReference來解決ABA問題。這個類的compareAndSet方法的作用是首先檢查當前引用是否等於預期引用,並且檢查當前標誌是否等於預期標誌,如果全部相等,則以原子方式將該引用和該標誌的值設定為給定的更新值。關於AtomicStampedReference的使用,有興趣的小夥伴可以自行檢視相關原始碼實現。

迴圈時間開銷太大

在後期的文章我們會講述自旋CAS,關於自旋CAS,因為後期關於鎖的文章會具體描述,這裡我就簡單描述一下,在Java中有很多的併發框架都使用了自旋CAS來獲取相應的鎖,會一直迴圈直到獲取到相應的鎖後,然後執行相應的操作。那麼當其自旋時CAS,會一直佔用CPU的資源。如果自旋CAS長時間不成功,會給CPU帶來非常大的執行開銷。

只能保證一個共享變數的原子操作

當對一個共享變數執行操作時,我們可以使用迴圈CAS的方式來保證原子操作,但是對多個共享變數操作時,自旋CAS就無法保證操作的原子性,這個時候就可以用鎖。還有一個取巧的辦法,就是把多個共享變數合併成一個共享變數來操作。比如,有兩個共享變數i=2,j=a,合併一下ij=2a,然後用CAS來操作ij。從Java 1.5開始,JDK提供了AtomicReference類來保證引用物件之間的原子性,就可以把多個變數放在一個物件裡來進行CAS操作。關於AtomicReference的使用,有興趣的小夥伴可以自行檢視相關原始碼實現。

總結

  • 對於物理計算機中的快取鎖,在Java中是使用CAS操作來實現的。
  • CAS操作中會出現三個問題,ABA問題。迴圈時間開銷太大,只能保證一個共享變數的原子操作。

相關文章