計算機程式的思維邏輯 (70) – 原子變數和CAS

swiftma發表於2019-03-04

本系列文章經補充和完善,已修訂整理成書《Java程式設計的邏輯》(馬俊昌著),由機械工業出版社華章分社出版,於2018年1月上市熱銷,讀者好評如潮!各大網店和書店有售,歡迎購買:京東自營連結

計算機程式的思維邏輯 (70) – 原子變數和CAS

從本節開始,我們探討Java併發工具包java.util.concurrent中的內容,本節先介紹最基本的原子變數及其背後的原理和思維。

原子變數

什麼是原子變數?為什麼需要它們呢?

理解synchronized一節,我們介紹過一個Counter類,使用synchronized關鍵字保證原子更新操作,程式碼如下:

public class Counter {
    private int count;

    public synchronized void incr(){
        count ++;
    }
    
    public synchronized int getCount() {
        return count;
    }
}
複製程式碼

對於count++這種操作來說,使用synchronzied成本太高了,需要先獲取鎖,最後還要釋放鎖,獲取不到鎖的情況下還要等待,還會有執行緒的上下文切換,這些都需要成本。

對於這種情況,完全可以使用原子變數代替,Java併發包中的基本原子變數型別有:

  • AtomicBoolean:原子Boolean型別
  • AtomicInteger:原子Integer型別
  • AtomicLong:原子Long型別
  • AtomicReference:原子引用型別

這是我們主要介紹的類,除了這四個類,還有一些其他的類,我們也會進行簡要介紹。

針對Integer, Long和Reference型別,還有對應的陣列型別:

  • AtomicIntegerArray
  • AtomicLongArray
  • AtomicReferenceArray

為了便於以原子方式更新物件中的欄位,還有如下的類:

  • AtomicIntegerFieldUpdater
  • AtomicLongFieldUpdater
  • AtomicReferenceFieldUpdater

AtomicReference還有兩個類似的類,在某些情況下更為易用:

  • AtomicMarkableReference
  • AtomicStampedReference

你可能會發現,怎麼沒有針對char, short, float, double型別的原子變數呢?大概是用的比較少吧,如果需要,可以轉換為int/long,然後使用AtomicInteger或AtomicLong。比如,對於float,可以使用如下方法和int相互轉換:

public static int floatToIntBits(float value)
public static float intBitsToFloat(int bits);
複製程式碼

下面,我們先來看幾個基本原子型別,從AtomicInteger開始。

AtomicInteger

基本用法

AtomicInteger有兩個構造方法:

public AtomicInteger(int initialValue)
public AtomicInteger()
複製程式碼

第一個構造方法給定了一個初始值,第二個的初始值為0。

可以直接獲取或設定AtomicInteger中的值,方法是:

public final int get()
public final void set(int newValue) 
複製程式碼

之所以稱為原子變數,是因為其包含一些以原子方式實現組合操作的方法,比如:

//以原子方式獲取舊值並設定新值
public final int getAndSet(int newValue)
//以原子方式獲取舊值並給當前值加1
public final int getAndIncrement()
//以原子方式獲取舊值並給當前值減1
public final int getAndDecrement()
//以原子方式獲取舊值並給當前值加delta
public final int getAndAdd(int delta)
//以原子方式給當前值加1並獲取新值
public final int incrementAndGet()
//以原子方式給當前值減1並獲取新值
public final int decrementAndGet()
//以原子方式給當前值加delta並獲取新值
public final int addAndGet(int delta)
複製程式碼

這些方法的實現都依賴另一個public方法:

public final boolean compareAndSet(int expect, int update)
複製程式碼

這是一個非常重要的方法,比較並設定,我們以後將簡稱為CAS。該方法以原子方式實現瞭如下功能:如果當前值等於expect,則更新為update,否則不更新,如果更新成功,返回true,否則返回false。

AtomicInteger可以在程式中用作一個計數器,多個執行緒併發更新,也總能實現正確性,我們看個例子:

public class AtomicIntegerDemo {
    private static AtomicInteger counter = new AtomicInteger(0);

