JAVA 併發之路 (二) 執行緒安全性

望舒喜歡夏天發表於2018-01-11

物件的狀態

物件的狀態是指儲存在狀態變數(例如例項域,靜態域)中的資料,還可能包括其他依賴物件的域。物件中的域的值的集合描述著當前特徵的資訊,這就是物件的狀態。在物件的狀態中包含了任何可能影響其外部可見行為的資料。

要編寫執行緒安全的程式碼,其核心在於要對狀態訪問操作進行管理,特別是對共享的可變的狀態的訪問。“共享”意味著變數可以被多個執行緒同時訪問;“可變”意味著變數的值在其生命週期內可以發生變化。

一個物件是否需要是執行緒安全的,取決於它是否被多個執行緒訪問。這指的是在程式中訪問物件的方式,而不是物件要實現的功能。要使得物件是執行緒安全的,需要採用同步機制來協同對物件可變狀態的訪問。

Java中的主要同步機制是關鍵字synchronized,它提供了一種獨佔的加鎖方式。但是“同步”這個術語還包括volatile型別的變數,顯式鎖以及原子變數。

如果當多個執行緒訪問同一個可變的狀態變數時沒有使用合適的同步,那麼程式就會出現錯誤。有三種方式可修復這個問題:

  • 不線上程之間共享該狀態變數。
  • 將狀態變數修改為不可變的變數;
  • 在訪問狀態變數時使用同步;

應當始終遵循的原則

編寫併發應用程式時應當始終遵循的原則是:首先使程式碼正確執行,然後再提高程式碼的速度。即便如此,最好也只是當效能測試結果和應用需求告訴你必須提高效能,以及測量結果表明這種優化在實際環境中確實能夠帶來效能提升時,才進行優化。

執行緒安全性

執行緒安全性是一個在程式碼上使用的術語,但它只是與狀態相關的,因此只能應用於封裝其狀態的整個程式碼,這可能是一個物件,也可能是整個程式。

線上程安全性的定義中,最核心的概念就是正確性。正確性的含義是:某各類的行為與其規範完全一致。在良好的規範中通常會定義各種不變性條件來約束物件的狀態,以及定義各種後驗條件來描述物件操作的結果

執行緒安全性當多個執行緒訪問某個類時,不管執行時環境採用何種排程方式或者這些執行緒將如何交替執行,並且在主調程式碼中不需要任何額外的同步或協同,這個類都能表現出正確的行為,那麼就稱這個類是執行緒安全的。

也可以將執行緒安全類認為是一個在單執行緒環境和併發環境都不會被破壞的類。如果正確地實現了某個物件,那麼在任何操作中都不會違背不變性條件或後驗條件。線上程安全類的物件例項上執行的任何序列或並行操作都不會使物件處於無效狀態。

無狀態物件一定是執行緒安全的。因為不存在任何域,也不包含任何對其他類中域的引用。計算過程中的臨時狀態僅僅存在於執行緒棧上的區域性變數中,並且只能由正在執行的執行緒訪問。

競態條件: 在併發程式設計中,由於不恰當的執行時序而出現不正確的結果是一種非常重要的情況。當某個計算的正確性取決於多個執行緒的交替執行時序時,就會發生競態條件。

常見的競態條件型別: “先檢查後執行”操作:通過一個可能失效的觀測結果來決定下一步的動作,或者來做出判斷,或者執行某個計算。常見情況就是延遲初始化。 “讀取-修改-寫入”操作:基於物件之前的狀態來定義物件狀態的轉換。比如遞增運算。

要避免競態條件問題,就必須在某個執行緒修改變數時,通過某種方式防止其他執行緒使用這個變數,從而確保其他執行緒只能在修改操作完成之前或之後讀取和修改狀態,而不是在修改狀態的過程中。

複合操作

原子操作是指,對於訪問同一個狀態的所有操作(包括該操作本身)來說,這個操作是一個以原子方式執行的操作。

複合操作是指,包含了一組必須以原子方式執行的操作以確保執行緒安全性。比如“先檢查後執行”,“讀取-修改-寫入”等操作統稱為複合操作。

