啃碎併發(五):Java執行緒安全特性與問題

猿碼道發表於2019-03-01

0 前言

在單執行緒中不會出現執行緒安全問題,而在多執行緒程式設計中,有可能會出現同時訪問同一個 共享、可變資源 的情況,這種資源可以是:一個變數、一個物件、一個檔案等。特別注意兩點:

  1. 共享: 意味著該資源可以由多個執行緒同時訪問;
  2. 可變: 意味著該資源可以在其生命週期內被修改;

簡單的說,如果你的程式碼在單執行緒下執行和在多執行緒下執行永遠都能獲得一樣的結果,那麼你的程式碼就是執行緒安全的。那麼,當進行多執行緒程式設計時,我們又會面臨哪些執行緒安全的要求呢?又是要如何去解決的呢?

1 執行緒安全特性

1.1 原子性

跟資料庫事務的原子性概念差不多,即一個操作(有可能包含有多個子操作)要麼全部執行(生效),要麼全部都不執行(都不生效)

關於原子性,一個非常經典的例子就是銀行轉賬問題:

比如:A和B同時向C轉賬10萬元。如果轉賬操作不具有原子性,A在向C轉賬時,讀取了C的餘額為20萬,然後加上轉賬的10萬,計算出此時應該有30萬,但還未來及將30萬寫回C的賬戶,此時B的轉賬請求過來了,B發現C的餘額為20萬,然後將其加10萬並寫回。然後A的轉賬操作繼續——將30萬寫回C的餘額。這種情況下C的最終餘額為30萬,而非預期的40萬。

1.2 可見性

可見性是指,當多個執行緒併發訪問共享變數時,一個執行緒對共享變數的修改,其它執行緒能夠立即看到。可見性問題是好多人忽略或者理解錯誤的一點。

CPU從主記憶體中讀資料的效率相對來說不高,現在主流的計算機中,都有幾級快取。每個執行緒讀取共享變數時,都會將該變數載入進其對應CPU的快取記憶體裡,修改該變數後,CPU會立即更新該快取,但並不一定會立即將其寫回主記憶體(實際上寫回主記憶體的時間不可預期)。此時其它執行緒(尤其是不在同一個CPU上執行的執行緒)訪問該變數時,從主記憶體中讀到的就是舊的資料,而非第一個執行緒更新後的資料。

這一點是作業系統或者說是硬體層面的機制,所以很多應用開發人員經常會忽略。

1.3 有序性

有序性指的是,程式執行的順序按照程式碼的先後順序執行。以下面這段程式碼為例:

boolean started = false; // 語句1
long counter = 0L; // 語句2
counter = 1; // 語句3
started = true; // 語句4
複製程式碼

從程式碼順序上看,上面四條語句應該依次執行,但實際上JVM真正在執行這段程式碼時,並不保證它們一定完全按照此順序執行。

處理器為了提高程式整體的執行效率,可能會對程式碼進行優化,其中的一項優化方式就是調整程式碼順序,按照更高效的順序執行程式碼

講到這裡,有人要著急了——什麼,CPU不按照我的程式碼順序執行程式碼,那怎麼保證得到我們想要的效果呢?實際上,大家大可放心,CPU雖然並不保證完全按照程式碼順序執行,但它會保證程式最終的執行結果和程式碼順序執行時的結果一致

2 執行緒安全問題

2.1 競態條件與臨界區

執行緒之間共享堆空間,在程式設計的時候就要格外注意避免競態條件。危險在於多個執行緒同時訪問相同的資源並進行讀寫操作。當其中一個執行緒需要根據某個變數的狀態來相應執行某個操作的之前,該變數很可能已經被其它執行緒修改

也就是說,當兩個執行緒競爭同一資源時,如果對資源的訪問順序敏感,就稱存在 競態條件。導致竟態條件發生的程式碼稱作 臨界區

/**
 * 以下這段程式碼就存在競態條件,其中return ++count就是臨界區。
 */
