《Java併發編程實戰》 第二章:線程安全性

思惟導圖:編程

0. 前言

線程或者鎖在併發編程中的做用,相似於鉚釘與工字梁在土木工程中的做用。構建穩健的併發程序,必須正確的使用線程和鎖。其核心在於要對狀態訪問操做進行管理,特別是對共享的和可變的狀態的訪問緩存

從非正式的意義上說,對象的狀態指存儲在狀態變量(例如實例或靜態域)中的數據。對象的狀態可能包括其餘
依賴對象的域。例如,某個hashmap的狀態不只存儲在對象自己,還存儲在許多map.entry對象中。

「共享」 意味着變量能夠有多個線程同時訪問,而 「可變」 則意味着變量的值在生命週期能夠發生變化。
複製代碼

當多個線程訪問某個狀態變量而且其中有一個線程執行寫入操做時,必須採用同步機制來協調這些線程對變量的訪問。安全

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

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

1. 什麼是線程安全性

在線程安全性的定義中,最核心的概念就是正確性。正確性的含義是,某個類的行爲與其規範徹底一致。所以就能夠定義線程安全性:當多個線程訪問某個類時,這個類始終都能表現出正確的行爲,那麼就稱這個類是線程安全的。bash

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

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

與大多數servlet相同,StatelessFactorizer無狀態的:它既不包含任何域,也不包含任何對其餘類中域的引用。 因爲線程訪問無狀態對象的行爲並不會影響其餘線程操做的正確性,所以無狀態對象是線程安全的。less

2. 原子性

當咱們在無狀態對象中增長一個狀態時,會出現什麼狀況?假設咱們想增長一個「命中計數器」來統計所處理的請求數量。一種直觀的方法是在servlet中增長一個long類型的域,而且每處理一個請求就將這個值加1,如程序2-2中:性能

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

在併發編程中,這種因爲不恰當執行時序而出現不正確的結果是一種很是重要的狀況,他有一個正式的名字:競態條件(Race Condition)this

2.1 競態條件

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

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

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

在此類中包含了一個競態條件,他可能會破壞這個類的正確性。假設線程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:

經過用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)應用程序: 可同時調用的數量,不只受到可用處理資源的限制,還受到應用程序自己結構的限制。

經過縮小同步代碼塊的做用範圍,咱們很容易作到既確保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),必定不要持有鎖。
複製代碼
相關文章
相關標籤/搜索