    static class Visitor extends Thread {
        @Override
        public void run() {
            for (int i = 0; i < 100; i++) {
                counter.incrementAndGet();
                Thread.yield();
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        int num = 100;
        Thread[] threads = new Thread[num];
        for (int i = 0; i < num; i++) {
            threads[i] = new Visitor();
            threads[i].start();
        }
        for (int i = 0; i < num; i++) {
            threads[i].join();
        }
        System.out.println(counter.get());
    }
}
複製程式碼

程式的輸出總是正確的,為10000。

基本原理和思維

AtomicInteger的使用方法是簡單直接的,它是怎麼實現的呢?它的主要內部成員是:

private volatile int value;
複製程式碼

注意,它的宣告帶有volatile,這是必需的,以保證記憶體可見性。

它的大部分更新方法實現都類似,我們看一個方法incrementAndGet,其程式碼為:

public final int incrementAndGet() {
    for (;;) {
        int current = get();
        int next = current + 1;
        if (compareAndSet(current, next))
            return next;
    }
}
複製程式碼

程式碼主體是個死迴圈,先獲取當前值current,計算期望的值next,然後呼叫CAS方法進行更新,如果當前值沒有變,則更新並返回新值,否則繼續迴圈直到更新成功為止。

與synchronized鎖相比,這種原子更新方式代表一種不同的思維方式。

synchronized是悲觀的,它假定更新很可能衝突,所以先獲取鎖,得到鎖後才更新。原子變數的更新邏輯是樂觀的,它假定衝突比較少,但使用CAS更新,也就是進行衝突檢測,如果確實衝突了,那也沒關係,繼續嘗試就好了。

synchronized代表一種阻塞式演算法,得不到鎖的時候,進入鎖等待佇列,等待其他執行緒喚醒,有上下文切換開銷。原子變數的更新邏輯是非阻塞式的,更新衝突的時候,它就重試,不會阻塞,不會有上下文切換開銷。

對於大部分比較簡單的操作,無論是在低併發還是高併發情況下,這種樂觀非阻塞方式的效能都要遠高於悲觀阻塞式方式。

原子變數是比較簡單的,但對於複雜一些的資料結構和演算法,非阻塞方式往往難於實現和理解,幸運的是,Java併發包中已經提供了一些非阻塞容器,我們只需要會使用就可以了,比如:

  • ConcurrentLinkedQueue和ConcurrentLinkedDeque:非阻塞併發佇列
  • ConcurrentSkipListMap和ConcurrentSkipListSet:非阻塞併發Map和Set

這些容器我們在後續章節介紹。

但compareAndSet是怎麼實現的呢?我們看程式碼:

public final boolean compareAndSet(int expect, int update) {
    return unsafe.compareAndSwapInt(this, valueOffset, expect, update);
}
複製程式碼

它呼叫了unsafe的compareAndSwapInt方法,unsafe是什麼呢?它的型別為sun.misc.Unsafe,定義為:

private static final Unsafe unsafe = Unsafe.getUnsafe();
複製程式碼

它是Sun的私有實現,從名字看,表示的也是”不安全”,一般應用程式不應該直接使用。原理上,一般的計算機系統都在硬體層次上直接支援CAS指令,而Java的實現都會利用這些特殊指令。從程式的角度看,我們可以將compareAndSet視為計算機的基本操作,直接接納就好。

實現鎖

基於CAS,除了可以實現樂觀非阻塞演算法,它也可以用來實現悲觀阻塞式演算法,比如鎖,實際上,Java併發包中的所有阻塞式工具、容器、演算法也都是基於CAS的 (不過,也需要一些別的支援)。

怎麼實現呢?我們演示一個簡單的例子,用AtomicInteger實現一個鎖MyLock,程式碼如下:

public class MyLock {
    private AtomicInteger status = new AtomicInteger(0);

    public void lock() {
        while (!status.compareAndSet(0, 1)) {
            Thread.yield();
        }
    }

