併發編程----8、CAS與AQS

目錄

 

一、併發編程之CAS

1.1、什麼是CAS

1.3、CAS底層原理

1.4、CAS原理分析

1.5、CAS應用場景

二、併發編程之AQS

2.1、概念

2.2、基本思想

2.3、CLH同步隊列

三、AQS源碼分析


一、併發編程之CAS

在介紹CAS的時候先說下悲觀鎖和樂觀鎖,要不面提到這2個概念,大家會覺得陌生。

悲觀鎖:寫多,讀少。所以悲觀

樂觀鎖:讀多,寫少,有版本控制。

這一章偏理論,因爲CAS主要就是一個算法。

1.1、什麼是CAS

CAS (compareAndSwap 有的也叫compareAndSet),中文叫比較交換,一種無鎖原子算法(樂觀鎖)。過程是這樣:它包含 3 個參數 CAS(V,E,N),V表示要更新變量的值,E表示預期值,N表示新值。僅當 V值等於E值時,纔會將V的值設爲N,如果V值和E值不同,則說明已經有其他線程做兩個更新,則當前線程則什麼都不做。最後,CAS 返回當前V的真實值。CAS 操作時抱着樂觀的態度進行的,它總是認爲自己可以成功完成操作。,其作用是讓CPU先進行比較兩個值是否相等,然後原子地更新某個位置的值,其實現方式是基於硬件平臺的彙編指令,在intel的CPU中,使用的是cmpxchg指令(當你彙編自己的代碼的時候會看到好多這個命令,說明很多同步,鎖 都是利用CAS實現的哦),就是說CAS是靠硬件實現的,從而在硬件層面提升效率。

當多個線程同時使用CAS 操作一個變量時,只有一個會勝出,併成功更新,其餘均會失敗。失敗的線程不會掛起,僅是被告知失敗,並且允許再次嘗試,當然也允許實現的線程放棄操作。基於這樣的原理,CAS 操作即使沒有鎖,也可以阻止其他線程對當前線程的干擾。

與鎖相比,使用CAS會使程序看起來更加複雜一些,但由於其非阻塞的,它對死鎖問題天生免疫,並且,線程間的相互影響也非常小。更爲重要的是,使用無鎖的方式完全沒有鎖競爭帶來的系統開銷,也沒有線程間頻繁調度帶來的開銷,因此,他要比基於鎖的方式擁有更優越的性能。

簡單的說,CAS 需要你額外給出一個期望值,也就是你認爲這個變量現在應該是什麼樣子的。如果變量不是你想象的那樣,哪說明它已經被別人修改過了。你就需要重新讀取,再次嘗試修改就好了。

 

1.2、CAS作用和優缺點

CAS的實現稍微複雜,因爲多了一個期望值,但是CAS無鎖,不存在阻塞提高了效率,主要表現在CPU的吞吐量

CAS的缺點:

1)循環時間長

如果CAS一直不成功呢?這種情況絕對有可能發生,如果不斷自旋但是CAS長時間地不成功,則會給CPU帶來非常大的開銷。在JUC中有些地方就限制了CAS自旋的次數,例如BlockingQueue的SynchronousQueue。

2)只能保證一個共性變量原子操作

看了CAS的實現就知道這隻能針對一個共享變量,如果是多個共享變量就只能使用鎖了,當然如果你有辦法把多個變量整成一個變量,利用CAS也不錯。例如讀寫鎖中state的高低位,利用二進制位來表示不同的變量。

3)ABA問題

CAS需要檢查操作值有沒有發生改變,如果沒有發生改變則更新。但是存在這樣一種情況:如果一個值原來是A,變成了B,然後又變成了A,那麼在CAS檢查的時候會發現沒有改變,但是實質上它已經發生了改變,這就是所謂的ABA問題。對於ABA問題其解決方案是加上版本號,即在每個變量都加上一個版本號,每次改變時加1,即A —> B —> A,變成1A —> 2B —> 3A。

改進方法:

類:AtomicStampedReference<V> 

每次改進都會帶一個版本號

1.3、CAS底層原理

CAS的產生歸功於硬件指令集的發展,實際上,我們可以使用同步將這兩個操作變成原子的,但是這麼做就沒有意義了。所以我們只能靠硬件來完成,硬件保證一個從語義上看起來需要多次操作的行爲只通過一條處理器指令就能完成。這類指令常用的有:

