《Java併發程式設計實戰》 第二章:執行緒安全性

P城到底誰說的算發表於2019-04-16

思維導圖:

《Java併發程式設計實戰》 第二章:執行緒安全性

0. 前言

執行緒或者鎖在併發程式設計中的作用,類似於鉚釘與工字樑在土木工程中的作用。構建穩健的併發程式,必須正確的使用執行緒和鎖。其核心在於要對狀態訪問操作進行管理,特別是對共享的和可變的狀態的訪問

從非正式的意義上說,物件的狀態指儲存在狀態變數(例如例項或靜態域)中的資料。物件的狀態可能包括其他
依賴物件的域。例如,某個hashmap的狀態不僅儲存在物件本身,還儲存在許多map.entry物件中。

“共享” 意味著變數可以有多個執行緒同時訪問,而 “可變” 則意味著變數的值在生命週期可以發生變化。
複製程式碼

當多個執行緒訪問某個狀態變數並且其中有一個執行緒執行寫入操作時,必須採用同步機制來協調這些執行緒對變數的訪問。

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

* 不線上程之間共享該狀態變數
* 將狀態變數修改為不可變的變數
* 在訪問狀態變數時使用同步
複製程式碼

1. 什麼是執行緒安全性

線上程安全性的定義中,最核心的概念就是正確性。正確性的含義是,某個類的行為與其規範完全一致。因此就可以定義執行緒安全性:當多個執行緒訪問某個類時,這個類始終都能表現出正確的行為,那麼就稱這個類是執行緒安全的。

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

示例:一個無狀態的servlet 程式2-1給出了一個簡單的因數分解servlet。這個servlet從請求中提取出數值,執行因數分解,然後將結果封裝到servlet的響應中。

《Java併發程式設計實戰》 第二章:執行緒安全性

與大多數servlet相同,StatelessFactorizer無狀態的:它既不包含任何域,也不包含任何對其他類中域的引用。 由於執行緒訪問無狀態物件的行為並不會影響其他執行緒操作的正確性,因此無狀態物件是執行緒安全的。

2. 原子性

當我們在無狀態物件中增加一個狀態時,會出現什麼情況?假設我們想增加一個“命中計數器”來統計所處理的請求數量。一種直觀的方法是在servlet中增加一個long型別的域,並且每處理一個請求就將這個值加1,如程式2-2中:

《Java併發程式設計實戰》 第二章:執行緒安全性

這樣,這個類就不是執行緒安全的了。雖然遞增操作++count是一種緊湊的語法,使其看上去只是一個操作,但這個操作並非原子的,因而他並不會作為一個不可分割的操作來執行。實際上,包含了三個獨立的操作:讀取count,加1,然後將結果寫入count。這是一個“讀取-修改-寫入”的操作序列,並且其結果狀態依賴於之前的狀態。 此時,當兩個執行緒在沒有同步的情況下對這個計數器進行遞增操作時,如果計數器初始值為9,那麼某些情況下,每個執行緒讀到的都是9,接著執行遞增操作,並且都將計數器的值設為10。顯然,這種情況丟失了一次遞增操作。

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

2.1 競態條件

當某個計算的準確性取決於多個執行緒的交替執行時序時,那麼就會發生競態條件。換句話說,就是正確的結果取決於運氣。最常見的競態條件型別就是“先檢查後執行”操作,即通過一個可能失效的觀測結果來決定下一步動作。

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

延遲初始化的目的是將物件的初始化操作推遲到實際被使用時才進行,同時要確保只被初始化一次。在程式2-3中lazyInitRace說明了這種延遲初始化情況。

《Java併發程式設計實戰》 第二章:執行緒安全性
在此類中包含了一個競態條件,他可能會破壞這個類的正確性。假設執行緒A和執行緒B同時執行getInstance。A看到instance為空,因而建立一個新的例項。B同樣需要判斷instance是否為空。此時的instance是否為空,要取決於不可預測的時序,包括執行緒的排程方式,以及A需要花多長時間來初始化ExpensiveObject並設定instance。如果當B檢查時,instance為空,那麼在兩次呼叫getInstance時可能會得到不同的結果,即使getInstance通常被認為是返回相同的例項。

