1. 程式人生 > >【Java併發基礎】安全性、活躍性與效能問題

【Java併發基礎】安全性、活躍性與效能問題

前言

Java的多執行緒是一把雙刃劍,使用好它可以使我們的程式更高效,但是出現併發問題時,我們的程式將會變得非常糟糕。併發程式設計中需要注意三方面的問題,分別是安全性、活躍性和效能問題。

安全性問題

我們經常說這個方法是執行緒安全的、這個類是執行緒安全的,那麼到底該怎麼理解執行緒安全呢?

要給執行緒安全性定一個非常明確的定義是比較複雜的。越正式的定義越複雜,也就越難理解。但是不管怎樣,線上程安全性定義中,最核心的概念還是正確性,可以簡單的理解為程式按照我們期望的執行。
正確性的含義是:某個類的行為與其規範完全一致。執行緒的安全性就可以理解為:當多個執行緒訪問某個類時,這個類始終都能表現出正確的行為,那麼就稱這個類是執行緒安全的。

我們要想編寫出執行緒安全的程式,就需要避免出現併發問題的三個主要源頭:原子性問題、可見性問題和有序性問題。(前面的文章介紹了規避這三個問題的方法)當然也不是所有的程式碼都需要分析這三個問題,只有存在共享資料並且該資料會發生變化,即有多個執行緒會同時讀寫同一個資料時,我們才需要同步對共享變數的操作以保證執行緒安全性。

這也暗示了,如果不共享資料或者共享資料狀態不發生變化,那麼也可以保證執行緒安全性。

綜上,我們可以總結出設計執行緒安全的程式可以從以下三個方面入手:

  1. 不線上程之間共享變數。
  2. 將共享變數設定為不可變的。
  3. 在訪問共享變數時使用同步。

我們前面介紹過使用Java中主要的同步機制synchronized關鍵字來協同執行緒對變數的訪問,synchronized提供的是一種獨佔的加鎖方式。同步機制除了synchronized內建鎖方案,還包括volatile型別變數,顯式鎖(Explicit Lock)以及原子變數。而基於一二點的技術方案有執行緒本地儲存(Thread Local Storage, LTS)、不變模型等(後面會介紹)。

資料競爭

當多個執行緒同時訪問一個數據,並且至少有一個執行緒會寫這個資料時,如果我們不採用任何 同步機制協同這些執行緒對變數的訪問,那麼就會導致併發問題。這種情況我們叫做資料競爭(Data Race)。

例如下面的例子就會發生資料競爭。

public class Test {
    private long count = 0;
    void add10K() {
        int idx = 0;
        while(idx++ < 10000) {
            count += 1;
        }
    }
}

當多個執行緒呼叫add10K()時,就會發生資料競爭。但是我們下面使用synchronized同步機制就可以來防止資料競爭。

public class Test {
    private long count = 0;
    synchronized long get(){
        return count;
    }
    synchronized void set(long v){
        count = v;
    }
    void add10K() {
        int idx = 0;
        while(idx++ < 10000) {
            set(get()+1);      
        }
    }
}

競態條件

但是此時的add10K()方法並不是執行緒安全的。
假設count=0, 當兩個執行緒同時執行get()方法後,get()方法會返回相同的值0,兩個執行緒執行get()+1操作,結果都是1,之後兩個執行緒再將結果1寫入了記憶體。本來期望的是2,但是結果卻是1。(至於為什麼會同時?我當初腦袋被“阻塞”好一會兒才反應過來,哈哈,╮(~▽~)╭,看來不能熬夜寫部落格。因為如果實參需要計算那麼會先被計算,然後作為函式呼叫的引數傳入。這裡get()會先被呼叫,等其返回了才會呼叫set(),所以一個執行緒呼叫完了get()後,另一個執行緒可以馬上獲取鎖呼叫get()。這也就會造成兩個執行緒會得到相同的值。)