public class Obj
{

    private int count;

    public int incr()
    {
        return ++count;
    }

}
複製程式碼

2.2 死鎖

死鎖:指兩個或兩個以上的程式(或執行緒)在執行過程中,因爭奪資源而造成的一種互相等待的現象,若無外力作用,它們都將無法推進下去。此時稱系統處於死鎖狀態或系統產生了死鎖,這些永遠在互相等待的程式稱為死鎖程式。

關於死鎖發生的條件:

  1. 互斥條件:執行緒對資源的訪問是排他性的,如果一個執行緒對佔用了某資源,那麼其他執行緒必須處於等待狀態,直到資源被釋放。
  2. 請求和保持條件:執行緒T1至少已經保持了一個資源R1佔用,但又提出對另一個資源R2請求,而此時,資源R2被其他執行緒T2佔用,於是該執行緒T1也必須等待,但又對自己保持的資源R1不釋放。
  3. 不剝奪條件:執行緒已獲得的資源,在未使用完之前,不能被其他執行緒剝奪,只能在使用完以後由自己釋放。
  4. 環路等待條件:在死鎖發生時,必然存在一個“程式-資源環形鏈”,即:{p0,p1,p2,...pn},程式p0(或執行緒)等待p1佔用的資源,p1等待p2佔用的資源,pn等待p0佔用的資源。(最直觀的理解是,p0等待p1佔用的資源,而p1而在等待p0佔用的資源,於是兩個程式就相互等待)

2.3 活鎖

活鎖:是指執行緒1可以使用資源,但它很禮貌,讓其他執行緒先使用資源,執行緒2也可以使用資源,但它很紳士,也讓其他執行緒先使用資源。這樣你讓我,我讓你,最後兩個執行緒都無法使用資源

關於“死鎖與活鎖”的比喻

死鎖:迎面開來的汽車A和汽車B過馬路,汽車A得到了半條路的資源(滿足死鎖發生條件1:資源訪問是排他性的,我佔了路你就不能上來,除非你爬我頭上去),汽車B佔了汽車A的另外半條路的資源,A想過去必須請求另一半被B佔用的道路(死鎖發生條件2:必須整條車身的空間才能開過去,我已經佔了一半,尼瑪另一半的路被B佔用了),B若想過去也必須等待A讓路,A是輛蘭博基尼,B是開奇瑞QQ的屌絲,A素質比較低開窗對B狂罵:快給老子讓開,B很生氣,你媽逼的,老子就不讓(死鎖發生條件3:在未使用完資源前,不能被其他執行緒剝奪),於是兩者相互僵持一個都走不了(死鎖發生條件4:環路等待條件),而且導致整條道上的後續車輛也走不了。

活鎖:馬路中間有條小橋,只能容納一輛車經過,橋兩頭開來兩輛車A和B,A比較禮貌,示意B先過,B也比較禮貌,示意A先過,結果兩人一直謙讓誰也過不去。

2.4 飢餓

飢餓:是指如果執行緒T1佔用了資源R,執行緒T2又請求封鎖R,於是T2等待。T3也請求資源R,當T1釋放了R上的封鎖後,系統首先批准了T3的請求,T2仍然等待。然後T4又請求封鎖R,當T3釋放了R上的封鎖之後,系統又批准了T4的請求……,T2可能永遠等待

也就是,如果一個執行緒因為CPU時間全部被其他執行緒搶走而得不到CPU執行時間,這種狀態被稱之為“飢餓”。而該執行緒被“飢餓致死”正是因為它得不到CPU執行時間的機會

關於“飢餓”的比喻

