原子類與多線程中變量的累加問題

昨天閒着無事,就想看看java併發編程的的一些東西,想到了原子類。遂看。

首先,原子操作指的是在一步之內就完成而且不能被中斷。原子操作在多線程環境中是線程安全的,無需考慮同步的問題。

先上一段我經常用來做多線程測試的代碼:

public class Test7 {
	
	public static void main(String[] args) {
		AtomicLong a = new AtomicLong(0);
		int s = 100;
		for (int i = 0; i < s; i++) {
			new MyThread(a).start();
		}
	}
}

class MyThread extends Thread{
	
	AtomicLong a = null;
	public MyThread(AtomicLong a) {
		this.a = a;
	}
	
	@Override
	public void run() {
		System.out.println(a.incrementAndGet());
	}
}
運行後可以看到雖然打印出來不是完全從1、2、3....到99,但是沒有重複的,說明是有同步了的。它這個同步不用顯示編程來控制,原碼如下,用到了CAS(比較與交換)算法:

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

  public final boolean compareAndSet(long expect, long update) {
     return unsafe.compareAndSwapLong(this, valueOffset, expect, update);
  }
來看看大神是怎麼說的:

CAS:CPU指令,在大多數處理器架構,包括IA32、Space中採用的都是CAS指令,CAS的語義是“我認爲V的值應該爲A,如果是,那麼將V的值更新爲B,否則不修改並告訴V的值實際爲多少”,CAS是項樂觀鎖 技術,當多個線程嘗試使用CAS同時更新同一個變量時,只有其中一個線程能更新變量的值,而其它線程都失敗,失敗的線程並不會被掛起,而是被告知這次競爭中失敗,並可以再次嘗試。CAS有3個操作數,內存值V,舊的預期值A,要修改的新值B。當且僅當預期值A和內存值V相同時,將內存值V修改爲B,否則什麼都不做。CAS無鎖算法的C實現如下:

int compare_and_swap (int* reg, int oldval, int newval) 
{
  ATOMIC();
  int old_reg_val = *reg;
  if (old_reg_val == oldval) 
     *reg = newval;
  END_ATOMIC();
  return old_reg_val;
}

CAS(樂觀鎖算法)的基本假設前提

CAS比較與交換的僞代碼可以表示爲:

do{  
       備份舊數據; 
       基於舊數據構造新數據; 
}while(!CAS( 內存地址,備份的舊數據,新數據 ))  

就是指當兩者進行比較時,如果相等,則證明共享數據沒有被修改,替換成新值,然後繼續往下運行;如果不相等,說明共享數據已經被修改,放棄已經所做的操作,然後重新執行剛纔的操作。容易看出 CAS 操作是基於共享數據不會被修改的假設,採用了類似於數據庫的 commit-retry 的模式。當同步衝突出現的機會很少時,這種假設能帶來較大的性能提升。

JVM對CAS的支持:AtomicInt, AtomicLong.incrementAndGet()

在JDK1.5之前,如果不編寫明確的代碼就無法執行CAS操作,在JDK1.5中引入了底層的支持,在int、long和對象的引用等類型上都公開了CAS的操作,並且JVM把它們編譯爲底層硬件提供的最有效的方法,在運行CAS的平臺上,運行時把它們編譯爲相應的機器指令,如果處理器/CPU不支持CAS指令,那麼JVM將使用自旋鎖。因此,值得注意的是,CAS解決方案與平臺/編譯器緊密相關(比如x86架構下其對應的彙編指令是lock cmpxchg,如果想要64Bit的交換,則應使用lock cmpxchg8b。在.NET中我們可以使用Interlocked.CompareExchange函數)

在原子類變量中,如java.util.concurrent.atomic中的AtomicXXX,都使用了這些底層的JVM支持爲數字類型的引用類型提供一種高效的CAS操作,而在java.util.concurrent中的大多數類在實現時都直接或間接的使用了這些原子變量類。所以CAS是CPU底層級別對同步方式的實現。

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

這段代碼如何在不加鎖的情況下通過CAS實現線程安全,我們不妨考慮一下方法的執行:

1、AtomicInteger裏面的value原始值爲3,即主內存中AtomicInteger的value爲3,根據Java內存模型,線程1和線程2各自持有一份value的副本,值爲3

