【多線程與併發】如果不用鎖機制如何實現共享數據訪問

前提:不能用鎖,不能用synchronize塊或者方法,也不能直接使用jdk提供的線程安全的數據結構,需要自己實現一個類來保證多個線程同時讀寫這個類中的共享數據是線程安全的。

 

無鎖化編程的常用方法:硬件CPU同步原語CAS(Compare And Swap),如無鎖棧、無鎖隊列(ConcurrentLinkedQueue)等待。現在幾乎所有的CPU指令都支持CAS的院子操作,X86下對應的是CMPXCHG彙編指令,處理器執行CMPXCHG指令是一個原子性操作。有了這個原子操作,我們就可以用其來實現各種無鎖(lock free)的數據結構。

CAS實現了區別於synchronized同步鎖的一種樂觀鎖,當多個線程嘗試使用CAS同時更新同一個變量時,只有其中一個線程能更新變量的值,而其他線程都失敗,失敗的線程並不會被掛起,而是被告知這次競爭中失敗,並可以再次嘗試。CAS有三個操作數,內存值V、舊的預期值A,要修改後的新值B。當且僅當預期值A和內存值V相同時,將內存值修改爲B,否則什麼都不做。其實CAS也算是有鎖操作,只不過是由CPU來觸發,比如synchronized性能好的多。CAS的關鍵點在於,系統在硬件層面保證了比較並交換操作的原子性,處理器使用基於對緩存加鎖或總線加鎖的方式來實現多處理器之間的院子操作。CAS是非阻塞算法的一種常見實現。

一個線程間共享的變量,首先在主存中會保留一份,然後每個線程的工作內存也會保留一份副本。這裏說的預期值,就是線程保留的副本。當該線程從工作內存中獲取該變量的值後,主存中該變量可能已經被其他線程刷新了,但是該線程工作內存中該變量卻還是原來的值,這就是所謂的預期值了。當你要用CAS刷新該值的時候,如果發現線程工作內存和主存不一致了,就會失敗,如果一致,就可以更新成功。

Atomic包提供了一系列原子類。這些類可以保證多線程環境下,當某個線程在執行atomic的方法時,不會被其他線程打斷,而別的線程就像自旋鎖一樣,一直等到該方法執行完成,才由JVM從等待隊列中選擇一個線程執行。Atomic類在軟件層面上是非阻塞的,它的原子性其實是在硬件層面上藉助相關的指令來保證的。

AtomicInteger是一個支持原子操作的Integer類,就是保證對AtomicInteger類型變量的增加或減少操作是原子性的,不會出現多個線程下的數據不一致問題。如果不使用AtomicInteger,要實現一個按順序獲取的ID,就必須在每次獲取時進行加鎖操作,以避免出現併發時獲取到同樣的ID的現象。Java併發庫中的AtomicXXX類均是基於這個原語的實現,拿出AtomicInteger來研究在沒有鎖的情況下是如何做到數據正確性的:來看看++i是怎麼做到的

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

在這裏才用了CAS操作,每次從內存中讀取數據然後將此數據和+1後的結果進行CAS操作,如果成功就返回結果,否則重啓知道成功爲止

而compareAndSet利用了JNI來完成CPU指令的操作,非阻塞算法。

public final boolean compareAndSet(int expect,int update){
    return unsafe.compareAndSwapInt(this,valueOffset,expect,update);
}

其中,unsafe.compareAndSwapInt()是一個native方法,正是調用CAS原語完成該操作的。

首先假設有一個變量i,i的初始值爲0.每個線程都對i進行一次+1操作。CAS是這樣保證同步的:

假設有兩個線程,線程1讀取內存中的值爲0,current = 0,next = 1,然後掛起,然後線程2對i進行操作,將i的值變成了1。線程2執行完,回到線程1,進入if裏面的compareAndSet()方法,該方法進行的操作邏輯是

  1. 如果操作數的值在內存中沒有被修改,返回true,然後compareAndSet方法返回next的值
  2. 如果操作數的值在內存中被修改了,則返回false,重新進入下一次循環,重新得到current的值爲1,next的值爲2,然後在比較,由於這次沒有被修改,所以直接返回2。

那麼,爲什麼自增操作要通過CAS來完成呢?仔細觀察incrementAndGet()方法,發現自增操作時拆成了兩部分完成的。

int current = get();
int next = current + 1;

由於volatile只能保證讀取或寫入的是最新值,那麼可能出現以下情況:

  1. A線程執行get()操作,獲取current值(假設爲1)
  2. B線程執行get()操作,獲取current值(爲1)
  3. B線程執行next = current + 1操作,next = 2
  4. A線程執行next = current = 1操作,next = 2