在“首堵”北京的某一天,天氣陰沉,空氣中充斥著霧霾和地溝油的味道,某個苦逼的臨時工交警正在處理塞車,有兩條道A和B上都堵滿了車輛,其中A道堵的時間最長,B相對堵的時間較短,這時,前面道路已疏通,交警按照最佳分配原則,示意B道上車輛先過,B道路上過了一輛又一輛,A道上排隊時間最長的卻沒法通過,只能等B道上沒有車輛通過的時候再等交警發指令讓A道依次通過,這也就是ReentrantLock顯示鎖裡提供的不公平鎖機制(當然了,ReentrantLock也提供了公平鎖的機制,由使用者根據具體的使用場景而決定到底使用哪種鎖策略),不公平鎖能夠提高吞吐量但不可避免的會造成某些執行緒的飢餓

在Java中,下面三個常見的原因會導致執行緒飢餓,如下:

  1. 高優先順序執行緒吞噬所有的低優先順序執行緒的CPU時間

    你能為每個執行緒設定獨自的執行緒優先順序,優先順序越高的執行緒獲得的CPU時間越多,執行緒優先順序值設定在1到10之間,而這些優先順序值所表示行為的準確解釋則依賴於你的應用執行平臺。對大多數應用來說,你最好是不要改變其優先順序值

  2. 執行緒被永久堵塞在一個等待進入同步塊的狀態,因為其他執行緒總是能在它之前持續地對該同步塊進行訪問

    Java的同步程式碼區也是一個導致飢餓的因素。Java的同步程式碼區對哪個執行緒允許進入的次序沒有任何保障。這就意味著理論上存在一個試圖進入該同步區的執行緒處於被永久堵塞的風險,因為其他執行緒總是能持續地先於它獲得訪問,這即是“飢餓”問題,而一個執行緒被“飢餓致死”正是因為它得不到CPU執行時間的機會

  3. 執行緒在等待一個本身(在其上呼叫wait())也處於永久等待完成的物件,因為其他執行緒總是被持續地獲得喚醒

    如果多個執行緒處在wait()方法執行上,而對其呼叫notify()不會保證哪一個執行緒會獲得喚醒,任何執行緒都有可能處於繼續等待的狀態。因此存在這樣一個風險:一個等待執行緒從來得不到喚醒,因為其他等待執行緒總是能被獲得喚醒

2.5 公平

解決飢餓的方案被稱之為“公平性” – 即所有執行緒均能公平地獲得執行機會。在Java中實現公平性方案,需要:

  1. 使用鎖,而不是同步塊;
  2. 使用公平鎖;
  3. 注意效能方面;

在Java中實現公平性,雖Java不可能實現100%的公平性,依然可以通過同步結構線上程間實現公平性的提高

首先來學習一段簡單的同步態程式碼:

public class Synchronizer{
    public synchronized void doSynchronized () {
        // do a lot of work which takes a long time
    }
}
複製程式碼

如果有多個執行緒呼叫doSynchronized()方法,在第一個獲得訪問的執行緒未完成前,其他執行緒將一直處於阻塞狀態,而且在這種多執行緒被阻塞的場景下,接下來將是哪個執行緒獲得訪問是沒有保障的

改為 使用鎖方式替代同步塊,為了提高等待執行緒的公平性,我們使用鎖方式來替代同步塊:

public class Synchronizer{
    Lock lock = new Lock();
    public void doSynchronized() throws InterruptedException{
        this.lock.lock();
        //critical section, do a lot of work which takes a long time
        this.lock.unlock();
    }
}
複製程式碼

注意到doSynchronized()不再宣告為synchronized,而是用lock.lock()和lock.unlock()來替代。下面是用Lock類做的一個實現:

public class Lock{

    private boolean isLocked      = false;

    private Thread lockingThread = null;

    public synchronized void lock() throws InterruptedException{
        while(isLocked){
            wait();
        }

        isLocked = true;
        lockingThread = Thread.currentThread();
    }

    public synchronized void unlock(){

        if(this.lockingThread != Thread.currentThread()){
            throw new IllegalMonitorStateException("Calling thread has not locked this lock");
        }

        isLocked = false;
        lockingThread = null;
        notify();
    }
}
複製程式碼

