chapter02 執行緒安全性

語言: CN / TW / HK

在構建穩定的併發程式時,必須正確的使用執行緒和鎖。但這些只是一些機制,要編寫執行緒安全的程式碼,核心在於要對狀態訪問操作進行管理,特別是對共享的(shared)和可變的(mutable)狀態的訪問

從非正式意義上來說,物件的狀態指儲存在狀態變數(例項或靜態域)中的資料,物件的狀態可能包括其他依賴物件的域。在物件的狀態中包含了任何可能影響其他外部可見行為的資料(可以認為物件的屬性就是它的狀態)。

共享意味著變數可以由多個執行緒同時訪問,可變意味著變數的值在其生命週期內可以發生變化。

一個物件是否需要時執行緒安全的,取決於他是否被多個執行緒訪問。要使物件是執行緒安全的,需要採用同步機制來協同物件可變狀態的訪問,如果無法實現協同,那麼可能會導致資料破壞以及其他不該出現的結果。

當多個執行緒訪問某個狀態變數並且其中有一個執行緒執行寫入操作時,必須採用同步機制來協同這些執行緒對變數的訪問。java中的主要同步機制是關鍵字 synchronized,它提供了一種獨佔加鎖的方式,同步還包括volatile 變數、顯示鎖以及原子變數。

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

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

如果在設計類的時候沒有考慮併發訪問的情況,那麼後續的維護可謂知易行難,如果一開始就設計一個執行緒安全的類,那麼在以後再將這個類修改為執行緒安全的類要容易的多。

    訪問某個變數的程式碼越少,就越容易確保對變數的所有訪問都實現正確同步,同時也更容易找出變數在哪些條件下被訪問。程式的封裝性越好,就越容易實現程式的執行緒安全性。

當設計執行緒安全的類時,良好的面向物件技術、不可修改性,以及明晰的不變性規範都能起到一定的幫助作用。

在某些情況中,良好的面向物件設計技術與實際情況的需求並不一致,在這些情況中,可能需要犧牲一些良好的設計原則,以換取效能或者對遺留程式碼的向後相容。

1 什麼是執行緒安全性

線上程安全性的定義中,最核心的概念是正確性。其含義是,某個類的行為與其規範完全一致,在良好的規範中通常會定義各種不變性條件來約束物件的狀態,以及定義各種後驗條件來描述物件操作的結果。因此,從正確性的角度定義執行緒安全性:當多執行緒訪問某個類時,這個類始終都能表現出正確的行為,那麼就稱這個類是執行緒安全的

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

線上程安全類中封裝了必要的同步機制,因此客戶端無需進一步採取同步措施。

一個無狀態的Servlet

public class StatelessFactorizer implements Servlet {


    public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
        System.out.println("do some thing");
    }

    public void init(ServletConfig servletConfig) throws ServletException {

    }

    public ServletConfig getServletConfig() {
        return null;
    }


    public String getServletInfo() {
        return null;
    }

    public void destroy() {

    }
}

StatelessFactorizer是無狀態的,它不包含任何域也不包含任何其他類中域的引用。計算過程中的臨時狀態僅存線上程棧上的區域性變數中,並且只能由正在執行的執行緒訪問。訪問StatelessFactorizer的執行緒不會影響另一個訪問同一個StatelessFactorizer執行緒的計算結果,因為這兩個執行緒沒有共享狀態,就好像它們都在訪問不同的例項,由於執行緒訪問無狀態的行為並不會影響其他執行緒中操作的正確性。

無狀態的物件一定是執行緒安全的。

2 原子性

public class UnsafeCountingFactorizer implements Servlet {

    private long count = 0;
    public void init(ServletConfig servletConfig) throws ServletException {
        count++;
    }
}

count++操作並非原子的,因為它並不會作為一個不可分割的操作來執行,它包含了三個獨立的操作:讀取count值,加1,然後將計算結果寫入count。多執行緒在沒有同步機制的情況下讀取到count值時,可能讀取到相同的count值,這個計算就失去準確性。

在併發程式設計中,由於不恰當執行時序而出現不正確的結果是一種非常重要的情況,這種情況也被稱之為:競態條件(Race Condition)。

2.1 競態條件

當某個計算的正確性取決於多個執行緒的交替執行時序時,那麼就會發生競態條件。

最常見的競態條件型別是“先檢查後執行”,即通過一個可能失敗的觀測結果來決定下一步的動作。

2.2 示例:延遲初始化中的競態條件

public class LazyInitRace {

    private LazyInitRace instance = null;

    public LazyInitRace getInstance(){
        if(instance == null){
            instance = new LazyInitRace();
        }
        return instance;
    }
}

對於getInstance,就存在競態條件。A執行緒和B執行緒同時訪問該方法時,A執行緒執行new操作沒完成,B執行緒也會進來執行new操作,相當於這個new操作有可能執行2次。

競態條件並不總是會產生錯誤,還需要某種不恰當的執行時序。

2.3 複合操作

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

假設有兩個操作A和B,如果從執行A的執行緒來看,當另一個執行緒執行B時,要麼將B全部執行完,要麼完全不執行B,那麼A和B對彼此來說是原子的。原子操作是指,對於訪問同一個狀態的所有操作,這個操作是一個原子的方式執行的操作。

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

public class SafeCount {

    private final AtomicInteger integer = new AtomicInteger(0);

    public void c(){
        integer.incrementAndGet();
    }
}

使用AtomicInteger系列原子變數保證計算操作的原子性,對於SafeCount,它的執行緒狀態取決於integer的執行緒狀態,因此它是一個執行緒安全類。在一個無狀態的類中新增一個狀態時,如果該狀態完全由執行緒安全的物件來管理,那麼這個類仍然是執行緒安全的。

在實際情況中,應儘可能地使用現有的執行緒安全物件來管理類的狀態。與非執行緒安全的物件相比,判斷執行緒安全物件可能狀態及其狀態轉換情況要更為容易,從而也更容易維護和驗證過執行緒安全性。

3 加鎖機制

對於包含多個狀態的類,即使這些類時原子性的,如果存在不恰當的競態條件,這個類仍然是執行緒不安全的。

public class UnSafeCachingFactorizer implements Servlet {

    private final AtomicReference<BigInteger> last = new AtomicReference<BigInteger>();
    private final AtomicReference<BigInteger[]> lastFactors = new AtomicReference<BigInteger[]>();

    public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
        BigInteger i = (BigInteger) servletRequest;
        if(last.get().equals(i)){

        }else {
            last.set(i);
            lastFactors.set(new BigInteger[]{});
        }
    }
}

對於原子類來說,set操作是原子的,但是對於lastFactors來說,他的set取決於last.get的判斷,這種競態條件是非原子的。

要保持狀態的一致性,就需要在單個原子操作中更新所有相關的狀態變數。

3.1 內建鎖

java使用內建的鎖機制來支援原子性,同步程式碼塊(synchronized block),同步程式碼塊包含兩個部分:一個是作為鎖的物件引用,一個座位由這個鎖保護的程式碼塊,以關鍵字 synchronized修飾的方法就是一種橫跨整個方法體的同步程式碼塊,其中該同步程式碼塊的鎖就是方法呼叫所在的物件,靜態的synchronized方法以Class物件為鎖。

synchronized(lock){
    //訪問或修改由所保護的共享狀態
}

每個Java物件都可以用做一個實現同步的鎖,這個鎖被稱之為內建鎖(intrinsic lock)或監視鎖(monitor lock),執行緒在進入同步程式碼塊之前會自動獲取鎖,在退出同步程式碼快後自動釋放鎖,無論是正常退出還是異常退出,獲取內建鎖的唯一方式就是進入鎖保護的同步程式碼塊或方法。

Java的內建鎖相當於一種互斥體(互斥鎖),這意味著最多隻有一個執行緒能持有這種鎖,當xianchengA嘗試獲取一個由B執行緒持有的鎖時,執行緒A必須等待或者阻塞,直到執行緒B釋放這個鎖,如果B永遠不釋放鎖,那麼執行緒A也將永遠等下去。