這種情況,我們稱為競態條件(Race Condition)。競態條件,是指程式的執行結果依賴執行緒執行的順序 。
上面的例子中,如果兩個執行緒完全同時執行,那麼結果是1;如果兩個執行緒是前後執行,那麼結果就是2。在併發環境裡,執行緒的執行順序是不確定的,如果程式存在競態條件問題,那麼就意味著程式執行的結果是不確定的,而執行結果不確定就是一個大問題。

我們前面講併發bug源頭時,也介紹過競態條件。由於不恰當的執行時序而導致的不正確的結果。要避免競態條件問題,就必須在某個執行緒修改該變數時,通過某種方式防止其他執行緒使用這個變數,從而確保其他執行緒只能在修改操作完成之前或者之後讀取和修改狀態,而不是在修改狀態的過程中。

解決這個例子的競態條件問題,我們可以介紹過的加鎖機制來保證:其他執行緒只能在修改操作完成之前或者之後讀取和修改狀態,而不是在修改狀態的過程中。

public class Test {
    private long count = 0;
    synchronized long get(){
        return count;
    }
    synchronized void set(long v){
        count = v;
    }
    void add10K() {
        int idx = 0;
        while(idx++ < 10000) {
            synchronized(this){
                set(get()+1);    
            }  
        }
    }
}

所以面對資料競爭和競態條件我們可以使用加鎖機制來保證執行緒的安全性!

活躍性問題

安全性的含義是“永遠不發生糟糕的事情”,而活躍性則關注另外一個目標,即“某件正確的事情最終會發生”。 當某個操作無法繼續執行下去時,就會發生活躍性問題。
在序列程式中,活躍性問題的形式之一便是無意中造成的無限迴圈。從而使迴圈之後的程式碼無法被執行。而執行緒將會帶來其他的一些活躍性問題,例如我們前面所講的死鎖,以及我們下面將要介紹的飢餓和活鎖。

飢餓

飢餓(Starvation)指的是執行緒無法訪問到所需要的資源而無法執行下去的情況。

引發飢餓最常見的資源便是CPU時鐘週期。如果Java應用程式中對執行緒的優先順序使用不當,或者在持有鎖時執行一些無法結束的結構(例如無限迴圈或者無限制地等待某個資源),那麼也可能導致飢餓,因為其他需要這個鎖的執行緒無法得到它。

通常,我們儘量不要改變執行緒的優先順序,在大部分併發應用程式中,可以使用預設的執行緒優先順序。只要改變了執行緒的優先順序,程式的行為就將與平臺相關,並且可能導致發生飢餓問題的風險(例如優先順序高的執行緒會一直獲取資源,而低優先順序的執行緒則將一直無法獲取到資源)。
當某個程式會在一些奇怪的地方呼叫Thread.sleepThread.yield,那是這個程式在試圖克服優先順序調整問題或響應性問題,並試圖讓低優先順序的執行緒執行更多的時間。

飢餓問題的實質可以用孔子老人家說過的一句話來總結:不患寡而患不均。

解決飢餓問題,有以下三種方案:

  1. 保證資源充足。
  2. 公平地分配資源。
  3. 避免持有鎖的執行緒長時間執行。

這三個方案中,方案一和方案三的適用場景比較有限,因為很多場景下,資源的稀缺性是沒辦法解決的,持有鎖的執行緒執行的時間也很難縮短。所以,方案二的適用場景會多一點。在併發程式設計裡,我們可以使用公平鎖來公平的分配資源。所謂公平鎖,是一種FIFO方案,執行緒的等待是有順序的,排在等待佇列前面的執行緒會優先獲得資源。

活鎖

活鎖(Livelock)是另一種形式的活躍性問題,它和死鎖很相似,但是它卻不會阻塞執行緒。活鎖儘管不會阻塞執行緒,但也不能繼續執行,因為執行緒將不斷重複執行相同的操作,而且總會失敗。