2.3 複合操作

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

假定有兩個操作A和B,如果從執行A的執行緒來看,當另一個執行緒執行B時,要麼B全部執行完,要不完全不執行B,
那麼A和B對彼此來說是原子的。
原子操作是指,對於訪問同一個狀態的所有操作(包括該操作本身)來說,這個操作是一個以原子方式執行的操作。
複製程式碼

解決複合操作,可以使用加鎖機制,將在下一小節介紹。目前使用另一種方式來修復這個問題,即使用一個現有的執行緒安全類,如程式2-4:

《Java併發程式設計實戰》 第二章:執行緒安全性

通過用AtomicLong來代替long型別的計數器,能夠確保所有對計數器狀態的訪問都是原子的。

3. 加鎖機制

假設希望提升servlet的效能:將最近的計算結果快取出來,當兩個連續的請求對相同的數值進行因數分解時,可以直接使用上一次的計算結果。要實現該快取策略,需要儲存兩個狀態:最近執行過因數分解的數值,以及結果。

我們嘗試用新增執行緒安全狀態變數來完成這件事,UnsafeCachingFactorizer的程式碼為:

//    2-5  該Servlet在沒有足夠原子性保證的情況下對最近計算結果進行快取(不要這麼做)
@NotThreadSafe
public class UnsafeCachingFactorizer extends GenericServlet implements Servlet {
    //AtomicReference是作用是對"物件"進行原子操作
    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);
            lastNumber.set(i);
            lastFactors.set(factors);
            encodeIntoResponse(resp, factors);
        }
    }

    void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {//儲存執行過因數分解的數值及其結果
    }

    BigInteger extractFromRequest(ServletRequest req) {  
        return new BigInteger("7");
    }

    BigInteger[] factor(BigInteger i) {
        return new BigInteger[]{i};
    }
}

複製程式碼

然而,儘管這些原子引用本身各自都是執行緒安全的,但在UnsafeCachingFactorizer中存在著競態條件,這可能導致錯誤。

線上程安全性的定義中要求,多個執行緒之間的操作無論採用何種執行順序或交替方式,都要保證不變性條件不被破壞。UnsafeCachingFactorizer的不變性條件之一是:在lastFactors中快取的因數之積應該等於在lastNumber中快取的數值。只有確保了這個不變性條件不被破壞,上面的Servlet才是正確的。當在不變性條件中涉及多個變數時,各個變數之間並不是彼此獨立的,而是某個變數的值會對其他變數的值產生約束。因此,在更新某一個變數時,需要在同一個原子操作中隊其他變數同時進行更新。

在使用AtomicReference的情況下,儘管對set方法的每次呼叫都是原子的,但仍然無法同時更新lastNumberlastFactors。如果只修改了其中一個變數,那麼在這兩次修改操作之間,其他執行緒將發現不變性條件被破環了。同樣,我們也不能確保會同時獲取兩個值:執行緒A獲取這兩個值得過程中,執行緒B可能修改了它們,這樣執行緒A也會發現不變性條件被破壞了。

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

3.1 內建鎖

Java提供一種內建的鎖機制來支援原子性: 同步程式碼塊(Synchronized Block)

同步程式碼塊包括兩部分:一個作為鎖的物件引用,一個作為這個鎖保護的程式碼塊。

以關鍵字synchronized(同步的)來修飾的方法就是一種橫跨整個方法體的同步程式碼塊,其中該同步程式碼塊的鎖就是方法呼叫所在的物件。靜態的synchronized方法以Class物件作為鎖。

synchronized(lock){ 
    //訪問或修改由鎖保護的共享狀態 
}
複製程式碼

