CAS 無鎖式同步機制


計算機系統中,CPU 和內存之間是通過總線進行通信的,當某個線程占有 CPU 執行指令的時候,會盡可能的將一些需要從內存中訪問的變量緩存在自己的高速緩存區中,而修改也不會立即映射到內存。

而此時,其他線程將看不到內存中該變量的任何改動,這就是我們說的內存可見性問題。連續的文章中,我們總共提出了兩種解決辦法。

其一是使用關鍵字 volatile 修飾共享的全局變量,而 volatile 的實現原理大致分兩個步驟,任何對於該變量的修改操作都會由虛擬機追加一條指令立馬將該變量所在緩存區中的值回寫內存,接着將失效該變量在其他 CPU 緩存區的引用。也就意味着,其他 CPU 如果再想要使用該變量,緩存中是沒有的,進而逼迫去訪問內存拿最新的數據。

其二是使用關鍵字 synchronized 並借助對象內置鎖實現數據一致性,主要思路是,如果一個線程因為競爭某個鎖失敗而被阻塞了,那么它就認為別的線程正在工作,很可能會改了某些共享變量的數據,進而在獲得鎖后第一時間重新刷內存中的數據,同時一個線程走出同步代碼塊之前會同步數據到內存。

其實我們也很少會使用第二種方法來解決內存可見性問題,着實有點大材小用的感覺,使用 volatile 關鍵字算是一個比較常用的方式。但是 volatile 是有特定的適用場景的,也具有它的局限性,我們一起來看。

volatile 的局限性

廢話不多說,先看一段代碼:

public class MainTest {
    private static volatile int count;

    @Test
    public void testVolatile() throws InterruptedException {
        Thread1[] thread1s = new Thread1[100];
        for (int i = 0; i < 100; i++){
            thread1s[i] = new Thread1();
            thread1s[i].start();
        }

        for (int j = 0; j < 100; j++){
            thread1s[j].join();
        }
        System.out.println(count);
    }
    //每個線程隨機自增 count
    private class Thread1 extends Thread{
        @Override
        public void run(){
            try {
                Thread.sleep((long) (Math.random() * 500));
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            count++;
        }
    }
}

我們將變量 count 使用 volatile 進行修飾,然后創建一百個線程並啟動,按照我們之前的理解,變量 count 的值一旦被修改就可以被其他線程立馬看到,不會緩存在自己的工作內存。但是結果卻不是這樣。

多次運行,結果不盡相同

94

96

98

....

其實原因很簡單,我們只說過 volatile 會在變量值被修改后回寫內存並失效其他 CPU 緩存中該變量的引用迫使其他線程從主存中重新去獲取該變量的值。

但是 count++ 這個操作並不是原子操作,之前我們說過這一點,這個操作會使得 CPU 做以下幾件事情:

  • 從 CPU 緩存讀出變量的值放入寄存器 A 中
  • 為 count 加一並將值保存在另一個寄存器 B 中
  • 將寄存器 B 中的數據寫到緩存並通過緩存鎖回寫內存

而如果第一步剛執行結束,或第二步剛執行結束,但沒有執行第三步的時候,其他的某個線程更改了該變量的值並失效了當前 CPU 中緩存中該變量的引用,那么第三步會由於緩存失效而先去內存中讀一個值過來,然后用寄存器 B 中的值覆蓋緩存並刷到內存中。

這就意味着,在此之前其他線程的修改被覆蓋,進而我們得不到我們預期的結果。結論就是,volatile 關鍵字具有可見性而不具有原子性。

原子類型變量

JDK1.5 以后由 Doug Lea 大神設計的 java.util.concurrent.atomic 包中包含了原子類型相關的所有類。

image

其中,

  • AtomicBoolean:對應的 Boolean 類型的原子類型
  • AtomicInteger:對應的 Integer 類型的原子類型
  • AtomicLong:類似
  • AtomicIntegerArray:對應的數組類型
  • AtomicLongArray:類似
  • AtomicReference:對應的引用類型的原子類型
  • AtomicIntegerFieldUpdater:字段更新類型

剩余的幾個類的作用,我們稍后再詳細介紹。

針對基本類型所對應的原子類型,我們以 AtomicInteger 這個類為例,看看它的源碼實現情況。

AtomicInteger 相關實現

image

內部定義了一個 int 類型的變量 value,並且 value 修飾為 volatile,表示 value 這個字段值的任何修改都對其他線程立即可見。

而構造函數允許你傳入一個初始的 value 數值,不傳的話就會導致 value 的值為零。

public final int getAndIncrement() {
    return unsafe.getAndAddInt(this, valueOffset, 1);
}

這個方法就是原子的「i++」操作,我們跟進去看:

public final int getAndAddInt(Object var1, long var2, int var4) {
    int var5;
    do {
        var5 = this.getIntVolatile(var1, var2);
    } while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));

    return var5;
}