活鎖通常發生在處理事務訊息的應用程式中:如何不能成功地處理某個訊息,那麼訊息處理機制將回滾整個事務,並將它重新放置到佇列的開頭。如果訊息處理器在處理某種特定的訊息時存在錯誤並導致它失敗,那麼每當這個訊息從佇列中取出並傳遞到存在錯誤的處理器時,都會發生事務回滾。由於這個訊息又被放到佇列開頭,因此處理器將被反覆呼叫,並返回相同的處理結果。(有時候也被稱為毒藥訊息,Poison Message。)雖然處理訊息的執行緒沒有被阻塞,但也無法執行下去。這種形式的活鎖,通常由過度的錯誤恢復程式碼造成,因為它錯誤地將不可修復的錯誤作為可修復的錯誤。

當多個相互協作的執行緒都對彼此進行響應從而修改各自的狀態,並使得任何一個執行緒都無法繼續執行時,就發生了活鎖。 這就好比兩個過於禮貌的人在半路上相遇,為了不相撞,他們彼此都給對方讓路,結果導致他們又相撞。他們如此反覆下一,便造成了活鎖問題。

解決這種活鎖問題,我們在重試機制中引入隨機性。即,讓他們在謙讓時嘗試等待一個隨機的時間。如此,他們便不會相撞而順序通行。我們在乙太網協議的二進位制指數退避演算法中,也可以看到引入隨機性降低衝突和反覆失敗的好處。在併發應用程式中,通過等待隨機長度的時間和回退可以有效避免活鎖的發生。

效能問題

與活躍性問題密切相關的是效能問題。活躍性意味著某件正確的事情最終會發生,但卻不夠好,因為我們通常希望正確事情儘快發生。效能問題包括多個方面,例如服務時間過長,響應不靈敏,吞吐量過低,資源消耗過高,或者可伸縮性降低等。與活躍性和安全性一樣,在多執行緒程式中不僅存在與單執行緒程式相同的效能問題,而且還存在由於實現執行緒而引入的其他效能問題。

我們使用多執行緒的目的是提升程式的整體效能,但是與單執行緒的方法相比,使用多個執行緒總會引入一些額外的效能開銷。造成這些開銷的操作包括:執行緒之間的協調(如加鎖、記憶體同步等),增加上下文切換,執行緒的建立和銷燬,以及執行緒的排程等。如果我們多度地使用執行緒,那麼這些開銷可能超過由於提高吞吐量、響應性或者計算能力所帶來的效能提升。另一方面,一個併發設計很糟糕的程式,其效能甚至比完成相同功能的序列程式效能還要低。
想要通過併發來獲得更好的效能就需要做到:更有效地利用現有處理資源,以及在出現新的處理資源時使程式儘可能地利用這些新資源。

下面我們將介紹如何評估效能、分析多執行緒帶來的額外開銷以及如何減少這些開銷。

效能和可伸縮性

應用程式的效能可以採用多個指標來衡量,例如服務時間、延遲時間、吞吐量、效率、可伸縮性以及容量等。其中一些指標(服務時間、等待時間)用於衡量程式的“執行速度”,即某個指定的任務單元需要“多快”才能處理完成。另一些指標(生產量、吞吐量)用於程式的“處理能力”,即在計算資源一定的情況下,能完成“多少”工作。

可伸縮性指的是:當增加計算資源(例如CPU、記憶體、儲存容量或者I/O頻寬)時,程式的吞吐量或者處理能力相應地增加。在對可伸縮性調優時,目的是將設法將問題的計算並行化,從而能夠利用更多的計算資源來完成更多的任務。而我們傳統的對效能調優,目的是用更小的代價完成相同的工作,例如通過快取來重用之前的計算結果。

Amdahl定律

大多數的併發程式都是由一系列的並行工作和序列工作組成。
Amdahl定律描述的是:在增加計算資源的情況下,程式在理論上能夠實現最高加速比,這個值取決於程式中可並行元件與序列元件所佔比重。簡單點說,Amdahl定律代表了處理器並行運算之後效率提升的能力。
假定F是必須被序列執行的部分,那麼根據Amdahl定律,在包含N個處理器的機器中,最高加速比為:

\[Speedup <= \frac{1}{F+\frac{(1-F)}{N}}\]