如何確保原子性?

  • 使用一個現有的執行緒安全類:java.util.concurrent.atomic包中就包含了一些原子變數類。當在無狀態的類中新增一個狀態時,如果該狀態完全由執行緒安全的物件來管理,那麼這個類仍然是執行緒安全的。然而,當狀態變數的數量由一個變為多個時,並不會像狀態變數由零個變為一個那樣簡單。。。

    為何?見下例

    線上程安全性的定義中要求,多個執行緒之間的操作無論採用何種執行時序或交替方式,都要保證不變性條件不被破壞。當在不變性條件中涉及多個變數時,各個變數之間並不是彼此獨立的,而是某個變數的值會對其他變數的值產生約束。因此,當更新某一個變數時,需要在同一個原子操作中對其他變數同時進行更新。

    @NotThreadSafe
    public class UnsafeCachingFactorizer implements Servlet {
        private final AtomicReference<BigInteger> lastNumber
          = new AtomicReference<BigInteger>();
        private final AtomicReference<BigInteger[]> lastFactors
          = new AtomicReference<BigInteger[]>;
          
        public void service(ServletRequest req, ServletResponse resp) {
            BigInteger i = extractFromRequest(req);
            if (i.equals(lastNumber.get())) {
                encodeIntoResponse(resp, lastFactors.get());
            } else {
                BigInteger[] factors = factor(i);
                // 儘管這裡對set方法的每次呼叫都是原子的,但仍然無法同時更新lastNumber和lastFactors這兩個
                // 狀態變數,而它們之間存在值的約束關係.而且也不能保證會同時獲取這兩個值。
                lastNumber.set(i);
                lastFactors.set(factors);
                encodeIntoResponse(resp, factors);
            }
        }
    }
    複製程式碼
  • 加鎖機制:Java提供了一種內建的鎖機制來支援原子性:同步程式碼塊(Synchronized Block).

    同步程式碼塊包括兩部分,一個是作為鎖的物件引用,一個作為由鎖保護的程式碼塊。以關鍵字synchronized來修飾的方法就是一種橫跨整個方法體的同步程式碼塊,其中該同步程式碼塊的就是方法呼叫所在的物件。靜態的synchronized方法以Class物件作為

    每個Java物件都可以用做一個實現同步的鎖,這些鎖被稱為內建鎖或監視器鎖,它們相當於一種互斥體或互斥鎖,這意味著最多隻有一個執行緒能持有這種鎖。所以每次只能有一個執行緒執行內建鎖保護的程式碼塊,所以由這個鎖保護的同步程式碼塊是以原子方式執行的。而獲得鎖的唯一途徑就是進入由這個鎖保護的同步程式碼塊或方法。

    併發環境中的原子性與事務應用程式中的原子性有著相同的含義,即一組語句作為一個不可分割的單子被執行

重入:內建鎖是可以重入的。如果某個執行緒試圖獲得一個已經由它自己持有的鎖,那麼這個請求就會成功,而不會阻塞。重入進一步提升了加鎖行為的封裝性。

重入意味著獲取鎖的操作的粒度是“執行緒”,而不是呼叫。一種實現方法就是為每個鎖關聯一個獲取計數值和一個所有者執行緒。當鎖不被任何執行緒持有時,計數值為0;當執行緒請求一個未被持有的鎖時,JVM將記下鎖的持有者,並將獲取計數值置為1;如果同一個執行緒再次獲取這個鎖,計數值將遞增;而當執行緒退出同步程式碼塊時,計數值會相應地遞減;直到計數值為0時,鎖被釋放。

用鎖來保護狀態

訪問共享狀態的複合操作都必須是原子操作,以避免產生競態條件。如果在複合操作的執行過程中持有一個鎖,那麼該複合操作就是源自操作。然而僅僅將複合操作封裝到一個同步程式碼塊中是不夠的。如果用同步來協調對某個變數的訪問,那麼在訪問這個變數的所有位置上都需要使用同步,而且當使用鎖來協調對某個變數的訪問時,在訪問變數的所有位置上都需要使用同一個鎖。

對於可能被多個執行緒同時訪問的可變狀態變數,在訪問它時都需要持有同一個鎖,在這種情況下,我們稱狀態變數是由這個鎖保護的。 每個共享的和可變的變數都應該只由一個鎖來保護。