    public void unlock() {
        status.compareAndSet(1, 0);
    }
}
複製程式碼

在MyLock中,使用status表示鎖的狀態,0表示未鎖定,1表示鎖定,lock()/unlock()使用CAS方法更新,lock()只有在更新成功後才退出,實現了阻塞的效果,不過一般而言,這種阻塞方式過於消耗CPU,有更為高效的方式,我們後續章節介紹。MyLock只是用於演示基本概念,實際開發中應該使用Java併發包中的類如ReentrantLock。

AtomicBoolean/AtomicLong/AtomicReference

AtomicBoolean/AtomicLong/AtomicReference的用法和原理與AtomicInteger是類似的,我們簡要介紹下。

AtomicBoolean

AtomicBoolean可以用來在程式中表示一個標誌位,它的原子操作方法有:

public final boolean compareAndSet(boolean expect, boolean update)
public final boolean getAndSet(boolean newValue) 
複製程式碼

實際上,AtomicBoolean內部使用的也是int型別的值,用1表示true, 0表示false,比如,其CAS方法程式碼為:

public final boolean compareAndSet(boolean expect, boolean update) {
    int e = expect ? 1 : 0;
    int u = update ? 1 : 0;
    return unsafe.compareAndSwapInt(this, valueOffset, e, u);
}
複製程式碼

AtomicLong

AtomicLong可以用來在程式中生成唯一序列號,它的方法與AtomicInteger類似,就不贅述了。它的CAS方法呼叫的是unsafe的另一個方法,如:

public final boolean compareAndSet(long expect, long update) {
    return unsafe.compareAndSwapLong(this, valueOffset, expect, update);
}
複製程式碼

AtomicReference

AtomicReference用來以原子方式更新複雜型別,它有一個型別引數,使用時需要指定引用的型別。以下程式碼演示了其基本用法:

public class AtomicReferenceDemo {
    static class Pair {
        final private int first;
        final private int second;

        public Pair(int first, int second) {
            this.first = first;
            this.second = second;
        }

        public int getFirst() {
            return first;
        }

        public int getSecond() {
            return second;
        }
    }

    public static void main(String[] args) {
        Pair p = new Pair(100, 200);
        AtomicReference<Pair> pairRef = new AtomicReference<>(p);
        pairRef.compareAndSet(p, new Pair(200, 200));

        System.out.println(pairRef.get().getFirst());
    }
}
複製程式碼

AtomicReference的CAS方法呼叫的是unsafe的另一個方法:

public final boolean compareAndSet(V expect, V update) {
    return unsafe.compareAndSwapObject(this, valueOffset, expect, update);
}
複製程式碼

原子陣列

原子陣列方便以原子的方式更新陣列中的每個元素,我們以AtomicIntegerArray為例來簡要介紹下。

它有兩個構造方法:

public AtomicIntegerArray(int length)
public AtomicIntegerArray(int[] array) 
複製程式碼

第一個會建立一個長度為length的空陣列。第二個接受一個已有的陣列,但不會直接操作該陣列,而是會建立一個新陣列,只是拷貝引數陣列中的內容到新陣列。

AtomicIntegerArray中的原子更新方法大多帶有陣列索引引數,比如:

public final boolean compareAndSet(int i, int expect, int update)
public final int getAndIncrement(int i)
public final int getAndAdd(int i, int delta)
複製程式碼

第一個引數i就是索引。看個簡單的例子:

public class AtomicArrayDemo {
    public static void main(String[] args) {
        int[] arr = { 1, 2, 3, 4 };
        AtomicIntegerArray atomicArr = new AtomicIntegerArray(arr);
        atomicArr.compareAndSet(1, 2, 100);
        System.out.println(atomicArr.get(1));
        System.out.println(arr[1]);
    }
}
複製程式碼

輸出為:

100
2
複製程式碼

FieldUpdater

FieldUpdater方便以原子方式更新物件中的欄位,欄位不需要宣告為原子變數,FieldUpdater是基於反射機制實現的,我們會在後續章節介紹反射,這裡簡單介紹下其用法,看程式碼:

public class FieldUpdaterDemo {
    static class DemoObject {
        private volatile int num;
        private volatile Object ref;

