Java併發編程之Java CAS操做

該文章屬於《Java併發編程》系列文章,若是想了解更多,請點擊《Java併發編程之總目錄》ios

前言

在上一篇文章中咱們描述過,物理機計算機的數據緩存不一致的時候,咱們通常採用兩種方式來處理。一,經過總線加鎖的形式,二,經過緩存一致性協議來操做。而體現緩存一致性的正是CAS操做,CAS操做在整個Java併發框架中起着很是重要的做用。若是你們能把CAS的由來和原理完全搞清楚,我相信對於其餘關於Java中併發的問題都能迎刃而解。編程

緩存鎖

Java併發編程之Java內存模型文章中,在物理機計算機中當處理器中數據緩存不一致的時候,通常採用總線鎖。可是總線鎖把CPU和內存以前的通訊鎖住了,那麼在鎖按期間,其餘的處理器是不能操做其餘內存地址的數據。因此總線鎖的開銷比較大, 因此隨着技術的進步,如今計算機已經採用了緩存鎖來替代總線鎖來進行性能的優化。windows

緩存鎖的原理

cpu高速緩存.jpg

咱們都知道在CPU數據處理中,頻繁使用的內存會緩存在處理器的L一、L2和L3高速緩存裏,那麼數據的操做都在處理器內部緩存中進行。並不須要聲明總線鎖,在目前的處理器中可使用「緩存鎖定」的方式來處理數據不一致的狀況,這裏所謂的「緩存鎖定」是指內存區域若是被緩存在處理器的緩存中,而且在操做期間被鎖定,那麼當它執行鎖操做會寫到內存時,處理器並不會像鎖總線的那樣聲明LOCK#信號,而是修改其對應的內存地址。同時最重要的是其容許緩存一致性來保證數據的一致性。緩存

緩存一致性核心思想:在多處理器下,爲了保證各個處理器的緩存是一致的,就會實現緩存一致性協議,每一個處理經過嗅探在總線上傳播的數據來檢查本身的緩存的值是否是過時了,當處理器發現本身緩存的數據對應的內存地址被修改,就會將當前處理器緩存的數據處理爲無效,當處理器對這個數據進行修改的操做的時候,會從新從系統內存中把數據讀處處理器緩存中。bash

緩存鎖與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操做執行時,當且僅當主內存對應的值等於舊值時,處理器用新值去更新舊值,不然它就不執行更新。可是不管是否更新了主內存中的值,都會返回舊值,上述的處理過程是一個原子操做。框架

對於概念類的東西,你們理解起來比較困難,這裏簡單舉個例子以下圖所示: oop

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來說解,具體代碼以下:post

//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問題。循環時間開銷太大,只能保證一個共享變量的原子操做。
相關文章
相關標籤/搜索