併發問題的解決
併發的三大特性是在並行開發中一定要保證的。
最簡單的保證這三個特性的方式就是使用 synchronized關鍵字或使用鎖。這樣做既簡單又包治百病,但是同步操作是悲觀鎖的方式,原理是讓原本的並行在同步區域和鎖區域中轉爲串行。相當於自廢武功,如果濫用會失去併發的意義。
除了掌握 synchronized和鎖以外,遇到併發問題時,採用更爲合適的輕量級辦法是必要的。
1. 實現原子性
Atomic
1)Atomic概述
Atomic 相關類在 java.util.concurrent.atomic 包中。針對不同的原生類型及引用類型,有 AtomicInteger、AtomicLong、AtomicBoolean、AtomicReference 等。另外還有數組對應類型 AtomicIntegerArray、AtomicLongArray、AtomicReferenceArray。
以 AtomicInteger 爲例,一個簡單的例子,運算邏輯是對變量 count 的累加。
假如 count 爲 int 類型,多個線程併發時,可能各自讀取到了同樣的值,也可能 A 線程讀到 2,但由於某種原因更新晚了,count 已經被其它線程更新爲了 4,但是線程 A 還是繼續執行了 count+1 的操作,count 反而被更新爲更小的值 3。
現在的多線程程序是不安全的。如果把 count=count+1
放入 synchronized 代碼塊中肯定能夠解決問題。但是這種同步操作是悲觀鎖的方式,每次都認爲有其它線程在和它併發操作,所以每次都要對資源進行鎖定,而加鎖這個操作自身就有很大消耗。而且不是每一次 count+1 時都有併發發生,無併發發生時的加鎖並無必要。直接用 synchronized 進行同步,效率並不高。
在聲明 count 的時候,將其聲明爲 AtomicInteger 即可,然後把 count=count+1
的語句改爲 count.incrementAndGet ()
問題就解決了。
2)Atomic源碼分析
構造方法
以AtomicInteger爲例,該類中有3個重要的變量,
private static final Unsafe unsafe = Unsafe.getUnsafe();
private static final long valueOffset;
private volatile int value;
- Unsafe對象,Atomic中原子操作都是藉助於unsafe對象完成的
- AtomicInteger對象包裝的變量在內存中的地址
- AtomicInteger對象包裝的變量值,使用
volatile
關鍵字修飾,確保變量的變化能夠被其他線程看到
AtomicInteger的構造方法如下,
public AtomicInteger(int initialValue) {
value = initialValue;
}
把傳入的值賦予 value對象,同時AtomicInteger類中存在一段靜態代碼塊,獲取value的內存地址,
static {
try {
valueOffset = unsafe.objectFieldOffset
(AtomicInteger.class.getDeclaredField("value"));
} catch (Exception ex) { throw new Error(ex); }
}
通過unsafe對象的方法獲取到 value對象的內存地址並賦值給 valueOffset對象。
increamentAndGet方法
Atomic的方法都是原子性的,以AtomicInteger的自增方法爲例,
public final int incrementAndGet() {
return unsafe.getAndAddInt(this, valueOffset, 1) + 1;
}
原子性的操作實際上都是由 Unsafe類對象實現的,實例對象unsafe的getAndAddInt
源碼如下,
public final int getAndAddInt(Object obj, long valueOffset, int var) {
int expect;
// 利用循環,直到更新成功才跳出循環。
do {
// 獲取value當前的最新值
expect = this.getIntVolatile(obj, valueOffset);
// expect + var表示需要更新的值,使用CAS方式進行更新
// 更新成功則停止,反之再次嘗試
} while(!this.compareAndSwapInt(obj, valueOffset, expect, expect + var));
// 返回當前線程在更改value成功後的,value變量原先值。並不是更改後的值
return expect;
}
Unsafe類中原子性操作本質上也是CAS算法,compareAndSwapInt
方法是native的,在不同的平臺實現方式是不同的,不過總體的思路如下,
- 判斷當前系統是否是多核處理器
- 執行CPU指令對新值和舊值進行交換。如果是多核處理器,會在交換前上鎖。上鎖的目的是使得當前處理器的緩存被鎖定,同時其他處理器無法讀寫被訪問數據的內存區域,故稱爲緩存鎖定
CAS算法
1)CAS與synchronized差異
CAS 是 Compare and swap 的縮寫,翻譯過來就是比較替換。其實 CAS 是樂觀鎖的一種實現(此處與同步進行區分),而 Synchronized 則是悲觀鎖。這裏的樂觀和悲觀指的是當前線程對是否有併發的判斷。
鎖類型 | 解釋 |
---|---|
悲觀鎖 | 認爲當前線程每次的操作大概率會有其它線程在併發,所以自身在操作前都要對資源進行鎖定,這種鎖定是排他的。悲觀鎖的缺點是不但把多線程並行轉化爲了串行,而且加鎖和釋放鎖都會有額外的開支 |
樂觀鎖 | 認爲當前線程每次操作時大概率不會有其它線程併發,所以操作時並不加鎖,而是在對數據操作時比較數據的版本,和自己更新前取得的版本一致才進行更新。樂觀鎖省掉了加鎖、釋放鎖的資源消耗,而且在併發量並不是很大的時候,很少會發生版本不一致的情況,此時樂觀鎖效率會更高。 |
2)CAS算法缺點
CAS將同步的消耗降到了最低,但是也存在如下缺點,
- CAS過程如果失敗,則會一直循環,直至成功。這在併發量很大的情況下對 CPU 的消耗將會非常大
- 只能保證一個變量自身操作的原子性,但多個變量操作要實現原子性,是無法實現的
- ABA問題,假如本線程更新前取得期望值爲 A,和更新操作之間的這段時間內,其它線程可能把 value 改爲了 B 又改回了 A。 而本線程更新時發現 value 和期望值一樣還是 A,認爲其沒有變化,則執行了更新操作。但其實此時的 A 已經不是彼時的 A 了
2. 實現可見性和有序性
上一節筆記可見性問題中已經簡單提到過 volatile 關鍵字。
- 被 volatile 關鍵字修飾的變量,會確保值的變化被其它線程所感知,從而從主存中取得該變量最新的值。
- 在 happans-before 原則中有一條 volatile 變量原則,闡述了 vlatile 如何確保有序性。
volatile效果
以下面的代碼爲例,
private static class ShowVisibility implements Runnable{
public static Object o = new Object();
private volatile Boolean flag = false;
@Override
public void run() {
while (true) {
if (flag) {
System.out.println(Thread.currentThread().getName()+":"+flag);
}
}
}
}
public static void main(String[] args) throws InterruptedException {
ShowVisibility showVisibility = new ShowVisibility();
Thread visableThread = new Thread(showVisibility);
visableThread.start();
//給線程啓動的時間
Thread.sleep(500);
//更新flag
showVisibility.flag=true;
System.out.println("flag is true, thread should print");
Thread.sleep(1000);
System.out.println("I have slept 1 seconds. Is there anything printed ?");
}
代碼中使用 volatile 修飾 flag 變量,確保在多個線程併發時,任何一個線程改變了 flag 的值都會立即被其它線程所看到。以上程序 main 線程修改了 flag 值後,visableThread 能夠立即打印出自己的線程 name。但如果把 flag 前的 volatile 去掉,可以看到 main 線程修改了 flag 值後,visableThread 也不會有任何輸出。也就是說 visableThread 並不知道 flag 值已經被修改。
理解volatile
被volatile修飾後,該變量獲得以下特性,
- 可見性。任何線程對其修改,其它線程馬上就能讀到最新值
- 有序性。禁止指令重排序
1)保證可見性
CPU 爲了提升速度,採用了緩存,因此造成了多個線程緩存不一致的問題,是可見性的根源。爲了解決緩存一致性,需要了解緩存一致性協議。
MESI 協議是目前主流的緩存一致性協議。此協議會保證,寫操作發生時,線程獨佔該變量的緩存(鎖定的是緩存),同時CPU會通知其它線程對於該變量所在的緩存段失效。只有在獨佔操縱完成之後,該線程才能修改此變量。而此時由於其它緩存全部失效,所以就不存在緩存一致性問題。而其它線程的讀取操作,需要等寫入操作完成,恢復到共享狀態。
2)保證有序性
volatile 的有序性則是通過內存屏障。
內存屏障就是在屏障前的所有指令可以重排序的,屏障之後的指令也可以重排序,但是重排序的時候不能越過內存屏障。也就是說內存屏障前的指令不會被重排序到內存屏障之後,反之亦然。
3)無法保證原子性
volatile 能夠保證變量的可見性和有序性,但是並不能保證原子性。比如用 volatile 修飾了變量 i,多線程併發執行i++
。假如有 10 個線程,每個線程執行 1 萬次 i++
,那麼最後 i 的結果肯定不是 10 萬。因爲 i++
實際爲三步操作,
- 各線程從主內存中取得變量i的值存放到緩存
- i+1
- 賦值給i,並寫入主存(賦值操作與緩存中變量是否失效無關)
這三步在沒有原子性保證時多線程併發,就會導致不同線程同時執行了步驟 1,讀取到了一樣的 n 值,從而造成了重複的 +1 操作。多次 i++ 操作但只爲 i 增加了 1。從試驗結果可以明顯的看出 volatile 並不會保證原子性。
volatile使用場景
首先對volatile的侷限性進行說明,
- volatile的可見性和有序性只能作用與單一變量
- 不能保證原子性
- volatile不能作用於方法,只能修飾實例或者類變量
volatile 的以上特點,決定了它的使用場景是有限的,並不能完全取代 synchronized 同步方式。
一般使用 volatile 的場景是代碼中通過某個狀態值 flag 做判斷,flag 可能被多個線程修改。如果不使用 volatile 修飾,那麼 flag 不能保證最新的值被每個線程讀取到。而在使用 volatile 修飾後,任何線程對 flag 的修改,都立刻對其它線程可見。此外其它線程看到 flag 變化時,所有對 flag 操作前的代碼都已生效,這是 volatile 的有序性確保的。