        private static final AtomicIntegerFieldUpdater<DemoObject> numUpdater
            = AtomicIntegerFieldUpdater.newUpdater(DemoObject.class, "num");
        private static final AtomicReferenceFieldUpdater<DemoObject, Object>
            refUpdater = AtomicReferenceFieldUpdater.newUpdater(
                    DemoObject.class, Object.class, "ref");

        public boolean compareAndSetNum(int expect, int update) {
            return numUpdater.compareAndSet(this, expect, update);
        }

        public int getNum() {
            return num;
        }

        public Object compareAndSetRef(Object expect, Object update) {
            return refUpdater.compareAndSet(this, expect, update);
        }

        public Object getRef() {
            return ref;
        }
    }

    public static void main(String[] args) {
        DemoObject obj = new DemoObject();
        obj.compareAndSetNum(0, 100);
        obj.compareAndSetRef(null, new String("hello"));
        System.out.println(obj.getNum());
        System.out.println(obj.getRef());
    }
}
複製程式碼

類DemoObject中有兩個成員num和ref,宣告為volatile,但不是原子變數,不過DemoObject對外提供了原子更新方法compareAndSet,它是使用欄位對應的FieldUpdater實現的,FieldUpdater是一個靜態成員,通過newUpdater工廠方法得到,newUpdater需要的引數有型別、欄位名、對於引用型別,還需要引用的具體型別。

ABA問題

使用CAS方式更新有一個ABA問題,該問題是指,一個執行緒開始看到的值是A,隨後使用CAS進行更新,它的實際期望是沒有其他執行緒修改過才更新,但普通的CAS做不到,因為可能在這個過程中,已經有其他執行緒修改過了,比如先改為了B,然後又改回為了A。

ABA是不是一個問題與程式的邏輯有關,如果是一個問題,一個解決方法是使用AtomicStampedReference,在修改值的同時附加一個時間戳,只有值和時間戳都相同才進行修改,其CAS方法宣告為:

public boolean compareAndSet(
    V expectedReference, V newReference, int expectedStamp, int newStamp) 
複製程式碼

比如:

Pair pair = new Pair(100, 200);
int stamp = 1;
AtomicStampedReference<Pair> pairRef = new AtomicStampedReference<Pair>(pair, stamp);
int newStamp = 2;
pairRef.compareAndSet(pair, new Pair(200, 200), stamp, newStamp);
複製程式碼

AtomicStampedReference在compareAndSet中要同時修改兩個值,一個是引用,另一個是時間戳,它怎麼實現原子性呢?實際上,內部AtomicStampedReference會將兩個值組合為一個物件,修改的是一個值,我們看程式碼:

public boolean compareAndSet(V   expectedReference,
                             V   newReference,
                             int expectedStamp,
                             int newStamp) {
    Pair<V> current = pair;
    return
        expectedReference == current.reference &&
        expectedStamp == current.stamp &&
        ((newReference == current.reference &&
          newStamp == current.stamp) ||
         casPair(current, Pair.of(newReference, newStamp)));
}
複製程式碼

這個Pair是AtomicStampedReference的一個內部類,成員包括引用和時間戳,具體定義為:

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將對引用值和時間戳的組合比較和修改轉換為了對這個內部類Pair單個值的比較和修改。

AtomicMarkableReference是另一個AtomicReference的增強類,與AtomicStampedReference類似,它也是給引用關聯了一個欄位,只是這次是一個boolean型別的標誌位,只有引用值和標誌位都相同的情況下才進行修改。

小結

本節介紹了各種原子變數的用法以及背後的原理CAS,對於併發環境中的計數、產生序列號等需求,考慮使用原子變數而非鎖,CAS是Java併發包的基礎,基於它可以實現高效的、樂觀、非阻塞式資料結構和演算法,它也是併發包中鎖、同步工具和各種容器的基礎。

下一節,我們討論併發包中的顯式鎖。

(與其他章節一樣,本節所有程式碼位於 github.com/swiftma/pro…)


未完待續,檢視最新文章,敬請關注微信公眾號“老馬說程式設計”(掃描下方二維碼),深入淺出,老馬和你一起探索Java程式設計及計算機技術的本質。用心原創,保留所有版權。

計算機程式的思維邏輯 (70) – 原子變數和CAS

相關文章