注意到上面對Lock的實現,如果存在多執行緒併發訪問lock(),這些執行緒將阻塞在對lock()方法的訪問上。另外,如果鎖已經鎖上(校對注:這裡指的是isLocked等於true時),這些執行緒將阻塞在while(isLocked)迴圈的wait()呼叫裡面。要記住的是,當執行緒正在等待進入lock() 時,可以呼叫wait()釋放其鎖例項對應的同步鎖,使得其他多個執行緒可以進入lock()方法,並呼叫wait()方法

這回看下doSynchronized(),你會注意到在lock()和unlock()之間的註釋:在這兩個呼叫之間的程式碼將執行很長一段時間。進一步設想,這段程式碼將長時間執行,和進入lock()並呼叫wait()來比較的話。這意味著大部分時間用在等待進入鎖和進入臨界區的過程是用在wait()的等待中,而不是被阻塞在試圖進入lock()方法中

在早些時候提到過,同步塊不會對等待進入的多個執行緒誰能獲得訪問做任何保障,同樣當呼叫notify()時,wait()也不會做保障一定能喚醒執行緒。因此這個版本的Lock類和doSynchronized()那個版本就保障公平性而言,沒有任何區別。

但我們能夠改變這種情況,如下:

當前的Lock類版本呼叫自己的wait()方法,如果每個執行緒在不同的物件上呼叫wait(),那麼只有一個執行緒會在該物件上呼叫wait(),Lock類可以決定哪個物件能對其呼叫notify(),因此能做到有效的選擇喚醒哪個執行緒

下面將上面Lock類轉變為公平鎖FairLock。你會注意到新的實現和之前的Lock類中的同步和wait()/notify()稍有不同。重點是,每一個呼叫lock()的執行緒都會進入一個佇列,當解鎖時,只有佇列裡的第一個執行緒被允許鎖住FairLock例項,所有其它的執行緒都將處於等待狀態,直到他們處於佇列頭部。如下:

public class FairLock {
    private boolean isLocked = false;
    private Thread lockingThread = null;
    private List<QueueObject> waitingThreads = new ArrayList<QueueObject>();

    public void lock() throws InterruptedException{
        // 當前執行緒建立“令牌”
        QueueObject queueObject = new QueueObject();
        boolean isLockedForThisThread = true;
        synchronized(this){
            // 所有執行緒的queueObject令牌,入隊
            waitingThreads.add(queueObject);
        }

        while(isLockedForThisThread){
            synchronized(this){
                // 1. 判斷是否已被鎖住:是否已有執行緒獲得鎖,正在執行同步程式碼塊
                // 2. 判斷頭部令牌與當前執行緒令牌是否一致:也就是隻鎖住頭部令牌對應的執行緒;
                isLockedForThisThread = isLocked || waitingThreads.get(0) != queueObject;
                if(!isLockedForThisThread){
                    isLocked = true;
                    // 移除頭部令牌
                    waitingThreads.remove(queueObject);
                    lockingThread = Thread.currentThread();
                    return;
                }
            }
            try{
                // 其他執行緒執行doWait(),進行等待
                queueObject.doWait();
            }catch(InterruptedException e){
                synchronized(this) { waitingThreads.remove(queueObject); }
                throw e;
            }
        }
    }

    public synchronized void unlock(){
        if(this.lockingThread != Thread.currentThread()){
            throw new IllegalMonitorStateException("Calling thread has not locked this lock");
        }
        isLocked = false;
        lockingThread = null;
        if(waitingThreads.size() > 0) {
            // 喚醒頭部令牌對應的執行緒,可以執行
            waitingThreads.get(0).doNotify();
        }
    }
}

public class QueueObject {
    private boolean isNotified = false;

    public synchronized void doWait() throws InterruptedException {
        while(!isNotified){
            this.wait();
        }
        this.isNotified = false;
    }

    public synchronized void doNotify() {
        this.isNotified = true;
        this.notify();
    }

    public boolean equals(Object o) {
        return this == o;
    }
}
複製程式碼