每個Java物件都可以用做一個實現同步的鎖, 這些鎖被稱為內建鎖(Intrinsic Lock)或者監視鎖(Monitor Lock)。執行緒在進入程式碼塊之前會自動獲得鎖,並且在退出同步程式碼塊時自動釋放鎖,無論是通過正常路徑退出還是通過從程式碼塊中丟擲異常退出。獲得內建鎖的唯一路徑就是進入由這個鎖保護的同步程式碼塊或方法。

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

由於每次只能有一個執行緒執行內建鎖保護的程式碼塊,因此,由這個鎖保護的同步程式碼塊會以原子方式執行,多個執行緒在執行該程式碼塊也不會相互干擾。

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

下面我們使用synchronized關鍵字來改進:

//   2-6    這個Servlet能正確快取最新的計算結果,但併發性卻非常糟糕(不要這麼做)
@ThreadSafe
public class SynchronizedFactorizer extends GenericServlet implements Servlet {
    @GuardedBy("this") private BigInteger lastNumber;
    @GuardedBy("this") private BigInteger[] lastFactors;

    public synchronized void service(ServletRequest req,
                                     ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        if (i.equals(lastNumber))
            encodeIntoResponse(resp, lastFactors);
        else {
            BigInteger[] factors = factor(i);
            lastNumber = i;
            lastFactors = factors;
            encodeIntoResponse(resp, factors);
        }
    }
}
複製程式碼

儘管SynchronizedFactorizer是執行緒安全,然而這種方法卻過於極端,因為多個客戶端無法同時使用因數分解Servlet,服務的響應性非常低。

3.2 重入

內建鎖是可重入的,如果某個執行緒試圖獲得一個已經由它持有的鎖,那麼這個請求就會成功。”重入“獲取鎖操作的基本單位是“執行緒”而不是“呼叫”。

重入的一種實現方法是,為每個鎖關聯一個獲取計數值和一個所有者執行緒。當計數值為0時,這個鎖就被認為是沒有被任何執行緒持有。當執行緒請求一個未被持有的鎖時,JVM將記下鎖的持有者,並將獲取值設定為1,如果同一執行緒再次獲取這個鎖,計數值遞增,而當執行緒退出同步程式碼塊時,計數器會相應地遞減,當計數值為0時,這個鎖將被釋放。

“重入”進一步提升了加鎖行為的封裝性(encapsulation),因此簡化了物件導向(Object-Oriented)併發程式碼的開發。
複製程式碼

在以下程式碼中,子類改寫了synchronized修飾的方法,然後呼叫父類中方法,如果沒有可重入的時,這段程式碼將產生死鎖。由於子類和父類的doSomething方法都是synchronized方法,因此每個doSomething方法在執行前都會獲取Widget上的鎖。如果內建鎖是不可重入,那麼在呼叫super.doSomething時將無法獲得Widget上的鎖,因為這個鎖已經被持有,從而執行緒將永遠停頓下去。重入避免了這種死鎖情況的發生。

// 2-7 如果內建鎖不是可重入的,這段程式碼將發生死鎖
public class Widget {
    public synchronized void doSomething() {
...
    }
}
public class LoggingWidget extends Widget {
    public synchronized void doSomething() {
         System.out.println(toString() + ": calling doSomething");
         super.doSomething();
    }
}

複製程式碼

4. 用鎖來保護狀態

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

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

上面的SynchronizedFactorizer(實現了Servlet介面)中,lastNumberlastFactors這兩個變數都是由Servlet物件的內建鎖來保護的。

物件的內建鎖與其狀態之間沒有內在的關聯。雖然大多數類都將內建鎖用做一種有效的加鎖機制,對物件的域並一定要通過內建鎖來保護。當獲取與物件關聯的鎖時,並不能阻止其他執行緒訪問該物件。某個執行緒在獲得物件的鎖之後,只能阻止其他執行緒獲得同一個鎖。之所以每個物件都有一個內建鎖,是為了免去顯式地建立鎖物件。需自行構造加鎖協議或同步策略來實現對共享狀態的安全訪問,並且在程式中一直使用它們。

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

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

只有被多個執行緒同時訪問的可變資料才需要通過鎖來保護,單執行緒程式不需要同步。

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

不加區別地濫用synchronized,可能導致程式中出現過度同步。此外即使將每個方法都作為同步方法,在某些操作中仍然存在競態條件。還會導致活躍性問題(Liveness)或效能問題(Performance)。

5. 活躍性(Liveness)和效能(Performance)

SynchronizedFactorizer中,通過Servlet物件的內建鎖來保護每一個狀態變數,該策略的實現方式也就是對整個service方法進行同步。雖然這種簡單且粗魯的方法能確保執行緒安全,但代價卻很高。

Servlet需要能同時處理多個請求,SynchronizedFactorizer違背了這個初衷。其他客戶端必須等待Servlet處理完當前的請求,才能開始新的因數分解運算。這浪費了很多時間和減低了CPU的使用率。

下圖給出了當多個請求同時達到因數分解Servlet時發生的情況:這些請求將排隊等待處理。我們將這種Web應用程式稱為不良併發(Poor Concurrency)應用程式: 可同時呼叫的數量,不僅受到可用處理資源的限制,還受到應用程式本身結構的限制。

《Java併發程式設計實戰》 第二章:執行緒安全性

通過縮小同步程式碼塊的作用範圍,我們很容易做到既確保Servlet的併發性,同時又維護執行緒安全性。CachedFactorizerServlet的程式碼修改為使用兩個獨立的同步程式碼塊,一個同步程式碼塊負責保護判斷是否只需返回快取結構的”先檢查後執行”操作序列,另一個同步程式碼塊負責確保對快取的數值和因數分解結果進行同步更新。此外我們還引入了“命中計數器”,新增了“快取命中”計數器,並在第一個同步程式碼塊中更新這兩個變數。由於這兩個計數器也是共享可變狀態的一部分,因此必須在所有訪問它們的位置都使用同步。位於同步程式碼塊之外的程式碼將以獨佔方式來訪問區域性(位於棧上的)變數,這些變數不會在多個執行緒貢獻,因此不需要同步。

//快取最近執行因數分解的數值以及其計算結果的Servlet
@ThreadSafe
public class CachedFactorizer extends GenericServlet implements Servlet {
    @GuardedBy("this") private BigInteger lastNumber;
    @GuardedBy("this") private BigInteger[] lastFactors;
    @GuardedBy("this") private long hits;
    @GuardedBy("this") 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();//clone()會複製物件。所謂的複製物件,首先要分配一個和源物件同樣大小的空間,在這個空間中建立一個新的物件。
            }
        }
        if (factors == null) {
            factors = factor(i);
            synchronized (this) {       //負責確保對快取的數值和因數分解結果進行同步更新。
                lastNumber = i;
                lastFactors = factors.clone();
            }
        }
        encodeIntoResponse(resp, factors);
    }

    void encodeIntoResponse(ServletResponse resp, BigInteger[] factors) {
    }

    BigInteger extractFromRequest(ServletRequest req) {
        return new BigInteger("7");
    }

    BigInteger[] factor(BigInteger i) {
        return new BigInteger[]{i};
    }
}
複製程式碼

這裡沒有使用AtomicLong型別的命中計數器,而是使用long型別。對單個變數上實現原子操作來說,原子變數是很有用,但我們已經使用了同步程式碼塊來構造原子操作,而使用兩種不同的同步機制不僅會帶來混亂,也不會在效能或安全性上帶來任何好處,所以這裡不使用原子變數。

CachedFactorizerSynchronizedFactorizer相比,實現了簡單性(對整個方法進行同步)與併發性(對儘可能短的程式碼路徑進行同步)之間的平衡。在獲取與釋放鎖等操作上都需要一定開銷,如果同步程式碼塊分得太細(例如將++this分解為一個同步程式碼塊),那樣通常不好。

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

當執行時間較長的計算或者無法快速完成的操作時(例如,網路I/O或控制檯I/O),一定不要持有鎖。
複製程式碼

相關文章