當N趨近於無窮大時,最高加速比趨近於\(\frac{1}{F}\) 。因此,如果程式有50%的計算需要序列執行,那麼最高加速比只能是2,而不管有多個執行緒可用。無論我們採用什麼技術,最高也就只能提升2倍的效能。
Amdahl定律量化了序列化的效率開銷。在擁有10個處理器的系統中,如果程式中有10%的部分需要序列執行,那麼最高加速比為5.3(53%的使用率),在擁有100個處理器的系統中,加速比可以達到9.2(92%的使用率)。但是擁有無限多的處理器,加速比也不會到達10。

如果能準確估計出執行過程中穿行部分所佔的比例,那麼Amdahl定律就可以量化當有更多計算資源可用時的加速比。

執行緒引入的開銷

在多個執行緒的排程和協調過程中都需要一定的效能開銷。所以我們要保證,並行帶來的效能提升必須超過併發導致的開銷,不然這就是一個失敗的併發設計。下面介紹併發帶來的開銷。

上下文切換

如果主執行緒是唯一的執行緒,那麼它基本上不會被排程出去。如果可執行的執行緒數目大於CPU的數量,那麼作業系統最終會將某個正在執行的執行緒排程出來,從而使其他執行緒能夠使用CPU。這將導致一次上下文切換,在這個過程中,將儲存當前執行執行緒的執行上下文,並將新排程進來的執行緒的執行上下文設定為當前上下文。

切換上下文需要一定的開銷,而線上程排程過程中需要訪問由作業系統和JVM共享的資料結構。上下文切換的開銷不止包含JVM和作業系統的開銷。當一個新的執行緒被切換進來時,它所需要的資料可能不在當前處理器的本地快取中,因此上下文切換將導致一些快取缺失(丟失區域性性),因而執行緒在首次排程執行時會更加緩慢。
排程器會為每個可執行的執行緒分配一個最小執行時間,即使有許多其他的執行緒正在等待執行:這是為了將上下文切換的開銷分攤到更多不會中斷的執行時間上,從而提高整體的吞吐量(以損失響應性為代價)。

當執行緒被頻繁的阻塞時,也可能會導致上下文切換,從而增加排程開銷,降低吞吐量。因為,當執行緒由於沒有競爭到鎖而被阻塞時,JVM通常會將這個執行緒掛起,並允許它被交換出去。

上下文切換的實際開銷會隨著平臺的不同而變化,按照經驗來看:在大多數通用的處理器上,上下文切換的開銷相當於5000~10000個時鐘週期,也就是幾微秒。

記憶體同步

同步操作的效能開銷包括多個方面。在synchronized和volatile提供的可見性保證中可能會使用一些特殊指令,即記憶體柵欄(也就是我們前面文章介紹過的記憶體屏障)。記憶體柵欄可以重新整理快取,使快取無效,重新整理硬體的寫緩衝,以及停止執行管道。記憶體柵欄可能同樣會對效能帶來間接的影響,因為它們將抑制一些編譯器優化操作。在記憶體柵欄中,大多數的操作都是不能被重排序的。

在評估同步操作帶來的效能影響時,需要區分有競爭的同步和無競爭的同步。現代的JVM可以優化一些不會發生競爭的鎖,從而減少不必要的同步開銷。

synchronized(new Object()){...}

JVM會通過逃逸分析優化掉以上的加鎖。
所以,我們應該將優化重點放在那些發生鎖競爭的地方。

某個執行緒的同步可能會影響其他執行緒的效能。同步會增加共享記憶體總線上的通訊量,匯流排的頻寬是有限的,並且所有的處理器都將共享這條匯流排。如果有多個執行緒競爭同步頻寬,那麼所有使用了同步的執行緒都會受到影響。

阻塞

非競爭的同步可以完全在JVM中處理,而競爭的同步可能需要作業系統的介入,從而增加系統的開銷。在鎖上發生競爭時,競爭失敗的執行緒會被阻塞。JVM在實現阻塞行為時,可以採用自旋等待(Spin-Waitiin,指通過迴圈不斷地嘗試獲取鎖,直到成功)或者通過作業系統掛起被阻塞的執行緒。這兩種方式的效率高低,取決於上下文切換的開銷以及在成功獲取鎖之前需要等待的時間。如果等待時間短,就採用自旋等待方式;如果等待時間長,則適合採用執行緒掛起的方式。JVM會分析歷史等待時間做選擇,不過,大多數JVM在等待鎖時都只是將執行緒掛起。

