JAVA 併發之路 (二) 線程安全性

對象的狀態

對象的狀態是指存儲在狀態變量(例如實例域,靜態域)中的數據,還可能包括其餘依賴對象的域。對象中的域的值的集合描述着當前特徵的信息,這就是對象的狀態。在對象的狀態中包含了任何可能影響其外部可見行爲的數據。java

要編寫線程安全的代碼,其核心在於要對狀態訪問操做進行管理,特別是對共享的可變的狀態的訪問。「共享」意味着變量能夠被多個線程同時訪問;「可變」意味着變量的值在其生命週期內能夠發生變化。編程

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

Java中的主要同步機制是關鍵字synchronized,它提供了一種獨佔的加鎖方式。可是「同步」這個術語還包括volatile類型的變量,顯式鎖以及原子變量。bash

若是當多個線程訪問同一個可變的狀態變量時沒有使用合適的同步,那麼程序就會出現錯誤。有三種方式可修復這個問題:併發

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

應當始終遵循的原則

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

線程安全性

線程安全性是一個在代碼上使用的術語,但它只是與狀態相關的,所以只能應用於封裝其狀態的整個代碼,這多是一個對象,也多是整個程序。測試

在線程安全性的定義中,最核心的概念就是正確性。正確性的含義是:某各種的行爲與其規範徹底一致。在良好的規範中一般會定義各類不變性條件來約束對象的狀態,以及定義各類後驗條件來描述對象操做的結果優化

線程安全性當多個線程訪問某個類時,無論運行時環境採用何種調度方式或者這些線程將如何交替執行,而且在主調代碼中不須要任何額外的同步或協同,這個類都能表現出正確的行爲,那麼就稱這個類是線程安全的。ui

也能夠將線程安全類認爲是一個在單線程環境和併發環境都不會被破壞的類。若是正確地實現了某個對象,那麼在任何操做中都不會違背不變性條件或後驗條件。在線程安全類的對象實例上執行的任何串行或並行操做都不會使對象處於無效狀態。this

無狀態對象必定是線程安全的。由於不存在任何域,也不包含任何對其餘類中域的引用。計算過程當中的臨時狀態僅僅存在於線程棧上的局部變量中,而且只能由正在執行的線程訪問。

競態條件: 在併發編程中,因爲不恰當的執行時序而出現不正確的結果是一種很是重要的狀況。當某個計算的正確性取決於多個線程的交替執行時序時,就會發生競態條件。

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

要避免競態條件問題,就必須在某個線程修改變量時,經過某種方式防止其餘線程使用這個變量,從而確保其餘線程只能在修改操做完成以前或以後讀取和修改狀態,而不是在修改狀態的過程當中。

複合操做

原子操做是指,對於訪問同一個狀態的全部操做(包括該操做自己)來講,這個操做是一個以原子方式執行的操做。

複合操做是指,包含了一組必須以原子方式執行的操做以確保線程安全性。好比「先檢查後執行」,「讀取-修改-寫入」等操做統稱爲複合操做。

如何確保原子性?

  • 使用一個現有的線程安全類: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);
    }
}
複製代碼
相關文章
相關標籤/搜索