這樣的結果明顯不是我們想要的,所以自增操作必須採用CAS來完成。

 

CAS的優缺點

CAS由於是在硬件層面保證的原子性,不會鎖住當前線程,它的效率是很高的。

CAS雖然很高效的實現了院子操作,但是它依然存在三個問題。

一、ABA問題 

CAS在操作值的時候和檢查值是否已經變化,沒有變化的情況下才會進行更新。但是如果一個值原來是A,變成B,又變成A,那麼CAS進行檢查時會認爲這個值沒有變化,操作成功。ABA問題的解決方式是使用版本號。在變量前面追加上版本號,每次變量更新的時候把版本號加一,那麼A-B-A就變成1A-2B-3A。從Java1.5開始JDK的atomic包裏提供了一個類AtomicStampedReference來解決問題。從這個類的compareAndSet方法作用首先是檢查當前引用是否等於預期引用,並且當前標誌是否等於預期標誌,如果全部相等,則以院子方式將該引用和該標誌的值設置爲給定的更新值。

CAS算法實現了一個重要前提是需要取出內存中某時刻的數據,而在下一時刻把取出後的數據和內存中原始數據比較並替換,那麼在這個時間差內會導致數據的變化。

比如說一個線程one從內存位置V中取出A,這時候另一個線程two也從內存中取出A,並且two進行了一些操作變成了B,然後two又將V位置的數據變成A,這時候線程one進行CAS操作發現內存中仍然是A,然後one操作成功。儘管線程one的CAS操作成功,但是不代表這個過程就是沒有問題的。如果鏈表的頭在變化了兩次後恢復了原值,但是不代表鏈表就沒有變化。因此前面提到的原子操作AtomicStampedReference/AtomicMarkableReference就很有用了。這允許一對變化的元素進行原子操作。

現有一個用單向鏈表實現的堆棧,棧頂爲A,這時線程T1已經知道A.next爲B,然後希望用CAS將棧頂替換爲B:
head.compareAndSet(A,B);
在T1執行上面這條指令前,線程T2介入,將A,B出棧,在Push D、C、A,此時堆棧結構如下圖,而對象B此時處於遊離狀態:

此時輪到線程T1執行CAS操作,檢測發現棧頂仍爲A,所以CAS成功,棧頂變爲B,但實際上B.next爲null,所以此時的情況變爲:


其中堆棧中只有B一個元素,C和D組成的鏈表不再存在於堆棧中,平白無故就把C、D丟掉了。

以上就是由於ABA問題帶來的隱患,各種樂觀鎖的實現中通常都會用版本號version來對記錄或對象標記,避免並生操作帶來的問題。在Java中,AtomicStampedReference<E>也實現了這個作用,它通過包裝[E,Integer]的元祖來對對象標記版本戳stamp,從而避免CAS問題

二、循環時間長開銷大

自旋CAS如果長時間不成功,他會給CPU帶來非常大的執行開銷。因此CAS不適合競爭十分頻繁的場景。

三、只能保證一個共性變量的原子操作

當對一個共享變量執行操作時,我們可以使用循環CAS的方式來保證原子操作,但是對多個共享變量操作時,循環CAS就無法保證操作的原子性,這個時候就可以用鎖。

 

這裏粘貼一個模擬CAS實現的計數器

/**
 * @Author: Soldier49Zed
 * @Date: 2019/9/26 0:31
 * @Description:
 */

class SimilatedCAS{
    private int value;
    public int getValue(){
        return value;
    }

    //這裏只能用synchronized了  畢竟無法調用操作系統的CAS
    public synchronized boolean compareAndSwap(int expectedValue,int newValue){
        if (value == expectedValue){
            value = newValue;
            return true;
        }
        return false;
    }
}


public class CASCount implements Runnable{

    private SimilatedCAS counter = new SimilatedCAS();

    @Override
    public void run() {
        for (int i = 0;i < 10000;i++){
            System.out.println(this.increment());
        }
    }

    private int increment() {
        int oldValue = counter.getValue();
        int newValue = oldValue + 1;

        while (!counter.compareAndSwap(oldValue,newValue)) {//如果CAS失敗,就去拿新值繼續執行CAS
            oldValue = counter.getValue();
            newValue = oldValue + 1;
        }
        return newValue;
    }

    public static void main(String[] args) {
        Runnable run = new CASCount();
        new Thread(run).start();
        new Thread(run).start();
        new Thread(run).start();
        new Thread(run).start();
        new Thread(run).start();
    }

}

 

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