java原子操作CAS
- 2020 年 3 月 16 日
- 筆記
本次內容主要講原子操作的概念、原子操作的實現方式、CAS的使用、原理、3大問題及其解決方案,最後還講到了JDK中經常使用到的原子操作類。
1、什麼是原子操作?
所謂原子操作是指不會被執行緒調度機制打斷的操作,這種操作一旦開始,就一直運行到結束,中間不會有任何執行緒上下文切換。原子操作可以是一個步驟,也可以是多個操作步驟,但是其順序不可以被打亂,也不可以被切割而只執行其中的一部分。我們常用的i++看起來雖然簡單,但這並不是一個原子操作,具體原理後面單獨介紹。假定有兩個操作A和B,如果從執行A的執行緒來看,當另一個執行緒執行B時,要麼將B全部執行完,要麼完全不執行B,那麼A和B對彼此來說是原子的。將整個操作視作一個整體是原子性的核心特徵。
2、如何實現原子操作?
2.1 鎖機制實現原子操作及其問題
實現原子操作可以使用鎖。鎖機制滿足基本的需求是沒有問題的,但是有的時候我們的需求並非這麼簡單,我們需要更有效,更加靈活的機制。synchronized關鍵字是基於阻塞的鎖機制,也就是說當一個執行緒擁有鎖的時候,訪問同一資源的其它執行緒需要等待,直到該執行緒釋放鎖。使用synchronized關鍵字存在這樣的問題:
(1)如果被阻塞的執行緒優先順序很高很重要怎麼辦?
(2)如果獲得鎖的執行緒一直不釋放鎖怎麼辦?
(3)如果有大量的執行緒來競爭資源,那CPU將會花費大量的時間和資源來處理這些競爭,同時,還有可能出現一些例如死鎖之類的情況。
使用鎖機制是一種比較粗糙、粒度比較大的機制,我們可以想像多個執行緒操作同一個計數器的業務場景,使用鎖機制的話顯得太過笨重。
2.2 CAS機制
實現原子操作還可以使用當前的處理器基本都支援CAS(Compare And Swap)的指令,CPU指令集上提供了CAS操作相關指令,實現原子操作可以使用這些指令。每一個CAS操作過程都包含3個運算參數:一個記憶體地址V,一個期望的值A和一個新值B,操作的時候如果這個地址上存放的值等於這個期望的值A,則將地址上的值賦為新值B,否則不做任何操作。
2.3 CAS使用
先來模擬一個多個執行緒操作同一個計數器的場景,JDK中提供了boolean、int和long基本類型對應的原子包裝類AtomicBoolean、AtomicInteger和AtomicLong。我們用AtomicInteger演示,通過CountDownLatch進行並發模擬,如果對CountDownLatch用法不了解,歡迎查看上一篇文章,有通俗易懂的例子。先對AtomicInteger的主要API做一個介紹:
(1)int addAndGet(int delta):以原子方式將輸入的數值與實例中的值(AtomicInteger里的value)相加,並返回結果。
(2)boolean compareAndSet(int expect,int update):如果當前數值等於expect,則以原子方式將當前值設置為update。
(3)int getAndIncrement():以原子方式將當前值加1,注意,這裡返回的是自增前的值。
(4)int getAndSet(int newValue):以原子方式設置為newValue的值,並返回舊值。
import java.util.concurrent.CountDownLatch; import java.util.concurrent.atomic.AtomicInteger; public class AtomicIntegerDemo { static AtomicInteger counter = new AtomicInteger(0); static CountDownLatch countDownLatch = new CountDownLatch(20); static class CounterThread implements Runnable { @Override public void run() { try { countDownLatch.await(); } catch (InterruptedException e) { e.printStackTrace(); } counter.getAndIncrement(); } } public static void main(String[] args) throws InterruptedException { for (int i = 0; i < 20; i++) { Runnable thread = new CounterThread(); new Thread(thread).start(); countDownLatch.countDown(); } Thread.sleep(2000); //保證子執行緒全部執行完成 System.out.println("20個執行緒並發執行getAndIncrement()方法後的結果:" + counter.get()); counter.compareAndSet(20, 18);//如果counter當前數值為20,則以原子方式更新為18 System.out.println("compareAndSet(20, 18)後的結果:" + counter.get()); } }
程式中模擬了20個執行緒並發對一個計數器進行自增操作,結果輸出為20,可以看到這段程式碼並沒有用任何的鎖,也達到了原子操作目的。
2.4 CAS原理
CAS的基本思路就是,如果記憶體地址V上的值和期望的值A相等,則給其賦予新值B,否則不做任何事兒。CAS就是在一個循環里不斷的做CAS操作,直到成功為止。CAS是怎麼實現執行緒的安全呢?語言層面不做處理,JDK 調用這些指令來完成CAS操作,本質上就是將其交給CPU和記憶體,利用CPU的多處理能力,實現硬體層面的阻塞,再加上volatile變數的特性即可實現基於原子操作的執行緒安全。用一張圖來說明。
3、CAS實現原子操作的三大問題
3.1 ABA問題
因為CAS需要在操作值的時候,檢查值有沒有發生變化,如果沒有發生變化則更新,但是如果一個值原來是A,變成了B,又變成了A,那麼使用CAS進行檢查時會發現它的值沒有發生變化,但是實際上卻變化了。舉個通俗易懂的例子,我的同事老王今年35歲了,還沒有女朋友,我問他有什麼要求,給他介紹一個女朋友。老王就說了,只要是沒有結婚、35歲以下的女的就行。於是我就給他介紹了一個28歲,剛剛離婚不久的女同志,他還感謝了我好久,可能是他現在都還不知道他這個女朋友離過婚。這就是典型的ABA問題,只關心當前狀態,而不管中間經歷了什麼。ABA問題的解決思路就是使用版本號。給變數追加一個版本號,每次變數更新的時候把版本號加1,那麼A→B→A就會變成1A→2B→3A。就好比老王的要求改成:35歲以下,沒有結婚並且離婚次數為0的女性,就不會發生剛剛的事情了。
3.2 循環時間長開銷大。
CAS自旋如果長時間不成功,會給CPU帶來非常大的執行開銷。
3.3 只能保證一個共享變數的原子操作
當對一個共享變數執行操作時,我們可以使用循環CAS的方式來保證原子操作,但是對多個共享變數操作時,循環CAS就無法保證操作的原子性,這個時候就可以用鎖。怎麼解決這個問題呢?從Java 1.5開始,JDK提供了AtomicReference類來保證引用對象之間的原子性,就可以把多個變數放在一個對象里來進行CAS操作。
4、JDK中相關原子操作類
4.1 AtomicReference
AtomicReference,可以原子更新的對象引用。AtomicReference有一個compareAndSet()方法,它可以將已持有引用與預期引用進行比較,如果它們相等,則在AtomicReference對象內設置一個新的引用。看一段程式碼:
import java.util.concurrent.atomic.AtomicReference; public class AtomicReferenceDemo { static AtomicReference<UserInfo> atomicReference; public static void main(String[] args) { //原引用 UserInfo oldUser = new UserInfo("老王", 35); atomicReference = new AtomicReference<>(oldUser); //新引用 UserInfo updateUser = new UserInfo("小宋", 21); atomicReference.compareAndSet(oldUser, updateUser); System.out.println("使用compareAndSet()替換原有引用後的結果:" + atomicReference.get()); System.out.println("原引用:" + oldUser); } static class UserInfo { private String name; private int age; public UserInfo(String name, int age) { this.name = name; this.age = age; } public String getName() { return name; } public int getAge() { return age; } public void setName(String name) { this.name = name; } public void setAge(int age) { this.age = age; } @Override public String toString() { return "UserInfo{" + "name='" + name + ''' + ", age=" + age + '}'; } } }
從程式輸出可以看到,atomicReference的持有的引用被修改了,但是原引用對象並沒有發生改變。
4.2 AtomicStampedReference
AtomicStampedReference,利用版本戳的形式記錄了每次改變以後的版本號,這樣的話就不會存在ABA問題了。 AtomicStampedReference有一個內部類Pair,使用Pair的int stamp作為計數器使用,看下Pair的源碼:
private static class Pair<T> { final T reference; final int stamp; private Pair(T reference, int stamp) { this.reference = reference; this.stamp = stamp; } static <T> Pair<T> of(T reference, int stamp) { return new Pair<T>(reference, stamp); } }
還是老王那個例子,如果使用AtomicStampedReference的話,老王更關心的是介紹的女朋友離過幾次婚。用一段程式碼來模擬給老王介紹女朋友的場景:
import java.util.concurrent.atomic.AtomicStampedReference; public class AtomicStampedReferenceDemo { static AtomicStampedReference<String> asr = new AtomicStampedReference("介紹的女朋友", 0); public static void main(String[] args) throws InterruptedException { final String oldReference = asr.getReference();//初始值,表示介紹的女朋友 final int oldStamp = asr.getStamp();//初始版本0,表示介紹的女朋友沒有離過婚 Thread thread1 = new Thread(() -> { String newReference = oldReference + " 離婚1次"; boolean first = asr.compareAndSet(oldReference, newReference, oldStamp, oldStamp + 1); if (first) { System.out.println("介紹的女朋友第一次離婚。。。"); } boolean second = asr.compareAndSet(newReference, oldReference + "又離婚了", oldStamp + 1, oldStamp + 2); if (second) { System.out.println("介紹的女朋友第二次離婚。。。"); } }, "介紹的女朋友離婚"); Thread thread2 = new Thread(() -> { String reference = asr.getReference();//介紹的女朋友最新狀態 //判斷介紹的女朋友最新狀態是否符合老王的要求 boolean flag = asr.compareAndSet(reference, reference + "沒有離過婚", oldStamp, oldStamp + 1); if (flag) { System.out.println("老王笑嘻嘻地對我說,介紹的女朋友符合我的要求"); } else { System.out.println("老王拳頭緊握地對我說,介紹的女朋友居然離過" + asr.getStamp() + "次婚,不符合我要求!!!!"); } }, "老王相親"); thread1.start(); thread1.join(); thread2.start(); thread2.join(); } }
啟動2個子執行緒,分別代表介紹的女朋友多次離婚以及老王相親的場景。從程式輸出可以看到,介紹的女朋友不符合老王的要求,老王為了避免喜當爹,果斷拒絕了。
老王判斷的依據是,介紹的女朋友應該是沒有離過婚,stamp值等於0才對。但是老王仔細一看,stamp已經是2,不符合我的要求,不能要。
4.3 AtomicMarkableReference
AtomicMarkableReference,可以原子更新一個布爾類型的標記位和引用類型。構造方法是AtomicMarkableReference(V initialRef,booleaninitialMark)。AtomicMarkableReference也有一個內部類Pair,使用Pair的boolean mark來標記狀態。還是老王那個例子,使用AtomicStampedReference可能關心的是離婚次數,AtomicMarkableReference關心的是有沒有離過婚。用一段程式碼來模擬:
import java.util.concurrent.atomic.AtomicMarkableReference; public class AtomicMarkableReferenceDemo { static AtomicMarkableReference markableReference; public static void main(String[] args) throws InterruptedException { String girl = "介紹的女朋友"; markableReference = new AtomicMarkableReference(girl, false); Thread t1 = new Thread(() -> { markableReference.compareAndSet(girl, girl + "離婚", false, true); System.out.println(markableReference.getReference()); }, "介紹的女朋友離婚了"); Thread t2 = new Thread(() -> { //老王檢查標記,只關心這個標誌位 boolean marked = markableReference.isMarked(); if (marked) { System.out.println("你給我介紹的女朋友離過婚,我不要!!"); } else { System.out.println("兄弟,大兄弟,親生兄弟啊!!這個女朋友我要了"); } }, "老王鑒定介紹的女朋友有沒有離過婚"); t1.start(); t1.join(); t2.start(); t2.join(); } }
程式輸出可以看到,老王還是堅持了自己的原則。
4.4 AtomicIntegerArray
AtomicIntegerArray,元素可以原子更新的數組。其常用方法如下:
(1)int addAndGet(int i,int delta):以原子方式將輸入值與數組中索引i的元素相加。
(2)boolean compareAndSet(int i,int expect,int update):如果當前值等於預期值,則以原子方式將數組位置i的元素設置成update值。
需要注意的是,數組value通過構造方法傳遞進去,然後AtomicIntegerArray會將當前數組複製一份,所以當AtomicIntegerArray對內部的數組元素進行修改時,不會影響傳入的數組。
用法比較簡單,看一個例子:
public class AtomicIntegerArrayDemo { static int[] value = new int[]{1, 2};//原始數組 static AtomicIntegerArray atomicIntegerArray = new AtomicIntegerArray(value); public static void main(String[] args) { atomicIntegerArray.getAndSet(0, 3); System.out.println("atomicIntegerArray的第一個元素:" + atomicIntegerArray.get(0)); System.out.println("原始數組的第一個元素:" + value[0]);//原數組不會變化 } }
程式輸出可以看到,原始數組並沒有受到影響。
順便看一下AtomicIntegerArray的構造方法:
public AtomicIntegerArray(int[] array) { // Visibility guaranteed by final field guarantees this.array = array.clone(); }
5、結語
文中例子純屬虛構,便於對知識點的理解,不摻雜任何其他意思。下一篇內容中會介紹Java的顯示鎖Lock相關知識點,閱讀過程中如發現描述有誤,請指出,謝謝。