線程:併發問題的解決


併發的三大特性是在並行開發中一定要保證的。

最簡單的保證這三個特性的方式就是使用 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;
  1. Unsafe對象,Atomic中原子操作都是藉助於unsafe對象完成的
  2. AtomicInteger對象包裝的變量在內存中的地址
  3. 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的,在不同的平臺實現方式是不同的,不過總體的思路如下,

  1. 判斷當前系統是否是多核處理器
  2. 執行CPU指令對新值和舊值進行交換。如果是多核處理器,會在交換前上鎖。上鎖的目的是使得當前處理器的緩存被鎖定,同時其他處理器無法讀寫被訪問數據的內存區域,故稱爲緩存鎖定

CAS算法

1)CAS與synchronized差異

CAS 是 Compare and swap 的縮寫,翻譯過來就是比較替換。其實 CAS 是樂觀鎖的一種實現(此處與同步進行區分),而 Synchronized 則是悲觀鎖。這裏的樂觀和悲觀指的是當前線程對是否有併發的判斷

鎖類型 解釋
悲觀鎖 認爲當前線程每次的操作大概率會有其它線程在併發,所以自身在操作前都要對資源進行鎖定,這種鎖定是排他的。悲觀鎖的缺點是不但把多線程並行轉化爲了串行,而且加鎖和釋放鎖都會有額外的開支
樂觀鎖 認爲當前線程每次操作時大概率不會有其它線程併發,所以操作時並不加鎖,而是在對數據操作時比較數據的版本,和自己更新前取得的版本一致才進行更新。樂觀鎖省掉了加鎖、釋放鎖的資源消耗,而且在併發量並不是很大的時候,很少會發生版本不一致的情況,此時樂觀鎖效率會更高。

2)CAS算法缺點

CAS將同步的消耗降到了最低,但是也存在如下缺點,

  1. CAS過程如果失敗,則會一直循環,直至成功。這在併發量很大的情況下對 CPU 的消耗將會非常大
  2. 只能保證一個變量自身操作的原子性,但多個變量操作要實現原子性,是無法實現的
  3. ABA問題,假如本線程更新前取得期望值爲 A,和更新操作之間的這段時間內,其它線程可能把 value 改爲了 B 又改回了 A。 而本線程更新時發現 value 和期望值一樣還是 A,認爲其沒有變化,則執行了更新操作。但其實此時的 A 已經不是彼時的 A 了

2. 實現可見性和有序性

上一節筆記可見性問題中已經簡單提到過 volatile 關鍵字。

  1. 被 volatile 關鍵字修飾的變量,會確保值的變化被其它線程所感知,從而從主存中取得該變量最新的值。
  2. 在 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. 可見性。任何線程對其修改,其它線程馬上就能讀到最新值
  2. 有序性。禁止指令重排序

1)保證可見性

CPU 爲了提升速度,採用了緩存,因此造成了多個線程緩存不一致的問題,是可見性的根源。爲了解決緩存一致性,需要了解緩存一致性協議。

MESI 協議是目前主流的緩存一致性協議。此協議會保證,寫操作發生時,線程獨佔該變量的緩存(鎖定的是緩存),同時CPU會通知其它線程對於該變量所在的緩存段失效。只有在獨佔操縱完成之後,該線程才能修改此變量。而此時由於其它緩存全部失效,所以就不存在緩存一致性問題。而其它線程的讀取操作,需要等寫入操作完成,恢復到共享狀態。

2)保證有序性

volatile 的有序性則是通過內存屏障。

內存屏障就是在屏障前的所有指令可以重排序的,屏障之後的指令也可以重排序,但是重排序的時候不能越過內存屏障。也就是說內存屏障前的指令不會被重排序到內存屏障之後,反之亦然。

3)無法保證原子性

volatile 能夠保證變量的可見性和有序性,但是並不能保證原子性。比如用 volatile 修飾了變量 i,多線程併發執行i++。假如有 10 個線程,每個線程執行 1 萬次 i++,那麼最後 i 的結果肯定不是 10 萬。因爲 i++實際爲三步操作,

  1. 各線程從主內存中取得變量i的值存放到緩存
  2. i+1
  3. 賦值給i,並寫入主存(賦值操作與緩存中變量是否失效無關)

這三步在沒有原子性保證時多線程併發,就會導致不同線程同時執行了步驟 1,讀取到了一樣的 n 值,從而造成了重複的 +1 操作。多次 i++ 操作但只爲 i 增加了 1。從試驗結果可以明顯的看出 volatile 並不會保證原子性。

volatile使用場景

首先對volatile的侷限性進行說明,

  1. volatile的可見性和有序性只能作用與單一變量
  2. 不能保證原子性
  3. volatile不能作用於方法,只能修飾實例或者類變量

volatile 的以上特點,決定了它的使用場景是有限的,並不能完全取代 synchronized 同步方式。

一般使用 volatile 的場景是代碼中通過某個狀態值 flag 做判斷,flag 可能被多個線程修改。如果不使用 volatile 修飾,那麼 flag 不能保證最新的值被每個線程讀取到。而在使用 volatile 修飾後,任何線程對 flag 的修改,都立刻對其它線程可見。此外其它線程看到 flag 變化時,所有對 flag 操作前的代碼都已生效,這是 volatile 的有序性確保的。

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