由於鎖能使其保護的程式碼路徑以序列形式來訪問,因此可通過鎖來構造一些協議以實現對共享狀態的獨佔訪問。只要始終遵循這些協議,就能確保狀態的一致性。

一種常見的加鎖約定是,將所有的可變狀態都封裝在物件內部,並通過物件的內建鎖對所有訪問可變狀態的程式碼路徑進行同步,使得在該物件上不會發生併發訪問。然而如果在新增新的方法或者程式碼路徑時忘記了使用同步,那麼這種加鎖協議就會被破壞。

:物件的內建鎖與其狀態之間沒有內在的關聯。雖然大多數類都將內建鎖用做一種有效的加鎖機制,但物件的域並不一定要通過內建鎖來保護。當獲取與物件關聯的鎖時,並不能阻止其他執行緒訪問該物件;某個執行緒在獲得物件的鎖之後,只能阻止其他執行緒獲取同一個鎖。之所以每個物件都有一個內建鎖,只是為了免去顯式地建立鎖物件。

並非所有的資料都需要鎖的保護,只有被多個執行緒同時訪問的可變資料才需要通過鎖來保護。當某個變數由鎖來保護時,意味著在每次訪問這個變數時都必須首先獲得鎖,這樣就可確保在同一時刻只有一個執行緒可以訪問這個變數。

當類的不變性涉及多個狀態變數時,那麼還有另外一個需求:在不變性條件中的每個變數都必須由同一個鎖來保護。所以可以在單個原子操作中訪問或更新這些變數,從而確保不變性條件不被破壞。對於每個包含多個變數的不變性條件,其中涉及的所有變數都需要由同一個鎖來保護。

同步可避免競態條件問題,為何不在每個方法宣告上都使用synchronized?不加區別地濫用synchronized會怎樣?

  • 可能導致程式中出現過多的同步。

  • 如果只是將每個方法都作為同步方法,其實並不足以確保複合操作都是原子的。

    //雖然contains和add方法都是原子方法,但是該複合操作“如果不存在則新增”中仍然存在競態條件。
    if(!vector.contains(element)) {
        vector.add(element);
    }
    複製程式碼
  • 如果將每個方法都作為同步方法,還可能導致活躍性問題或效能問題。

所以雖然synchronized方法可以確保單個操作的原子性,但是如果要把多個操作合併為一個複合操作,還是需要額外的加鎖機制。

總結

要確保同步程式碼塊不要過小,並且不要將本應是原子的操作拆分到多個同步程式碼塊中。應該儘量將不影響共享狀態且執行時間較長的操作從同步程式碼塊中分離出去,從而在這些操作的執行過程中,其他執行緒可以訪問共享狀態。要判斷同步程式碼塊的合理大小,需要在各種設計需求之間進行權衡,包括安全性(該需求必須滿足)、簡單性和效能。有時候在簡單性和效能之間會發生衝突,但是通常在二者之間能夠找到某種平衡。

簡單性可粗略理解為對整個方法進行同步;效能可粗略理解為併發性,對儘可能短的程式碼路徑進行同步。見下例。

@ThreadSafe
public class CachedFactorizer implements Servlet {
    private BigInteger lastNumber;
    private BigInteger[] lastFactors;
    //此處並未使用原子變數AtomicLong, 因為已經使用了同步程式碼塊來構造原子操作。若使用兩種不同的同步機制會帶來混亂。
    private long hits;
    private long cacheHits;
    
    public synchronized long getHits() {
        return hits;
    }
    public synchronized double getCacheHitRatio() {
        return (double) cacheHits / (double) hits;
    }
    
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = null;
        //此處併發,並沒有分得過細,比如遞增操作分解到另一個同步程式碼塊中。
        synchronized (this) {
            ++hits;
            if (i.equals(lastNumber)) {
                ++cacheHits;
                factors = lastFactors.clone();
            }
        }
        if (factors == null) {
            //該執行時間較長的操作沒有持有鎖,從而不會過多地影響併發性。
            factors = factor(i);
            //此處併發。
            synchronized (this) {
                lastNumber = i;
                lastFactors = factors.clone();
            }
        }
        encodeIntoResponse(resp, factors);
    }
}
複製程式碼

相關文章