幾個參數簡單說一下,var1 是我們的 AtomicInteger 實例引用,var2 是一個字段偏移量,通過它我們可以定位到其中的 value 字段。var4 這里固定為一。

代碼的邏輯也是簡單的,取出內部 value 字段的值並暫存在變量 value5 中,然后再次判斷,如果 value 字段的值依然等於 value5,那么將原子操作式將 value 修改為 value4 + value5,本質上就是加一。

否則,說明在當前線程上次訪問后,又有其他線程修改了這個 value 字段的值,於是我們重新獲取這個字段的值,直到沒有人修改為止並自增它。

這個 compareAndSwapInt 方法我們一般把它叫做『CAS』,底層有系統指令做支撐,是一個比較並修改的原子指令,如果值等於 A 則將它修改為 B,否則返回。

AtomicInteger 中的其余方法大致類似,都是依賴這個『CAS』方法實現的。

  • int getAndAdd(int delta):自增 delta 並獲取修改之前的值
  • int incrementAndGet():自增並獲取修改后的值
  • int decrementAndGet():自減並獲取修改后的值
  • int addAndGet(int delta):自增 delta 並獲取修改后的值

基於這一點,我們重構上述的線程不安全的 demo:

//構建一個原子類型變量 aCount
private static volatile AtomicInteger aCount = new AtomicInteger(0);
@Test
public void testAtomic() throws InterruptedException {
    Thread2[] threads = new Thread2[100];
    for (int i = 0; i < 100; i++){
        threads[i] = new Thread2();
        threads[i].start();
    }
    for (int i = 0; i < 100; i++){
        threads[i].join();
    }
    System.out.println(aCount.get());
}

private class Thread2 extends Thread{
    @Override
    public void run(){
        try {
            Thread.sleep((long) (500 * Math.random()));
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        //原子自增
        aCount.getAndIncrement();
    }
}

修改后的代碼無論運行多少次,總會得到結果 100 。有關 AtomicLong、AtomicReference 的相關內容大致類似,都是依賴我們這個『CAS』方法,這里不再贅述。

FieldUpdater 是基於反射來原子修改變量的值,這里不多說了,下面我們看看『CAS』的一些問題。

CAS 的局限性

ABA 問題

CAS 有一個典型問題就是「ABA 問題」,我們知道 CAS 工作的基本原理是,先讀取目標變量的值,然后調用原子指令判斷該值是否等於我們期望的值,如果等於就認為沒有被別人改過,否則視作數據臟了,重新去讀變量的值。

但是問題是,如果變量 a 的值為 100,我們的 CAS 方法也讀到了 100,接着來了一個線程將這個變量改為 999,之后又來一個線程再改了一下,改成 100 。而輪到我們的主線程發現 a 的值依然是 100,它視作沒有人和它競爭修改 a 變量,於是修改 a 的值。

這種情況,雖然 CAS 會更新成功,但是會存在潛在的問題,中途加入的線程的操作對於后一個線程根本是不可見的。而一般的解決辦法是為每一次操作加上加時間戳,CAS 不僅關注變量的原始值,還關注上一次修改時間。

循環時間長開銷大

我們的 CAS 方法一般都定義在一個循環里面,直到修改成功才會退出循環,如果在某些並發量較大的情況下,變量的值始終被別的線程修改,本線程始終在循環里做判斷比較舊值,效率低下。

所以說,CAS 適用於並發量不是很高的情況下,效率遠遠高於鎖機制。

只能保證一個變量的原子操作

CAS 只能對一個變量進行原子性操作,而鎖機制則不同,獲得鎖之后,就可以對所有的共享變量進行修改而不會發生任何問題,因為別人沒有鎖不能修改這些共享變量。

總結一下,鎖其實是一種悲觀的思想,「我認為所有人都會和我來競爭某些資源的使用,所以我得到資源之后把它鎖上,用完再釋放掉鎖」,而 CAS 則是一種樂觀的思想,「我以為只有我一個人在使用這些資源,假如有人也在使用,那我再次嘗試即可」。

CAS 是以后的各種並發容器的實現基石,是一種樂觀的、非阻塞式的算法,將有助於提升我們的並發性能。


文章中的所有代碼、圖片、文件都雲存儲在我的 GitHub 上:

(https://github.com/SingleYam/overview_java)

歡迎關注微信公眾號:OneJavaCoder,所有文章都將同步在公眾號上。

image


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM