java原子操作CAS

  • 2020 年 3 月 16 日
  • 筆記

  本次內容主要講原子操作的概念、原子操作的實現方式、CAS的使用、原理、3大問題及其解決方案,最後還講到了JDK中經常使用到的原子操作類。

 

1、什麼是原子操作?

   所謂原子操作是指不會被執行緒調度機制打斷的操作,這種操作一旦開始,就一直運行到結束,中間不會有任何執行緒上下文切換。原子操作可以是一個步驟,也可以是多個操作步驟,但是其順序不可以被打亂,也不可以被切割而只執行其中的一部分。我們常用的i++看起來雖然簡單,但這並不是一個原子操作,具體原理後面單獨介紹。假定有兩個操作AB,如果從執行A的執行緒來看,當另一個執行緒執行B時,要麼將B全部執行完,要麼完全不執行B,那麼AB對彼此來說是原子的。將整個操作視作一個整體是原子性的核心特徵。

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、AtomicIntegerAtomicLong我們用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相關知識點,閱讀過程中如發現描述有誤,請指出,謝謝。