詳解Java中CAS機制的原理與優(yōu)缺點
什么是 CAS 機制
CAS 英文就是 compare and swap ,也就是比較并交換,首先它是一個原子操作,可以避免被其他線程打斷。在Java并發(fā)中,最初接觸的應該就是synchronized
關鍵字了,但是synchronized
屬于重量級鎖,很多時候會引起性能問題,雖然在新的JDK中對其已經進行了優(yōu)化。volatile
也是個不錯的選擇,但是volatile
不能保證原子性,只能在某些場合下使用。那么問題來了,這個 CAS 機制是怎么在不加鎖的情況下來保證共享資源的互斥呢?
synchronized補充:首先,synchronized會對第一個線程會有偏向,所以會給第一個線程添加偏向鎖,如果偏向鎖有其他線程來競爭時,這個鎖會升級,變成輕量級鎖(多數情況下是自旋鎖),再如果這個鎖在一定次內還是拿不到共享資源,這個輕量級的鎖會進一步升級,成為重量級鎖。
共享資源:通俗來講就是指那些可以被多個不同線程一起使用的數據。
首先先來看一個例子
當有很多個線程同時訪問一個貢獻資源時,無法保證的貢獻資源只被一個線程使用,如果要保證只有一個線程使用時,最先想到的一般就是加鎖。當然先看不加鎖的情況:
static volatile int NUMBER = 0; //volatile可以保證我們每次取值都是取內存的值(最新值),而不是取棧里面的緩存值 private static void add() throws InterruptedException { Thread.sleep(5); //模擬線程處理數據 NUMBER++; } public static void main(String[] args) throws InterruptedException { CountDownLatch countDownLatch = new CountDownLatch(100);//柵欄,底層為AQS實現,當里面值變?yōu)?時,柵欄才打開繼續(xù)運行下面代碼 Long start = System.currentTimeMillis(); for (int i = 0; i < 100; i++){ new Thread(new Runnable() { @Override public void run() { for (int j = 0; j<100; j++){ try { add(); } catch (InterruptedException e) { e.printStackTrace(); } } countDownLatch.countDown(); } }).start(); } countDownLatch.await();//等待所有線程運行完 System.out.println("最后執(zhí)行結果:" + NUMBER + "。執(zhí)行時間為:" + (System.currentTimeMillis() - start) + "ms"); } //最后執(zhí)行結果:9637。執(zhí)行時間為:600ms
首先要剖析add方法里面的代碼,一方面在方法上加了鎖,導致線程只能串行化,而且線程在方法中sleep了5毫秒,才進行NUMBER++,另一方面**在進行NUMBER++中,NUMBER++也并非原子操作。**看到代碼的注釋:
private synchronized static void add() throws InterruptedException { Thread.sleep(5); /** * NUMBER++ 被拆分成三步 * 1、從內存中獲取 NUMBER 的值,賦給 A : A = NUMBER * 2、對 A 進行 +1 得到 B: B = A + 1 * 3、把 B 的值寫回 NUMBER:NUMBER = B * 在不加鎖的情況下,如果有A.B兩個線程同時執(zhí)行count++,他們通知執(zhí)行到上面步驟的第一步,得到count是一樣的,而當第 3 步操作結束后,count只加1,導致count結果不一致 */ NUMBER++; } //最后執(zhí)行結果:10000。執(zhí)行時間為:56534ms
理清 JVM 的運行過程,可以發(fā)現其實把鎖加在第三步就行,這樣可以保證原子性夠小,但是要怎么做呢?(重點)
CAS(compare and swap) 就是一種樂觀鎖,比較和交換,原理是: 它有3個操作數,內存值 V,舊的期望值 expectNumber,要修改的新值 newNumber。當且僅當預期值A和內存值V相同時(比較),它就認為這個期間沒有人來訪問過這個貢獻資源。所以就把這個值改為新值(交換)。
static volatile int NUMBER = 0;//共享資源 private static void add() throws InterruptedException { Thread.sleep(5); /** * NUMBER++ 被拆分成三步 * 1、從內存中獲取 NUMBER 的值,賦給 A : A = NUMBER * 2、對 A 進行 +1 得到 B: B = A + 1 * 3、把 B 的值寫回 NUMBER:NUMBER = B * 升級第三步: * 1、獲取鎖 * 2、獲取 NUMBER 的最新值,記作LV * 3、判斷 LV 是否等于 A,如果相等,則將 B 的值賦給NUMBER,并且返回true,如果不相等返回false,線程再自旋請求修改值 */ int expectNumber; for (;;)//自旋修改,知道修改成功 if (compareAndSwap((expectNumber=getNumber()),expectNumber+1)) break; } /** * 模擬底層 sun.misc.Unsafe 的 compareAndSwap 方法,所以這里我加了鎖,底層并不是加鎖,它的底層是native修飾的,是方法區(qū)的方法,由C或者C++寫的,它是對數據總線加鎖,synchronized是對字節(jié)碼加鎖。 * @param expectNumber 期望值 * @param newNumber 要交換的值 * @return 返回交換是否成功 */ public static synchronized boolean compareAndSwap(int expectNumber, int newNumber){ if (expectNumber == getNumber()){ NUMBER = newNumber; return true; } return false; } public static int getNumber(){return NUMBER;} //最后執(zhí)行結果:10000。執(zhí)行時間為:596ms
來看看 JDK 對 CAS 的支持
首先,上面模擬的 compareAndSwap 其實是再 sun.misc.Unsafe 包里面的,JDK 并不希望開發(fā)者去使用這個包,這個包里面有三個相關的方法
public final native boolean compareAndSwapObject(Object var1, long var2, Object var4, Object var5); public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5); public final native boolean compareAndSwapLong(Object var1, long var2, long var4, long var6); //var1:表示要訪問的對象,對象引用地址 //var2:表示偏移量,即屬性再要訪問對象地址的偏移量 //var4:表示要修改的數據的期望值 //var5:表示要修改為的新值
CAS 的實現原理
CAS 通過 JNI(Java Native Interface)的代碼來實現,允許java調用其他語言。而 compareAndSwap 的方法就是借用C語言來調用CPU底層的指令(cmpxchg)來實現的。cmpxchg是一個原子指令,這個指令是給數據總線進行加鎖,所以是線程安全的。
CAS 其實也是有缺點的
1、首先就是經典的ABA問題
CAS需要在操作值的時候檢查下值有沒有發(fā)生變化,如果沒有發(fā)生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那么使用CAS進行檢查時會發(fā)現它的值沒有發(fā)生變化,但是實際上卻變化了。這就是CAS的ABA問題。
2、就是循環(huán)時間長開銷大
如果CAS不成功,則會原地自旋,如果長時間自旋會給CPU帶來非常大且沒必要的開銷。
3、只能保證一個共享變量的原子操作
只能保證一個共享變量的原子操作。當對一個共享變量執(zhí)行操作時,可以使用循環(huán)CAS的方式來保證原子操作,但是對多個共享變量操作時,循環(huán)CAS就無法保證操作的原子性,這個時候就可以用鎖,或者有一個取巧的辦法,就是把多個共享變量合并成一個共享變量來操作。比如有兩個共享變量 i=2,j=a,合并一下 ij=2a,然后用CAS來操作間。從Java1.5開始DK提供了AtomicRefrence類來保證引用對象之間的原子性,你可以把多個變量放在一個對象里來進行CAS操作。
如何解決 ABA 問題
常見的解決思路是使用版本號。在變量前面追加上版本號,每次變量更新的時候把版本號加一,那么A-B-A
就會變成1A-2B-3A
。 目前在JDK的atomic包里提供了一個類AtomicStampedReference
來解決ABA問題。這個類的compareAndSet方法作用是首先檢查當前引用是否等于預期引用,并且當前標志是否等于預期標志,如果全部相等,則以原子方式將該引用和該標志的值設置為給定的更新值。
//沒有版本號的情況 static AtomicInteger a = new AtomicInteger(1); public static void main(String[] args) throws InterruptedException { System.out.println("初始值:"+a.get()); new Thread(new Runnable() { @Override public void run() { int expectNum = a.get();//拿到最初的值 int newNum = expectNum+1; try { Thread.sleep(1000); //先休眠一會,保證是最后執(zhí)行的 } catch (InterruptedException e) { e.printStackTrace(); } boolean isSuccess = a.compareAndSet(expectNum, newNum); System.out.println("主線程執(zhí)行結果:" + isSuccess + ",值為:" + a.get()); } },"主線程").start(); new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); } a.incrementAndGet(); System.out.println("干擾線程執(zhí)行結果:" + a.get()); a.decrementAndGet(); System.out.println("干擾線程執(zhí)行結果:" + a.get()); } },"干擾線程").start(); } /* 初始值:1 干擾線程執(zhí)行結果:2 干擾線程執(zhí)行結果:1 主線程執(zhí)行結果:ture,值為:2*/
上面就是模擬的ABA情況,那么使用AtomicStampedReference
可以怎么解決呢?先看到AtomicStampedReference
里面關于創(chuàng)建對象和修改版本號和值的 API 。
//initialRef為初始值,initialStamp為初始版本號 public AtomicStampedReference(V initialRef, int initialStamp) { pair = Pair.of(initialRef, initialStamp); } /** * Atomically sets the value of both the reference and stamp * to the given update values if the * current reference is {@code ==} to the expected reference * and the current stamp is equal to the expected stamp. * * @param expectedReference the expected value of the reference 期望的引用 * @param newReference the new value for the reference 引用的新值 * @param expectedStamp the expected value of the stamp 期望的版本號 * @param newStamp the new value for the stamp 新值的版本號 * @return {@code true} if successful */ public boolean compareAndSet(V expectedReference, V newReference, int expectedStamp, int newStamp) { Pair<V> current = pair; return expectedReference == current.reference && expectedStamp == current.stamp && //前兩個判斷當前的版本或者當前的值是否被改過,改過就直接返回false ((newReference == current.reference && newStamp == current.stamp) //上面兩個判斷當前的版本號和新值是否和要修改后的一致,一致就不進行修改 //如果不一致,就修改當前的版本值和當前值 || casPair(current, Pair.of(newReference, newStamp))); }
由于每個過程值都會有對應的版本,所以在修改過程中需要傳入期望版本和當前的值,數據庫的多版本并發(fā)控制也類似,先來看一下修改后的代碼:
static AtomicStampedReference<Integer> a = new AtomicStampedReference(new Integer(1),1); public static void main(String[] args) throws InterruptedException { System.out.println("初始值:"+a.getReference()); new Thread(new Runnable() { @Override public void run() { //獲取需要的參數 int expectReference = a.getReference(); int newReference = expectReference + 1; int expectStamp = a.getStamp(); int newStamp = expectStamp + 1; try { Thread.sleep(1000);//睡眠保證最后執(zhí)行 } catch (InterruptedException e) { e.printStackTrace(); } //傳入我們之前拿到的期望版本號和期望的值 boolean isSuccess = a.compareAndSet(expectReference, newReference, expectStamp, newStamp); System.out.println("主線程執(zhí)行結果:" + isSuccess + ",值為:" + a.getReference()); } },"主線程").start(); new Thread(new Runnable() { @Override public void run() { try { Thread.sleep(20); } catch (InterruptedException e) { e.printStackTrace(); } //模擬修改ABA模式 a.compareAndSet(a.getReference(),a.getReference()+1,a.getStamp(),a.getStamp()+1); System.out.println("干擾線程執(zhí)行結果:" + a.getReference()); a.compareAndSet(a.getReference(),a.getReference()-1,a.getStamp(),a.getStamp()+1); System.out.println("干擾線程執(zhí)行結果:" + a.getReference()); } },"干擾線程").start(); } /* 初始值:1 干擾線程執(zhí)行結果:2 干擾線程執(zhí)行結果:1 主線程執(zhí)行結果:false,值為:1*/
可以看到執(zhí)行結果是false,也就是說只要值被動過,就會修改失敗,但是版本號需要人工維護。當然,如果對于業(yè)務的過程不是很注重的話也不需要去關注ABA問題,也不需要去維護版本號,而如果涉及到重要業(yè)務(轉賬),則需要解決ABA問題。
如何解決循環(huán)時間長開銷大問題
自旋CAS如果長時間不成功,會給CPU帶來非常大的執(zhí)行開銷。如果JVM能支持處理器提供的pause指令那么效率會有一定的提升,pause指令有兩個作用,第一它可以延遲流水線執(zhí)行指令(de-pipeline),使CPU不會消耗過多的執(zhí)行資源,延遲的時間取決于具體實現的版本,在一些處理器上延遲時間是零。第二它可以避免在退出循環(huán)的時候因內存順序沖突(memory order violation)而引起CPU流水線被清空(CPU pipeline flush),從而提高CPU的執(zhí)行效率。
總結
- CAS 可以實現原子性的操作,說白了就是一個native的API,解決并發(fā)帶來的問題,但是也存在一些自身的問題。
- 如何解決CAS自身帶來的問題。
- 明白他的應用,在讀Concurrent包下的類的源碼時,發(fā)現無論是ReenterLock內部的AQS(后續(xù)會出博客講到),還是各種Atomic開頭的原子類,內部都應用到了
CAS
。
到此這篇關于詳解Java中CAS機制的原理與優(yōu)缺點的文章就介紹到這了,更多相關Java CAS機制內容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關文章希望大家以后多多支持腳本之家!
相關文章
IDEA中Maven依賴包無法下載或導入的解決方案(系統(tǒng)缺失文件導致)
在配置Maven環(huán)境時,可能會遇到各種報錯問題,首先確保Maven路徑配置正確,例如使用apache-maven-3.5.0版本,則需要在系統(tǒng)環(huán)境變量的Path中添加其bin目錄路徑,并上移優(yōu)先級,接下來,在Maven的conf目錄下修改settings.xml文件,將鏡像源改為阿里云2024-09-09spring boot springjpa 支持多個數據源的實例代碼
這篇文章主要介紹了spring boot springjpa 支持多個數據源的實例代碼,需要的朋友可以參考下2018-04-04