2、線程1運行到第三行獲取到當前的value爲3,線程切換

3、線程2開始運行,獲取到value爲3,利用CAS對比內存中的值也爲3,比較成功,修改內存,此時內存中的value改變比方說是4,線程切換

4、線程1恢復運行,利用CAS比較發現自己的value爲3,內存中的value爲4,得到一個重要的結論-->此時value正在被另外一個線程修改,所以我不能去修改它

5、線程1的compareAndSet失敗,循環判斷,因爲value是volatile修飾的,所以它具備可見性的特性,線程2對於value的改變能被線程1看到,只要線程1發現當前獲取的value是4,內存中的value也是4,說明線程2對於value的修改已經完畢並且線程1可以嘗試去修改它

6、最後說一點,比如說此時線程3也準備修改value了,沒關係,因爲比較-交換是一個原子操作不可被打斷,線程3修改了value,線程1進行compareAndSet的時候必然返回的false,這樣線程1會繼續循環去獲取最新的value並進行compareAndSet,直至獲取的value和內存中的value一致爲止

CAS的缺點

CAS看起來很美,但這種操作顯然無法涵蓋併發下的所有場景,並且CAS從語義上來說也不是完美的,存在這樣一個邏輯漏洞:如果一個變量V初次讀取的時候是A值,並且在準備賦值的時候檢查到它仍然是A值,那我們就能說明它的值沒有被其他線程修改過了嗎?如果在這段期間它的值曾經被改成了B,然後又改回A,那CAS操作就會誤認爲它從來沒有被修改過。這個漏洞稱爲CAS操作的"ABA"問題。java.util.concurrent包爲了解決這個問題,提供了一個帶有標記的原子引用類"AtomicStampedReference",它可以通過控制變量值的版本來保證CAS的正確性。不過目前來說這個類比較"雞肋",大部分情況下ABA問題並不會影響程序併發的正確性,如果需要解決ABA問題,使用傳統的互斥同步可能迴避原子類更加高效。


我不要用原子類,是不是也能實現以上多線程裏計數的同步。我可以用一個全局的對象,讓多個線程同步操作它。改成下面這樣子。

public class Test8 {
	static Long n = new Long(0l);

	public static void main(String[] argv) {
		Long n = new Long(0l);
		for (int i = 0; i < 10; i++) {
			new MyThread2(n).start();
		}
	}
}


咋一看好像沒有問題。運行之,發現全都是0,這不是我的預期......

n是公共的,作爲參數傳入線程中,在構造方法MyThread2(Long t)中,this.t = n 讓線程內部的t也指向公共n,這樣對 t 的操作實際上也是對n的操作,不會說10個線程同步獲取到了初始值0,而已還是同步了的,不會全是0纔對。但是事情存在必定有它的道理啊,看似這麼簡單的幾行代碼如果這都弄不明白,那還用寫什麼牛逼的東西嗎?。我把n定義提到外面弄成static,也是不對。System.out.print(this.t == Test8.n)打印了false,這說明t已經不再引用n了,爲啥?在t++前面也打印了這句是true,那就是t++的時候把引用弄丟了。

t++是個什麼操作?先把t拿去用,用完再自增。Long是個引用數據類型,是對基本數據類型的包裝,jdk5.0後提供的功能。當一個Long類型變量做基本運算的時候,是要自動裝箱與拆箱的。分解t++,等價於 t = t + 1。t + 1的t要調用longValue(),變成基本數據類型進行加法,加完又自動裝箱把一個新值1包起來。t = 1把 t 的引用指向了1,注意這裏是線程內部的 t 的引用變了,而那個n卻還依然指向0,沒有變化。所以,它的值是永不變的。

正確寫法:

public class Test8 {
	public static void main(String[] argv) {
		for (int i = 1; i <= 10; i++) {
			new MyThread3().start();
		}
	}
}

class MyThread3 extends Thread {
	static Integer x = 1;
	public void run() {
		synchronized (x) {
			System.out.println(x++ + " " + Thread.currentThread().getName());
		}
	}
}
在線程類定義一個靜態類變量x,無論new了多少個MyThread3的實例,x只有一個。synchronized加在run()或者x上都可以,一般原則是粒度能小就小,對性能的影響就少。

學問真多,感謝豐少的幫助。

週末愉快。


一些參考:

Unsafe與CAS


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