首先注意到lock()方法不在宣告為synchronized,取而代之的是對必需同步的程式碼,在synchronized中進行巢狀

FairLock新建立了一個QueueObject的例項,並對每個呼叫lock()的執行緒進行入隊操作。呼叫unlock()的執行緒將從佇列頭部獲取QueueObject,並對其呼叫doNotify(),以喚醒在該物件上等待的執行緒。通過這種方式,在同一時間僅有一個等待執行緒獲得喚醒,而不是所有的等待執行緒。這也是實現FairLock公平性的核心所在。

還需注意到,QueueObject實際是一個semaphore。doWait()和doNotify()方法在QueueObject中儲存著訊號。這樣做以避免一個執行緒在呼叫queueObject.doWait()之前被另一個執行緒呼叫unlock()並隨之呼叫queueObject.doNotify()的執行緒重入,從而導致訊號丟失。queueObject.doWait()呼叫放置在synchronized(this)塊之外,以避免被monitor巢狀鎖死,所以另外的執行緒可以解鎖,只要當沒有執行緒在lock方法的synchronized(this)塊中執行即可。

最後,注意到queueObject.doWait()在try – catch塊中是怎樣呼叫的。在InterruptedException丟擲的情況下,執行緒得以離開lock(),並需讓它從佇列中移除

3 如何確保執行緒安全特性

3.1 如何確保原子性

3.1.1 鎖和同步

常用的保證Java操作原子性的工具是 鎖和同步方法(或者同步程式碼塊)。使用鎖,可以保證同一時間只有一個執行緒能拿到鎖,也就保證了同一時間只有一個執行緒能執行申請鎖和釋放鎖之間的程式碼。

public void testLock () {
    lock.lock();
    try{
        int j = i;
        i = j + 1;
    } finally {
        lock.unlock();
    }
}
複製程式碼

與鎖類似的是同步方法或者同步程式碼塊。使用非靜態同步方法時,鎖住的是當前例項;使用靜態同步方法時,鎖住的是該類的Class物件;使用靜態程式碼塊時,鎖住的是synchronized關鍵字後面括號內的物件。下面是同步程式碼塊示例:

public void testLock () {
    synchronized (anyObject){
        int j = i;
        i = j + 1;
    }
}
複製程式碼

無論使用鎖還是synchronized,本質都是一樣,通過鎖或同步來實現資源的排它性,從而實際目的碼段同一時間只會被一個執行緒執行,進而保證了目的碼段的原子性。這是一種以犧牲效能為代價的方法

3.1.2 CAS(compare and swap)

基礎型別變數自增(i++)是一種常被新手誤以為是原子操作而實際不是的操作。Java中提供了對應的原子操作類來實現該操作,並保證原子性,其本質是利用了CPU級別的CAS指令。由於是CPU級別的指令,其開銷比需要作業系統參與的鎖的開銷小。AtomicInteger使用方法如下:

AtomicInteger atomicInteger = new AtomicInteger();
for(int b = 0; b < numThreads; b++) {
    new Thread(() -> {
        for(int a = 0; a < iteration; a++) {
            atomicInteger.incrementAndGet();
        }
    }).start();
}
複製程式碼

3.2 如何確保可見性

Java提供了volatile關鍵字來保證可見性。當使用volatile修飾某個變數時,它會保證對該變數的修改會立即被更新到記憶體中,並且將其它執行緒快取中對該變數的快取設定成無效,因此其它執行緒需要讀取該值時必須從主記憶體中讀取,從而得到最新的值。

volatile適用場景:volatile適用於不需要保證原子性,但卻需要保證可見性的場景。一種典型的使用場景是用它修飾用於停止執行緒的狀態標記。如下所示:

boolean isRunning = false;
public void start () {
    new Thread( () -> {
        while(isRunning) {
            someOperation();
        }
    }).start();
}
public void stop () {
    isRunning = false;
}
複製程式碼

