啃碎併發(五):Java線程安全特性與問題

0 前言

在單線程中不會出現線程安全問題,而在多線程編程中,有可能會出現同時訪問同一個 共享、可變資源 的狀況,這種資源能夠是:一個變量、一個對象、一個文件等。特別注意兩點:html

  1. 共享: 意味着該資源能夠由多個線程同時訪問;
  2. 可變: 意味着該資源能夠在其生命週期內被修改;

簡單的說,若是你的代碼在單線程下執行和在多線程下執行永遠都能得到同樣的結果,那麼你的代碼就是線程安全的。那麼,當進行多線程編程時,咱們又會面臨哪些線程安全的要求呢?又是要如何去解決的呢?java

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萬。api

1.2 可見性

可見性是指,當多個線程併發訪問共享變量時,一個線程對共享變量的修改,其它線程可以當即看到。可見性問題是好多人忽略或者理解錯誤的一點。緩存

CPU從主內存中讀數據的效率相對來講不高,如今主流的計算機中,都有幾級緩存。每一個線程讀取共享變量時,都會將該變量加載進其對應CPU的高速緩存裏,修改該變量後,CPU會當即更新該緩存,但並不必定會當即將其寫回主內存(實際上寫回主內存的時間不可預期)。此時其它線程(尤爲是不在同一個CPU上執行的線程)訪問該變量時,從主內存中讀到的就是舊的數據,而非第一個線程更新後的數據。安全

這一點是操做系統或者說是硬件層面的機制,因此不少應用開發人員常常會忽略。多線程

1.3 有序性

有序性指的是,程序執行的順序按照代碼的前後順序執行。如下面這段代碼爲例:併發

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

從代碼順序上看,上面四條語句應該依次執行,但實際上JVM真正在執行這段代碼時,並不保證它們必定徹底按照此順序執行。oracle

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

講到這裏,有人要着急了——什麼,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關鍵字後面括號內的對象

相關文章
相關標籤/搜索