由於每次只能由一個執行緒執行內建鎖保護的程式碼塊,因此,這個鎖保護的程式碼塊會以原子的方式執行,多個執行緒在執行該程式碼塊時也不會互相干擾。併發環境中的原子性與實務應用程式中的原子性有相同的含義——一組語句作為一個不可分割的單元被執行。任何一個執行同步程式碼塊的執行緒,都不可能看到其他執行緒正在執行由同一個鎖保護的同步程式碼塊。

public class SynchronizedFactorizer implements Servlet {

    private BigInteger lastNumber;

    public synchronized void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
        lastNumber = new BigInteger("10");
    }
}

對於synchronized修飾方法時,執行緒安全形度考慮這個方法時安全的,但假設方法時間執行過長,其他執行緒無法執行,效能角度考慮很糟糕。

3.2 重入

當某個執行緒請求一個由其他執行緒持有的鎖時,發出的請求就會阻塞,由於內建鎖是可以重入的,因此如果某個執行緒試圖獲取一個已經由他自己持有的鎖,那麼這個請求就會成功。“重入”意味著獲取鎖的操作粒度時執行緒而不是呼叫。

public class Widget {

    public synchronized void doSomething(){}
}

class LoggingWidget extends Widget{
    public synchronized void doSomething(){
        super.doSomething();
    }
}

由於Widget 和 LoggingWidget 的 doSomething方法都是synchronized修飾的,呼叫doSometing時都會獲取Widget的鎖,那麼在執行super.doSometing時,若沒有重入機制就會產生死鎖。

4 用鎖來保護狀態

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

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

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

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

每個共享的和可變的變數都應該只有一個鎖來保護,從而使維護人員知道是哪一個鎖。

一種常見的加鎖約定是,將所有的可變狀態都封裝在物件內部,並通過物件的內建鎖對所有訪問可變狀態的程式碼路徑上進行同步,使得在該物件上不會發生併發訪問,例如Vector類。在這種情況下,物件狀態中的所有變數都是由物件的內建鎖保護起來,但是,如果再新增新的方法或者程式碼路徑時忘了使用同步,那麼這種加鎖協議會很容易被破壞。

並非所有的資料都需要保護,只有被多個執行緒同時訪問的可變資料才需要通過鎖來保護

濫用synchronized會造成效能問題,並且在上層呼叫並不能確保不發生競態條件。

對於每個包含多個變數的不變性條件,其中涉及的所有變數都需要由同一個鎖來保護。

5 活躍性與效能

synchronized加到方法時,如果方法執行時間過長,其他執行緒就進入不了這個方法,形成了整體阻塞,對於web應用來說,這將明顯降低介面的吞吐量。確定synchronized的範圍有利於保證程式的併發性與執行緒安全性,同步程式碼快不要過小,並且不要將本應是原子操作拆分到多個同步程式碼塊中,應該儘量將不影響共享狀態且執行時間較長的操作從同步程式碼塊中分離出去,從而這些操作的執行過程中,其他執行緒可以訪問共享狀態。

public class CachedFactorizer implements Servlet {

    private BigInteger lastNumber;

    private BigInteger[] lastFactors;

    private long hits;

    private long cacheHits;

    public synchronized long getHits() {
        return hits;
    }

    public synchronized double getCacheHitRatio() {
        return (double) cacheHits / hits;
    }

    public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
        BigInteger i = extractFromRequest(servletRequest);
        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();
            }
        }
    }
}

synchronized程式碼塊儘量僅維護狀態的原子操作。

要判斷同步程式碼塊的合理大小,需要在各種設計需求之間進行權衡,包括安全性、簡單性和效能,有時候,咋簡單性與效能之間會發生衝突。

通常,在簡單性與效能之間存在著相互制約的因素,當實現某個同步策略時,一定不要盲目地為了效能而犧牲簡單性。

當執行時間較長的計算或者可能無法快速完成的操作時(例如網路io或控制檯io),一定不要持有鎖。

總結