在這種實現方式下,即使其它執行緒通過呼叫stop()方法將isRunning設定為false,迴圈也不一定會立即結束。可以通過volatile關鍵字,保證while迴圈及時得到isRunning最新的狀態從而及時停止迴圈,結束執行緒

3.3 如何確保有序性

上文講過編譯器和處理器對指令進行重新排序時,會保證重新排序後的執行結果和程式碼順序執行的結果一致,所以重新排序過程並不會影響單執行緒程式的執行,卻可能影響多執行緒程式併發執行的正確性

Java中可通過volatile在一定程式上保證順序性,另外還可以通過synchronized和鎖來保證順序性。

synchronized和鎖保證順序性的原理和保證原子性一樣,都是通過保證同一時間只會有一個執行緒執行目的碼段來實現的。

除了從應用層面保證目的碼段執行的順序性外,JVM還通過被稱為happens-before原則隱式地保證順序性。兩個操作的執行順序只要可以通過happens-before推匯出來,則JVM會保證其順序性,反之JVM對其順序性不作任何保證,可對其進行任意必要的重新排序以獲取高效率。

happens-before原則(先行發生原則),如下:

  1. 傳遞規則:如果操作1在操作2前面,而操作2在操作3前面,則操作1肯定會在操作3前發生。該規則說明了happens-before原則具有傳遞性
  2. 鎖定規則:一個unlock操作肯定會在後面對同一個鎖的lock操作前發生。這個很好理解,鎖只有被釋放了才會被再次獲取
  3. volatile變數規則:對一個被volatile修飾的寫操作先發生於後面對該變數的讀操作。
  4. 程式次序規則:一個執行緒內,按照程式碼順序執行。
  5. 執行緒啟動規則:Thread物件的start()方法先發生於此執行緒的其它動作。
  6. 執行緒終結原則:執行緒的終止檢測後發生於執行緒中其它的所有操作。
  7. 執行緒中斷規則: 對執行緒interrupt()方法的呼叫先發生於對該中斷異常的獲取。
  8. 物件終結規則:一個物件構造先於它的finalize發生。

4 關於執行緒安全的幾個為什麼

  1. 平時專案中使用鎖和synchronized比較多,而很少使用volatile,難道就沒有保證可見性?

    鎖和synchronized即可以保證原子性,也可以保證可見性。都是通過保證同一時間只有一個執行緒執行目的碼段來實現的。

  2. 鎖和synchronized為何能保證可見性?

    根據JDK 7的Java doc中對concurrent包的說明,一個執行緒的寫結果保證對另外執行緒的讀操作可見,只要該寫操作可以由happen-before原則推斷出在讀操作之前發生。

  3. 既然鎖和synchronized即可保證原子性也可保證可見性,為何還需要volatile?

    synchronized和鎖需要通過作業系統來仲裁誰獲得鎖,開銷比較高,而volatile開銷小很多。因此在只需要保證可見性的條件下,使用volatile的效能要比使用鎖和synchronized高得多。

  4. 既然鎖和synchronized可以保證原子性,為什麼還需要AtomicInteger這種的類來保證原子操作?

    鎖和synchronized需要通過作業系統來仲裁誰獲得鎖,開銷比較高,而AtomicInteger是通過CPU級的CAS操作來保證原子性,開銷比較小。所以使用AtomicInteger的目的還是為了提高效能。

  5. 還有沒有別的辦法保證執行緒安全?

    有。儘可能避免引起非執行緒安全的條件——共享變數。如果能從設計上避免共享變數的使用,即可避免非執行緒安全的發生,也就無須通過鎖或者synchronized以及volatile解決原子性、可見性和順序性的問題

  6. synchronized即可修飾非靜態方式,也可修飾靜態方法,還可修飾程式碼塊,有何區別?

    synchronized修飾非靜態同步方法時,鎖住的是當前例項;synchronized修飾靜態同步方法時,鎖住的是該類的Class物件;synchronized修飾靜態程式碼塊時,鎖住的是synchronized關鍵字後面括號內的物件

相關文章