Java併發基礎(8)—— 原子操作CAS

目錄

一、背景

二、解決方法

三、原子操作CAS

四、CAS的缺點

4.1 ABA問題

4.2 循環開銷大

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

五、原子操作類

六、demo


一、背景

我們都知道在多線程環境下,num++,這個操作是不安全的,因爲它不是原子操作

在底層,這個加1的操作會被分成幾個步驟:

1、從內存中讀取 num

2、然後執行 num + 1

3、然後把新值寫入內存

直接看代碼

public class TestInt {
	
	 private volatile int num = 0;
	 
	    public int getNum() {
	        return this.num;
	    }
	 
	    public void increase() {
	        this.num++;
	    }
	 
	    public static void main(String[] args) {
	        final TestInt testInt = new TestInt();
	        for (int i = 0; i < 100; i++) {
	            new Thread(new Runnable() {
	                public void run() {
	                	try {
							Thread.sleep(100);
						} catch (InterruptedException e) {
							e.printStackTrace();
						}
	                	testInt.increase();
	                }
	            }).start();
	        }
	 
	        // 讓所有子線程執行完畢
	        while (Thread.activeCount() > 1) {
	            Thread.yield();
	        }
	        System.out.println("num:" + testInt.getNum());
	    }
}

按照我們想的,num結果應該爲100,實際測試時,結果卻每次都不一樣(小於或者等於100)

比如說有下面這種情況:

線程A獲取到num的值 = 0,由於它不是原子性,cpu資源被線程B搶走(或者A的時間片執行時間已到)
線程B獲取num的值 = 0
線程B對num + 1 = 1
線程B把num值更新到主內存中後結束
線程A重新獲得cpu資源, 對它內存中num的副本 + 1 = 1
線程A將num = 1更新到主內存中




本來應該num應該爲2的,結果卻爲1

二、解決方法

爲了保證線程的安全,我們第一步就想到使用synchronized關鍵字加鎖,這樣肯定可以解決問題

但是鎖機制也不是在任何情況下都是最優選擇

synchronized是基於阻塞的鎖機制,也就是說當一個線程擁有鎖的時候,訪問同一資源的其它線程需要等待,直到該線程釋放鎖

這樣可能會造成以下問題:

1、被阻塞的線程優先級很高

2、獲得鎖的線程一直不釋放鎖

3、大量的線程來競爭鎖,導致CPU資源的浪費

4、如果只是一個計數器,使用鎖機制比較笨重

三、原子操作CAS

原子操作定義:假定有兩個任務A和B,如果從執行A的線程來看,當另一個線程執行B時,要麼將B全部執行完,要麼完全不執行B,那麼A和B對彼此來說是原子的

那麼CAS是如何做到原子操作的呢?

它是利用了現代處理器都支持的CAS指令,循環這個指令,直到成功爲止

也就是說,它不是通過語言級別的操作來保證原子操作,而是在更底層,CPU指令級別的操作保證了原子操作

CAS操作過程都包含三個運算符:一個內存地址V,一個期望的值A和一個新值B

如果這個地址V上存放的值(也就是內存中的值)等於這個期望的值A,則將地址上的值賦爲新值B

上述動作是在一個循環中進行(for(;;){},也稱爲自旋操作,其實就是一個死循環),直到修改成功

我們可以先看一下CAS實現類AtomicInteger中如何實現類似++i的源碼

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

舉個例子

A,B兩個線程同時修改變量num的值0,進行加1操作

1、獲取值。首先他們都會去內存中獲取num=0,拷貝到自己工作空間,此時他們的期望值都是0 

2、計算新值

3、比較和交換。內存中的值與期望值相等,則交換。這裏通過CAS指令,保證了比較和交換是一個原子操作

並且要注意:該值是volatile修飾,保證了值修改時,其他線程可以立馬知道

A和B都執行完第2步,第3步只有一個線程可以成功執行

這裏爲了好理解,假設A快一點,先執行第3步,先比較期望值0和內存中值是否相等,發現相等,把新值1刷回內存中,然後返回結束循環

B慢一點,比較期望值0和內存中值(此時已變爲1,因爲是volatile修飾)是否相等,不相等,則執行下一次循環

再獲取內存中的值1拷貝到自己工作空間,也就是期望值爲1

再計算新值爲2

再比較和交換,這時相等,就把新值刷回內存,然後返回結束循環

四、CAS的缺點

4.1 ABA問題

從上面介紹,CAS操作經過幾個步驟,獲取值,比較,修改

如果在獲取值和比較之間,該值從原有的A,變爲B,再變爲A,CAS是不知道該值發生了變化

所以使用了版本號來解決該問題,每次變量更新都會把版本號加1,此時A→B→A就會變成1A→2B→3A

4.2 循環開銷大

自旋CAS如果長時間不成功,會給CPU帶來非常大的執行開銷

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

當對一個共享變量執行操作時,我們可以使用循環CAS的方式來保證原子操作

但是對多個共享變量操作時,循環CAS就無法保證操作的原子性,這個時候就需要用鎖機制

但是,我們可以把多個共享變量合併成一個變量,來進行CAS操作

五、原子操作類

java在java.util.concurrent.atomic包下,爲我們提供了一系列以Atomic開頭的包裝類,方便我們使用

jdk1.5的atomic包下提供的原子操作類

標量類:AtomicBoolean,AtomicInteger,AtomicLong,AtomicReference
數組類:AtomicIntegerArray,AtomicLongArray,AtomicReferenceArray
更新器類:AtomicLongFieldUpdater,AtomicIntegerFieldUpdater,AtomicReferenceFieldUpdater
複合變量類:AtomicMarkableReference,AtomicStampedReference



jdk1.8之後又添加了下面的四個類

LongAdder DoubleAdder 高併發情況下替代AtomicLong
LongAccumulator DoubleAccumulator

六、demo

以AtomicInteger爲例,提供了getAndIncrement()(類似i++操作)、incrementAndGet()(類似++i操作)、get()等方法

public class TestAtomicInt {
	
	static AtomicInteger num = new AtomicInteger(0);
	
    public static void main(String[] args) {
    	System.out.println(num.getAndIncrement());
    	System.out.println(num.incrementAndGet());
    	System.out.println(num.get());
    }
}

---------------------------------------------------------------------------------------------------------------------------------------------------

如果我的文章對您有點幫助,麻煩點個贊,您的鼓勵將是我繼續寫作的動力

如果哪裏有不正確的地方,歡迎指正

如果哪裏表述不清,歡迎留言討論

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