執行緒被阻塞掛起時,會包含兩次的上下文切換,以及所有必要的作業系統操作和快取操作。

減少鎖的競爭

序列操作會降低可伸縮性,並且上下文切換也會降低效能。當在鎖上發生競爭時會同時導致這兩種問題,因此減少鎖的競爭能夠提高效能和可伸縮性。
在對某個獨佔鎖保護的資源進行訪問時,將採用序列方式——每次只有一個執行緒能訪問它。如果在鎖上發生競爭,那麼將限制程式碼的可伸縮性。
在併發程式中,對可伸縮性的最主要的威脅就是獨佔方式的資源鎖。

有兩個因素將影響在鎖上發生競爭的可能性:鎖的請求頻率和每次持有該鎖的時間。(Little定律)
如果二者的乘積很小,那麼大多數獲取鎖的操作都不會發生競爭,因此在該鎖上的競爭不會對可伸縮性造成嚴重影響。

下面介紹降低鎖的競爭程度的方案。

縮小鎖的範圍

降低發生競爭的可能性的一種有效方式就是儘可能縮短鎖的持有時間。例如,可以將一些與鎖無關的程式碼移除程式碼塊,尤其是那些開銷較大的操作,以及可能被阻塞的操作(I/O操作)。
儘管縮小同步程式碼塊能提高可伸縮性,但同步程式碼塊也不能太小,因為會有一些複合操作需要以原子操作的方式進行,這時就必須在同一同步塊中。

減小鎖的粒度

另一種減少鎖的持有時間的方式便是降低執行緒請求鎖的頻率(從而減小發生競爭的可能性)。這可以通過鎖分解和鎖分段等技術來實現,這些技術中將採用多個相互獨立的鎖來保護相互獨立的狀態變數,從而改變這些變數在之前由單個鎖來保護的情況。這些技術能縮小鎖操作的粒度,並能實現更高的可伸縮性。但是需要注意,使用的鎖越多,也就越容易發生死鎖。

鎖分解

如果一個鎖需要保護多個相互獨立的狀態變數,那麼可以將這個鎖分解為多個鎖,並且每個鎖只保護一個變數,從而提高可伸縮性,並最終降低每個鎖被請求的頻率。

例如,如下的程式我們便可以進行鎖分解。(例子來自《Java併發程式設計實踐》)

@ThreadSafe   // 該註解表示該類是執行緒安全的
public class ServerStatus {
    // @GuardedBy(xxx)表示該狀態變數是由xxx鎖保護
    @GuardedBy("this") public final Set<String> users;
    @GuardedBy("this") public final Set<String> queries;

    public ServerStatusBeforeSplit() {
        users = new HashSet<String>();
        queries = new HashSet<String>();
    }

    public synchronized void addUser(String u) {
        users.add(u);
    }

    public synchronized void addQuery(String q) {
        queries.add(q);
    }

    public synchronized void removeUser(String u) {
        users.remove(u);
    }

    public synchronized void removeQuery(String q) {
        queries.remove(q);
    }
}

以上程式表示的是某個資料庫伺服器的部分監視介面,該資料庫維護了當前已經登入的使用者以及正在執行的請求。當一個使用者登入、登出、開始查詢或者結束查詢時,都會呼叫相應的add或者remove方法來更新ServerStatus物件。這兩種型別資訊是完全獨立的,因此,我們可以嘗試用鎖分解來提升該程式的效能。

@ThreadSafe
public class ServerStatus{
    @GuardedBy("users") public final Set<String> users;
    @GuardedBy("queries") public final Set<String> queries;

    public ServerStatusAfterSplit() {
        users = new HashSet<String>();
        queries = new HashSet<String>();
    }

    public void addUser(String u) {
        synchronized (users) {
            users.add(u);
        }
    }