1. 測試並設置(Tetst-and-Set)

2. 獲取並增加(Fetch-and-Increment)

3. 交換(Swap)

4. 比較並交換(Compare-and-Swap CAS)

5. 加載鏈接/條件存儲(Load-Linked/Store-Conditional)

這個不記住也沒關係,看一眼就行了。

CPU如何實現原子指令呢? 主要有2種方式

1、通過總線鎖定來保證原子性

總線鎖定其實就是處理器使用了總線鎖,所謂總線鎖就是使用處理器提供的一個 LOCK# 信號,當一個處理器在總線上輸出此信號時,其他處理器的請求將被阻塞住,那麼該處理器可以獨佔共享內存。但是該方法成本太大。因此有了下面的方式。

2、通過緩存鎖定來保證原子性 就是cache line 無效

所謂 緩存鎖定是指內存區域如果被緩存在處理器的緩存行中,並且在Lock 操作期間被鎖定,那麼當他執行鎖操作寫回到內存時,處理器不在總線上輸出 LOCK# 信號,而是修改內部的內存地址,並允許他的緩存一致性機制來保證操作的原子性,因爲緩存一致性機制會阻止同時修改兩個以上處理器緩存的內存區域數據(這裏和 volatile 的可見性原理相同,cache line),當其他處理器回寫已被鎖定cache line(緩存行)的數據時,會使cache line置爲無效。

注意:有兩種情況下處理器不會使用緩存鎖定。看下就行了。不用刻意記住

1. 當操作的數據不能被緩存在處理器內部,或操作的數據跨多個緩存行時,則處理器會調用總線鎖定。

2. 有些處理器不支持緩存鎖定,對於 Intel 486 和 Pentium 處理器,就是鎖定的內存區域在處理器的緩存行也會調用總線鎖定。

1.4、CAS原理分析

AtomicInteger就是利用CAS實現的,所以我們這次以AtomicInteger爲例,來分析下它的源碼。

先寫個代碼示例:我們用2種方式實現多線程自增,效果一樣,但是原理不一樣。

public class CAS1 {
    private static volatile int m=0;
    private static AtomicInteger atomic = new AtomicInteger(0);
    public static void increase1(){
        m++;
    }
    public static void increase2(){
        atomic.incrementAndGet();
    }

    public static void main(String[] args) throws InterruptedException {
        Thread[] t = new Thread[5];
        for(int i=0;i<5;i++){
            t[i] = new Thread(()->{
                CAS1.increase1();
            });
            t[i].start();
            t[i].join();
        }
        System.out.println(m);

        Thread[] t2 = new Thread[5];
        for(int i=0;i<5;i++){
            t2[i] = new Thread(()->{
                CAS1.increase2();
            });
            t2[i].start();
            t2[i].join();
        }
        System.out.println("atomic: "+atomic);
    }
}

 反編譯後: javap -c CAS1 大家看到increase1 和increase2 的彙編語句不一樣。

我們進入AtomicInteger 源碼看看具體幹了什麼。unsafe是不安全的,後門類,調用cpu指令

 

繼續看下incrementAndGet方法的實現,發現調用的是Unsafe的getAndAddInt方法

 

如果我們將10修改爲12,則var1就是10,var2就是10,var4是12。如果var1和var2的值一致,則可以修改,則修改成var4。如果不相等,證明被別的線程修改了,則不能修改。

再次進入compareAndSwapInt 會發現這個是個本地方法。在這個Unsafe類中會發現有個調用jvm方法的地方。如果想看jvm的源碼,需要下載一個jvm的源碼。可以在github去下載。網上資源也很多,不想下載的跟着我着看一眼就可以了。jvm實現都是C++,如果沒學過看着也費勁。

public final native boolean compareAndSwapInt(Object var1, long var2, int var4, int var5);

 

我們將jvm的源碼對應起來看下:

實現交換的本地方法如下圖。就是這就2行代碼,爲啥原子性大家明白了吧。cmpxchg這個是一條指令,沒辦法分隔。

 

整體的調用鏈是:

incrementAndGet()->unsafe->unsafe.cpp->彙編cmpxchg->二進制

Unsafe是CAS的核心類,Java無法直接訪問底層操作系統,而是通過本地(native)方法來訪問。不過儘管如此,JVM還是開了一個後門:Unsafe,它提供了硬件級別的原子操作。

 

CAS可以保證一次的讀-改-寫操作是原子操作,在單處理器上該操作容易實現,但是在多處理器上實現就有點兒複雜了。

緩存加鎖:其實針對於上面那種情況我們只需要保證在同一時刻對某個內存地址的操作是原子性的即可。當CPU1修改緩存行中的i時使用緩存鎖定,那麼CPU2就的緩存行就不能同時緩存i了。

1.5、CAS應用場景

1、應用於簡單的數據計算

2、適合線程衝突少的場景

 

CAS的知識差不多就介紹完了,大家看了這些足夠了。

二、併發編程之AQS

2.1、概念

AbstractQueuedSynchronizer的縮寫是一個同步發射器,用來構建鎖。在鎖級別上面比CAS高,比Sychronized低。在java.util.concurrent(JUC)包下。

重入鎖ReentrantLock,讀寫鎖,信號量 等都是利用AQS來實現的 這個我們在後面鎖章節在着重介紹。

從使用層面來說,AQS的功能分爲兩種:獨佔和共享

  • 獨佔鎖,每次只能有一個線程持有鎖,比如ReentrantLock就是以獨佔方式實現的互斥鎖
  • 共享鎖,允許多個線程同時獲取鎖,併發訪問共享資源,比如ReentrantReadWriteLock (讀寫鎖)

 

2.2、基本思想

通過內置的FIFO同步隊列來完成線程爭奪資源的管理工作。屬於公平的

2.3、CLH同步隊列

CLH同步隊列也是AQS的實現方式。其主要從兩方面進行了改造:把每個線程都看作一個節點,節點的結構與節點等待機制。在結構上引入了頭結點和尾節點,他們分別指向隊列的頭和尾,嘗試獲取鎖、入隊列、釋放鎖等實現都與頭尾節點相關,並且每個節點都引入前驅節點和後後續節點的引用;在等待機制上由原來的自旋改成阻塞喚醒。

 

如圖所示:每個線程就是一個節點,state表示競爭資源時的同步狀態,如果state=0表示當前沒有線程佔用,如果state>=1表示有線程佔用,則其他線程阻塞。

CLH隊列在等待的時候會自旋,可以防止線程從用戶線程變成內核線程,用戶線程變成內核線程非常耗時

 

AQS的CLH隊列相比原始的CLH隊列鎖,它採用了一種變形操作,將自旋機制改爲阻塞機制。當前線程將首先檢測是否爲頭結點且嘗試獲取鎖,如果當前節點爲頭結點併成功獲取鎖則直接返回,當前線程不進入阻塞,否則將當前線程阻塞:當前線程如果獲取同步狀態失敗時,AQS則會將當前線程已經等待狀態等信息構造成一個節點(Node)並將其加入到CLH同步隊列,同時會阻塞當前線程,當同步狀態釋放時,會把首節點喚醒(公平鎖),使其再次嘗試獲取同步狀態。

 

CLH隊列的節點都有一個狀態位,該狀態位與線程狀態密切相關:

CANCELLED =  1:因爲超時或者中斷,節點會被設置爲取消狀態,被取消的節點時不會參與到競爭中的,他會一直保持取消狀態不會轉變爲其他狀態;退出隊列;

SIGNAL    = -1:其後繼節點已經被阻塞了,到時需要進行喚醒操作;

CONDITION = -2:表示這個結點在條件隊列中,因爲等待某個條件而被喚醒;(這個可以讓指定的線程醒來)

PROPAGATE=-3 : 共享模式,頭結點的狀態

 

三、AQS源碼分析

 

首先問大家一個問題:在java中如何實現同步?

方法很多:wait/notify 、 synchronized、 ReentrantLock、 ConutDownLatch、 CyclieBarries 、Semaphore 這些都是java提供的可以同步的工具。

因爲AQS的加鎖和解鎖過程,源碼也是一堆東西。所以在後續章節專門開一節來講。歡迎大家訂閱併發系列。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章