    public void addQuery(String q) {
        synchronized (queries) {
            queries.add(q);
        }
    }

    public void removeUser(String u) {
        synchronized (users) {
            users.remove(u);
        }
    }

    public void removeQuery(String q) {
        synchronized (users) {
            queries.remove(q);
        }
    }
}

我們將原來的ServerStatus分解,使用新的細粒度鎖來同步對狀態變數的維護。減少了鎖的競爭,提升了效能。

鎖分段

把一個競爭激烈的鎖分解為兩個鎖時,這兩個鎖可能都存在激烈的競爭。在上面的鎖分解例子中,並不能進一步對鎖進行分解。

在某些情況下,可以將鎖分解技術進一步擴充套件為對一組獨立物件上的鎖進行分解,這種情況被稱為鎖分段。

例如,ConcurrentHashMap的實現中使用了一個包含16個鎖的陣列,每個鎖保護所有雜湊桶的\(\frac{1}{16}\) ,其中第N個雜湊桶由第(N mod 16)個鎖來保護。
假設雜湊函式具有合理的分佈性,並且關鍵字能夠實現均勻分佈,那麼這大約能把對於鎖的請求減少到原來的\(\frac{1}{16}\) 。正是因為這項技術,使用ConcurrentHashMap可以支援多大16個併發的寫入器。

鎖分段的一個劣勢在於:需要獲取多個鎖來實現獨佔訪問將更加困難且開銷更高。例如當ConcurrentHashMap需要擴充套件對映範圍,以及重新計算鍵值的雜湊值需要分不到更大的桶集合中時,就需要獲取所有分段鎖。

下面的程式碼展示了在基於雜湊的Map中使用鎖分段的技術。它擁有N_LOCKS個鎖,並且每個鎖保護雜湊桶的一個子集。大多數方法都只需要獲得一個鎖,如get(),而有些方法則需要獲取到所有的鎖,但不要求同時獲得,如clear()。(例子來自《Java併發程式設計實踐》)

@ThreadSafe
public class StripedMap {
    // Synchronization policy: buckets[n] guarded by locks[n%N_LOCKS]
    private static final int N_LOCKS = 16;
    private final Node[] buckets;
    private final Object[] locks;

    private static class Node {
        Node next;
        Object key;
        Object value;
    }

    public StripedMap(int numBuckets) {
        buckets = new Node[numBuckets];
        locks = new Object[N_LOCKS];
        for (int i = 0; i < N_LOCKS; i++)
            locks[i] = new Object();
    }

    private final int hash(Object key) {
        return Math.abs(key.hashCode() % buckets.length);
    }

    public Object get(Object key) {
        int hash = hash(key);
        synchronized (locks[hash % N_LOCKS]) {
            for (Node m = buckets[hash]; m != null; m = m.next)
                if (m.key.equals(key))
                    return m.value;
        }
        return null;
    }

    public void clear() {
        for (int i = 0; i < buckets.length; i++) {
            synchronized (locks[i % N_LOCKS]) {
                buckets[i] = null;
            }
        }
    }
}

一些代替獨佔鎖的方法

除了縮小鎖的範圍、減少請求鎖的粒度,還有第三種降低鎖的影響的技術就是放棄使用獨佔鎖。
使用一些無鎖的演算法或者資料結構來管理共享狀態。例如,使用併發容器、讀-寫鎖、不可變物件以及原子變數。

後面也會陸續介紹這些方案。

小結

結合我們前面講的併發知識,我們現在可以從微觀和巨集觀來理解併發程式設計。在微觀上,設計併發程式時我們要考慮到原子性、可見性和有序性問題。跳出微觀,從巨集觀上來看,我們設計程式,要考慮到到執行緒的安全性、活躍性以及效能問題。我們在做效能優化的前提是要保證執行緒安全性,如果會優化後出現併發問題,那麼結果將會與我們的預期背道而馳。

參考:
[1]極客時間專欄王寶令《Java併發程式設計實戰》
[2]Brian Goetz.Tim Peierls. et al.Java併發程式設計實戰[M].北京:機械工業出版社,2