Java併發編程的基礎理論

前言:本文的演示代碼已經進行了壓縮,以便減少代碼量並重點突出與內容相關的部分,完整的代碼示例放在碼雲上了,碼雲地址:https://gitee.com/reminis_com/java-concurrency-in-practice.git

併發簡史

  在早期的計算機中不包含操作系統,它們從頭到尾只執行一個程序,並且這個程序能訪問計算機中的所有資源。在這種裸機環境中,不僅很難編寫和運行程序,而且每次只能運行一個程序,這對於昂貴並且稀有的計算機資源來說也是一種浪費。

  操作系統的出現使得計算機每次能運行多個程序,並且不同的程序都在單獨的進程中運行∶操作系統爲各個獨立執行的進程分配各種資源,包括內存,文件句柄以及安全證書等。如果需要的話,在不同的進程之間可以通過一些粗粒度的通信機制來交換數據,包括∶套接字、信號處理器、共享內存、信號量以及文件等。

  之所以在計算機中加入操作系統來實現多個程序的同時執行,主要是基於以下原因∶

  • 資源利用率:在某些情況下,程序必須等待某個外部操作執行完成,例如輸入操作或輸出操作等,而在等待時程序無法執行其他任何工作。因此,如果在等待的同時可以運行另一個程序,那麼無疑將提高資源的利用率。
  • 公平性:不同的用戶和程序對於計算機上的資源有着同等的使用權。一種高效的運行方式是通過粗粒度的時間分片(Time Slicing)使這些用戶和程序能共享計算機資源,而不是由一個程序從頭運行到尾,然後再啓動下一個程序。
  • 便利性:通常來說,在計算多個任務時,應該編寫多個程序,每個程序執行一個任務並在必要時相互通信,這比只編寫一個程序來計算所有任務更容易實現。

  在早期的分時系統中,每個進程相當於一臺虛擬的馮·諾依曼計算機,它擁有存儲指令和數據的內存空間,根據機器語言的語義以串行方式執行指令,並通過一組I/O指令與外部設備通信。對每條被執行的指令,都有相應的“下一條指令”,程序中的控制流是按照指令集的規則來確定的。當前,幾乎所有的主流編程語言都遵循這種串行編程模型,並且在這些語言的規範中也都清晰地定義了在某個動作完成之後需要執行的“下一個動作”。

  這些促使進程出現的因素(資源利用率、公平性以及便利性等)同樣也促使着線程的出現。線程允許在同一個進程中同時存在多個程序控制流。線程會共享進程範圍內的資源,例如內存句柄和文件句柄,但每個線程都有各自的程序計數器(Program Counter)、棧以及局部變量等。線程還提供了一種直觀的分解模式來充分利用多處理器系統中的硬件並行性,而在同一個程序中的多個線程也可以被同時調度到多個CPU上運行。

  線程也被稱爲輕量級進程。在大多數現代操作系統中,都是以線程爲基本的調度單位,而不是進程。如果沒有明確的協同機制,那麼線程將彼此獨立執行。由於同一個進程中的所有線程都將共享進程的內存地址空間,因此這些線程都能訪問相同的變量並在同一個堆上分配對象,這就需要實現一種比在進程間共享數據粒度更細的數據共享機制。如果沒有明確的同步機制來協同對共享數據的訪問,那麼當一個線程正在使用某個變量時,另一個線程可能同時訪問這個變量,這將造成不可預測的結果。

線程安全性

  要編寫線程安全的代碼,其核心在於要對狀態訪問操作進行管理,特別是對共享的(Shared)和可變的(Mutable)狀態的訪問。“共享”意味着變量可以由多個線程同時訪問,而“可變”則意味着變量的值在其生命週期內可以發生變化。
  從非正式的意義上來說,對象的狀態是指存儲在狀態變量(例如實例或靜態域)中的數據。對象的狀態可能包括其他依賴對象的域。例如,某個HashMap的狀態不僅存儲在HashMap對象本身,還存儲在許多Map.Entry對象中。在對象的狀態中包含了任何可能影響其外部可見行爲的數據。

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

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

什麼是線程安全性?

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

示例:一個無狀態的Servlet
  我們來看一個簡單的示例————一個基於Servlet的因數分解服務,並逐漸擴展它的功能,同時確保它的線程安全性。如下示例給出了一個簡單的因數分解Servlet,這個Servlet從請求中提取出數值,執行因數分解,然後將結果封裝到該Servlet響應中。

@ThreadSafe
public class StatelessFactorizer implements Servlet {

    @Override
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        encodeIntoResponse(resp, factors);
    }
}

  與大多數Servlet相同,StatelessFactorizer是無狀態的∶它既不包含任何域,也不包含任何對其他類中域的引用。計算過程中的臨時狀態僅存在於線程棧上的局部變量中,並且只能由正在執行的線程訪問。訪問 StatelessFactorizer的線程不會影響另一個訪問同一個StatelessFactorizer的線程的計算結果,因爲這兩個線程並沒有共享狀態,就好像它們都在訪問不同的實例。由於線程訪問無狀態對象的行爲並不會影響其他線程中操作的正確性,因此無狀態對象是線程安全的。

   無狀態對象一定是線程安全的。

  大多數Servlet都是無狀態的,從而極大地降低了在實現Servlet線程安全性時的複雜性。只有當Servlet在處理請求時需要保存一些信息,線程安全性纔會成爲一個問題。

原子性

  當我們在無狀態的對象中增加一個狀態時,會出現什麼情況?假設我們希望增加一個“命中計數器”來統計所處理的請求數量,一種直觀的方法是在Servlet中增加一個long類型的域,並且每處理一個請求就將這個值加1,如UnsafeCountingFactorizer示例所示:

@NotThreadSafe
public class UnsafeCountingFactorizer implements Servlet {
    private long count = 0;

    public long getCount() {
        return count;
    }

    @Override
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        ++count;
        encodeIntoResponse(resp, factors);
    }
}

  不幸的是,UnsafeCountingFactorizer 並非線程安全的,儘管它在單線程環境中能正確運行。但在多線程環境中這個類很可能會丟失一些更新操作。雖然遞增操作++count是一種緊湊的語法,使其看上去只是一個操作,但這個操作並非原子的,因而它並不會作爲一個不可分割的操作來執行。實際上,它包含了三個獨立的操作∶讀取count的值,將值加1,然後將計算結果寫入count。這是一個"讀取一修改一寫入"的操作序列,並且其結果狀態依賴於之前的狀態。

競態條件

  在UnsafeCountingFactorizer中存在多個競態條件,從而使結果變得不可靠。當某個計算的正確性取決於多個線程的交替執行時序時,那麼就會發生競態條件。換句話說,就是正確的結果要取決於運氣。最常見的競態條件類型就是“先檢查後執行”操作。即通過一個可能失效的觀測結果來決定下一步的動作。

  在實際情況中經常會遇到競態條件。例如,假定你計劃中午在University Avenue的星巴克與一位朋友會面。但當你到達那裏時,發現在University Avenue上有兩家星巴克,並且你不知道說好碰面的是哪一家。在12∶10時,你沒有在星巴克A看到朋友,那麼就會去星巴克B看看他是否在那裏,但他也不在那裏。這有幾種可能∶你的朋友遲到了,還沒到任何一家星巴克;你的朋友在你離開後到了星巴克A;你的朋友在星巴克B,但他去星巴克A找你,並且此時正在去星巴克A的途中。我們假設是最糟糕的情況,即最後一種可能。現在是12∶15,你們兩個都去過了兩家星巴克,並且都開始懷疑對方是否失約了。現在你會怎麼做?回到另一家星巴克?來來回回要走多少次?除非你們之間約定了某種協議,否則你們整天都在University Avenue上走來走去,倍感沮喪。
  在“我去看看他是否在另一家星巴克”這種方法中,問題在於∶當你在街上走時,你的朋友可能已經離開了你要去的星巴克。你首先看了看星巴克A,發現“他不在”,並且開始去找他。你可以在星巴克B中做同樣的選擇,但不是同時發生。兩家星巴克之間有幾分鐘的路程,而就在這幾分鐘的時間裏,系統的狀態可能會發生變化。

  在星巴克這個示例中說明了一種競態條件,因爲要獲得正確的結果(與朋友會面),必須取決於事件的發生時序(當你們到達星巴克時,在離開並去另一家星巴克之前會等待多長時間……)。當你邁出前門時,你在星巴克A的觀察結果將變得無效,你的朋友可能從後門進來了,而你卻不知道。這種觀察結果的失效就是大多數競態條件的本質————基於一種可能失效的觀察結果來做出判斷或者執行某個計算。這種類型的競態條件稱爲“先檢查後執行”∶首先觀察到某個條件爲真(例如文件X不存在),然後根據這個觀察結果採用相應的動作(創建文件X),但事實上,在你觀察到這個結果以及開始創建文件之間,觀察結果可能變得無效(另一個線程在這期間創建了文件X),從而導致各種問題(未預期的異常、數據被覆蓋、文件被破壞等)。

示例:延遲初始化中的競態條件
  延遲初始化的目的是將對象的初始化操作延遲到實際被使用時才進行,同時確保只被初始化一次。如下示例:getInstance()方法首先判斷ExpensiveObject是否已經被初始化,如果已經初始化則返回現有的實例,否則,它將創建一個新的實例,並返回一個引用,從而在後來的調用中就無需再執行這段高開銷的代碼路徑。

@NotThreadSafe
public class LazyInitRace {
    private ExpensiveObject instance = null;

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

  在LazyInitRace中包含了一個競態條件,它可能會破壞這個類的正確性。假定線程A和線程B同時執行getInstance()。A看到 instance 爲空,因而創建一個新的ExpensiveObject 實例。B同樣需要判斷 instance是否爲空。此時的instance是否爲空,要取決於不可預測的時序,包括線程的調度方式,以及A需要花多長時間來初始化ExpensiveObject並設置instance。如果當B檢查時,instance爲空,那麼在兩次調用getInstance時可能會得到不同的結果,即使getInstance通常被認爲是返回相同的實例。

複合操作

  LazylnitRaceUnsafeCountingFactorizer都包含一組需要以原子方式執行(或者說不可分割)的操作。要避免競態條件問題,就必須在某個線程修改該變量時,通過某種方式防止其他線程使用這個變量,從而確保其他線程只能在修改操作完成之前或之後讀取和修改狀態,而不是在修改狀態的過程中。

  爲了確保線程安全性,“先檢查後執行”(例如延遲初始化)和“讀取-修改-寫入”(例如自增運算)等操作必須是原子的。我們將“先檢查後執行”和“讀取-修改-寫入”等操作統稱爲複合操作:包含了一組必須以原子方式執行的操作以確保線程安全性,如下示例,我們將使用原子類AtomicLong來統計已處理請求請求的數量,確保線程安全:

@ThreadSafe
public class CountingFactorizer implements Servlet {

    private final AtomicLong count = new AtomicLong(0);

    public long getCount() { return count.get(); }

    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = factor(i);
        count.incrementAndGet();
        encodeIntoResponse(resp, factors);
    }
}

  在java.util.concurrent.atomic包中包含了一些原子變量類,用於實現在數值和對象引用上的原子狀態轉換。通過用AtomicLong來代替long類型的計數器,能夠確保所有對計數器狀態的訪問操作都是原子的。由於Servlet的狀態就是計數器的狀態,並且計數器是線程安全的,因此這裏的Servlet也是線程安全的。

   在實際情況中,應儘可能地使用現有的線程安全對象(例如AcomicLong)來管理類的狀態。與非線程安全的對象相比,判斷線程安全對象的可能狀態及其狀態轉換情況要更爲客易,從而也更容易維護和驗證線程安全性。

加鎖機制

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

內置鎖

  Java提供了一種內置的鎖機制來支持原子性∶同步代碼塊(Synchronized Block)。同步代碼塊包括兩部分∶一個作爲鎖的對象引用,一個作爲由這個鎖保護的代碼塊。以關鍵字synchronized來修飾的方法就是一種橫跨整個方法體的同步代碼塊,其中該同步代碼塊的鎖就是方法調用所在的對象。靜態的synchronized方法以Class對象作爲鎖。

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

  每個Java對象都可以用做一個實現同步的鎖,這些鎖被稱爲內置鎖(Intrinsic Lock)或監視器鎖(Monitor Lock)。線程在進入同步代碼塊之前會自動獲得鎖,並且在退出同步代碼塊時自動釋放鎖,而無論是通過正常的控制路徑退出,還是通過從代碼塊中拋出異常退出。獲得內置鎖的唯一途徑就是進入由這個鎖保護的同步代碼塊或方法。
  Java的內置鎖相當於一種互斥體(或互斥鎖),這意味着最多隻有一個線程能持有這種鎖。當線程A嘗試獲取一個由線程B持有的鎖時,線程A必須等待或者阻塞,直到線程B釋放這個鎖。如果B永遠不釋放鎖,那麼A也將永遠地等下去。
  由於每次只能有一個線程執行內置鎖保護的代碼塊,因此,由這個鎖保護的同步代碼塊會以原子方式執行,多個線程在執行該代碼塊時也不會相互干擾。併發環境中的原子性與事務應用程序中的原子性有着相同的含義————一組語句作爲一個不可分割的單無被執行。任何一個執行同步代碼塊的線程,都不可能看到有其他線程正在執行由同一個鎖保護的同步代碼塊。

重入

  當某個線程請求一個由其他線程持有的鎖時,發出請求的線程就會阻塞。然而,由於內置鎖是可重入的,因此如果某個線程試圖獲得一個已經由它自己持有的鎖,那麼這個請求就會成功。“重入”意味着獲取鎖的操作的粒度是“線程”,而不是“調用”。重入的一種實現方法是,爲每個鎖關聯一個獲取計數值和一個所有者線程。當計數值爲0時,這個鎖就被認爲是沒有被任何線程持有。當線程請求一個未被持有的鎖時,JVM將記下鎖的持有者,並且將獲取計數值置爲1。如果同一個線程再次獲取這個鎖,計數值將遞增,而當線程退出同步代碼塊時,計數器會相應地遞減。當計數值爲0時,這個鎖將被釋放。

  重入進一步提升了加鎖行爲的封裝性,因此簡化了面向對象併發代碼的開發。在如下的示例代碼中,子類重寫了父類的synchronized方法,然後調用父類中的方法,此時如果沒有可重入的鎖,那麼這段代碼將產生死鎖。由於WidgetLoggingWidget doSomething()方法都是synchronized方法,因此每個doSomething()方法在執行前都會獲取Widget上的鎖。然而,如果內置鎖不是可重入的,那麼在調用super.doSomething()時將無法獲得Widget上的鎖,因爲這個鎖已經被持有,從而線程將永遠停頓下去,等待一個永遠也無法獲得的鎖。重入則避免了這種死鎖情況的發生。

public class Widget {
    public synchronized void doSomething() {
      // ...
    }
}

public class LoggingWidget extends Widget {
    public synchronized void doSomething() {
        System.out.println(toString() + ": calling doSomething");
        super.doSomething();
    }
}

用鎖來保護狀態

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

  由於鎖能使其保護的代碼路徑以串行形式中來訪問,因此可以通過鎖來構造一些協議以實現對共享狀態的獨佔訪問。只要始終遵循這些協議,就能確保狀態的一致性。
  訪問共享狀態的複合操作,例如命中計數器的遞增操作(讀取一修改-寫入)或者延遲初始化(先檢查後執行),都必須是原子操作以避免產生競態條件。如果在複合操作的執行過程中持有一個鎖,那麼會使複合操作成爲原子操作。然而,僅僅將複合操作封裝到一個同步代碼塊中是不夠的。如果用同步來協調對某個變量的訪問,那麼在訪問這個變量的所有位置上都需要使用同步。而且,當使用鎖來協調對某個變量的訪問時,在訪問變量的所有位置上都要使用同一個鎖。
  一種常見的錯誤是認爲,只有在寫入共享變量時才需要使用同步,然而事實並非如此。

對象的共享

  我們已經知道了同步代碼塊和同步方法可以確保以原子的方式執行操作,但一種常見的誤解是,認爲關鍵字synchronized只能用於實現原子性或者確定“臨界區(Critical Section)”。同步還有另一個重要的方面∶內存可見性(Memory Visibility)。我們不僅希望防止某個線程正在使用對象狀態而另一個線程在同時修改該狀態,而且希望確保當一個線程修改了對象狀態後,其他線程能夠看到發生的狀態變化。如果沒有同步,那麼這種情況就無法實現。你可以通過顯式的同步或者類庫中內置的同步來保證對象被安全地發佈。

可見性

  在如下關於NoVisibility的代碼示例中,說明了當多個線程在沒有同步的情況下共享數據時出現的錯誤。在代碼中,主線程和讀線程都將訪問共享變量ready和number。主線程啓動讀線程,然後將number設爲42,並將ready設爲true。讀線程一直循環直到發現ready的值變爲true,然後輸出number的值。雖然NoVisibility看起來會輸出42,但事實上很可能輸出0,或者根本無法終止。這是因爲在代碼中沒有使用足夠的同步機制,因此無法保證主線程寫入的ready值和number值對於讀線程來說是可見的。

public class NoVisibility {
    private static boolean ready;
    private static int number;

    private static class ReaderThread extends Thread {
        public void run() {
            while (!ready)
                Thread.yield();
            System.out.println(number);
        }
    }

    public static void main(String[] args) {
        new ReaderThread().start();
        number = 42;
        ready = true;
    }
}

  NoVisibility可能會持續循環下去,因爲讀線程可能永遠都看不到ready的值。一種更奇怪的現象是,NoVisibility可能會輸出0,因爲讀線程可能看到了寫入ready的值,但卻沒有看到之後寫入mumber的值,這種現象被稱爲“重排序(Reordering)”。只要在某個線程中無法檢測到重排序情況(即使在其他線程中可以很明顯地看到該線程中的重排序),那麼就無法確保線程中的操作將按照程序中指定的順序來執行。O當主線程首先寫入number,然後在沒有同步的情況下寫入 ready,那麼讀線程看到的順序可能與寫入的順序完全相反。

   在沒有同步的情況下,編譯器、處理器以及運行時等都可能對操作的執行順序進行一些意想不到的調整。在缺乏足夠同步的多線程程序中,要想對內存操作的執行順序進行判斷,幾乎無法得出正確的結論。

  NoVisibility是一個簡單的併發程序,只包含兩個線程和兩個共享變量,但即便如此,在判斷程序的執行結果以及是否會結束時仍然很容易得出錯誤結論。要對那些缺乏足夠同步的併發程序的執行情況進行推斷是十分困難的。這聽起來有點恐怖,但實際情況也確實如此。幸運的是,有一種簡單的方法能避免這些複雜的問題∶只要有數據在多個線程之間共享,就使用正確的同步。

非原子的64位操作
  當線程在沒有同步的情況下讀取變量時,可能會得到一個失效值,但至少這個值是由之前某個線程設置的值,而不是一個隨機值。這種安全性保證也被稱爲最低安全性(out-of-thin-air safety)。
  最低安全性適用於絕大多數變量,但是存在一個例外∶非volatile類型的64位數值變量(double和long)。Java內存模型要求,變量的讀取操作和寫入操作都必須是原子操作,但對於非volatile類型的long和double變量,JVM允許將64位的讀操作或寫操作分解爲兩個32位的操作。當讀取一個非volatile類型的long變量時,如果對該變量的讀操作和寫操作在不同的線程中執行,那麼很可能會讀取到某個值的高32位和另一個值的低32位。因此,即使不考慮失效數據問題,在多線程程序中使用共享且可變的long和double等類型的變量也是不安全的,除非用關鍵字volatile來聲明它們,或者用鎖保護起來。

加鎖與可見性

  內置鎖可以用於確保某個線程以一種可預測的方式來查看另一個線程的執行結果,如圖3-1所示。當線程A執行某個同步代碼塊時,線程B隨後進入由同一個鎖保護的同步代碼塊,在這種情況下可以保證,在鎖被釋放之前,A看到的變量值在B獲得鎖後同樣可以由B看到。換句話說,當線程B執行由鎖保護的同步代碼塊時,可以看到線程A之前在同一個同步代碼塊中的所有操作結果。如果沒有同步,那麼就無法實現上述保證。

  現在,我們可以進一步理解爲什麼在訪問某個共享且可變的變量時要求所有線程在同一個鎖上同步,就是爲了確保某個線程寫入該變量的值對於其他線程來說都是可見的。否則,如果一個線程在未持有正確鎖的情況下讀取某個變量,那麼讀到的可能是一個失效值。

   加鎖的含義不僅僅侷限於互斥行爲,還包括內存可見性。爲了確保所有線程都能看到共享變量的最新值,所有執行讀操作或者寫操作的線程都必須在同一個鎖上同步。

Volatitle變量

  Java語言提供了一種削弱的同步機制,即Volatile變量,用來確保將變量的更新操作通知到其他線程。當把變量聲明爲volatile類型後,編譯器與運行時都會注意到這個變量是共享的,因此不會將該變量上的操作與其他內存操作一起重排序。volatile變量不會被緩存在寄存器或者對其他處理器不可見的地方,因此在讀取 volatile 類型的變量時總會返回最新寫入的值。
  volatile變量對可見性的影響比volatile變量本身更爲重要。當線程A首先寫入一個volatile變量並且線程B隨後讀取該變量時,在寫入volatile變量之前對A可見的所有變量的值,在B讀取了volatile變量後,對B也是可見的。因此,從內存可見性的角度來看,寫入volatile變量相當於退出同步代碼塊,而讀取volatile變量就相當於進入同步代碼塊。然而,我們並不建議過度依賴volatile變量提供的可見性。如果在代碼中依賴volatile變量來控制狀態的可見性,通常比使用鎖的代碼更脆弱,也更難以理解。
   僅當volatile變量能簡化代碼的實現以及對同步策略的驗證時,才應該使用它們。如果在驗證正確性時需要對可見性進行復雜的判斷,那麼就不要使用volatile變量。volatile 變量的正確使用方式包括∶確保它們自身狀態的可見性,確保它們所引用對象的狀態的可見性,以及標識一些重要的程序生命週期事件的發生(例如,初始化或關閉)。

  雖然volatile變量很方便,但也存在一些侷限性。volatile變量通常用作某個操作完成、發生中斷或者狀態的標誌。儘管volatile變量也可以用於表示其它的狀態信息,但在使用時要非常小心。例如,volatile的語義不足以確保遞增操作(count++)的原子性,除非你能確保只有一個線程對變量執行寫操作。
  加鎖機制既可以確保可見性又可以確保原子性,而volatile變量只能確保可見性。

當且僅當滿足以下所有條件時,才應該使用volatile變量∶

  • 對變量的寫入操作不依賴變量的當前值,或者你能確保只有單個線程更新變量的值。
  • 該變量不會與其他狀態變量一起納入不變性條件中。
  • 在訪問變量時不需要加鎖。

發佈與逸出

  “發佈(Publish)”一個對象的意思是指,使對象能夠在當前作用城之外的代碼中使用。例如,將一個指向該對象的引用保存到其他代碼可以訪問的地方,或者在某一個非私有的方法中返回該引用,或者將引用傳遞到其他類的方法中。在許多情況中,我們要確保對象及其內部狀態不被髮布。而在某些情況下,我們又需要發佈某個對象,但如果在發佈時要確保線程安全性,則可能需要同步。發佈內部狀態可能會破壞封裝性,並使得程序難以維持不變性條件。例如,如果在對象構造完成之前就發佈該對象,就會破壞線程安全性。當某個不應該發佈的對象被髮布時,這種情況就被稱爲逸出(Escape)。後面會介紹如何安全發佈對象的一些方法。現在,我們首先來看看一個對象是如何逸出的。

  發佈對象的最簡單方法是將對象的引用保存到一個公有的靜態變量中,以便任何類和線程都能看見該對象,如下示例代碼所示。在initialize()方法中實例化一個新的HashSet對象,並將對象的引用保存到 knownSecrets 中以發佈該對象。

public static Set<Secret> knownSecrets;

public void initialize() {
  knownSecrets = new Hashset<Secret>();
}

  當發佈某個對象時,可能會間接地發佈其他對象。如果將一個Secret對象添加到集合knownSecrets中,那麼同樣會發佈這個對象,因爲任何代碼都可以遍歷這個集合,並獲得對這個新 Secret對象的引用。同樣,如果從非私有方法中返回一個引用,那麼同樣會發佈返回的對象。程序UnsafeStates發佈了本應爲私有的狀態數組。

public class UnsafeStates {
    private String[] states = new String[]{
            "AK", "AL" /*...*/
    };

    public String[] getStates() {
        return states;
    }
}

  如果按照上述方式來發布states,就會出現問題,因爲任何調用者都能修改這個數組的內容。在這個示例中,數組states已經逸出了它所在的作用域,因爲這個本應是私有的變量已經被髮布了。
  當發佈一個對象時,在該對象的非私有域中引用的所有對象同樣會被髮布。一般來說,如果一個已經發布的對象能夠通過非私有的變量引用和方法調用到達其他的對象,那麼這些對象也都會被髮布。
  假定有一個類C,對於C來說,"外部(Alien)方法"是指行爲並不完全由C來規定的方法,包括其他類中定義的方法以及類C中可以被改寫的方法(既不是私有【private】方法也不是終結【final】方法)。當把一個對象傳遞給某個外部方法時,就相當於發佈了這個對象。你無法知道哪些代碼會執行,也不知道在外部方法中究竟會發布這個對象,還是會保留對象的引用並在隨後由另一個線程使用。
  無論其他的線程會對已發佈的引用執行何種操作,其實都不重要,因爲誤用該引用的風險始終存在虧。當某個對象逸出後,你必須假設有某個類或線程可能會誤用該對象。這正是需要使用封裝的最主要原因∶封裝能夠使得對程序的正確性進行分析變得可能,並使得無意中破壞設計約束條件變得更難。
  最後一種發佈對象或其內部狀態的機制就是發佈一個內部的類實例,如代碼ThisEscape所示。當ThisEscape發佈EventListener時,也隱含地發佈了ThisEscape實例本身,因爲在這個內部類的實例中包含了對ThisEscape實例的隱含引用。

public class ThisEscape {

    public ThisEscape(EventSource source) {
        source.registerListener(new EventListener() {

            @Override
            public void onEvent(Event e) {
                doSomething(e);
            }
        });
    }
}

  在ThisEscape中給出了逸出的一個特殊示例,即 this 引用在構造函數中逸出。當內部的EventListener實例發佈時,在外部封裝的ThisEscape實例也逸出了。當且僅當對象的構造函數返回時,對象才處於可預測的和一致的狀態。因此,當從對象的構造函數中發佈對象時,只是發佈了一個尚未構造完成的對象。即使發佈對象的語句位於構造函數的最後一行也是如此。如果this引用在構造過程中逸出,那麼這種對象就被認爲是不正確構造
不要在構造過程中使this引用遠出。
在構造過程中使 this 引用逸出的一個常見錯誤是,在構造函數中啓動一個線程。當對象在其構造函數中創建一個線程時,無論是顯式創建(通過將它傳給構造函數)還是隱式創建(由於Thread或Runnable是該對象的一個內部類),this引用都會被新創建的線程共享。在對象尚未完全構造之前,新的線程就可以看見它。在構造函數中創建線程並沒有錯誤,但最好不要立即啓動它,而是通過一個start或initialize方法來啓動(請參見第7章瞭解更多關於服務生命週期的內容)。在構造函數中調用一個可改寫的實例方法時(既不是私有方法,也不是終結方法),同樣會導致this引用在構造過程中逸出。
如果想在構造函數中註冊一個事件監聽器或啓動線程,那麼可以使用一個私有的構造函數和一個公共的工廠方法(Factory Method),從而避免不正確的構造過程,如程序SafeListener所示:

public class SafeListener {
    private final EventListener listener;

    private SafeListener() {
        listener = new EventListener() {

            @Override
            public void onEvent(Event e) {
                doSomething(e);
            }
        };
    }

    public static SafeListener newInstance(EventSource source) {
        SafeListener safe = new SafeListener();
        source.registerListener(safe.listener);
        return safe;
    }
}

線程封閉

  當訪問共享的可變數據時,通常需要使用同步。一種避免同步的方式就是不共享數據。如果僅在單線程內訪問數據,就不需要同步,這種技術被稱爲線程封閉。它是實現線程安全性的最簡單方式之一。當某個對象封閉在一個線程中時,這種用法將自動實現線程安全性,即使被封閉的對象本身不是線程安全的。

  線程封閉技術的一種常見應用是JDBC(Java Database Connectivity)的Connection對象。JDBC規範並不要求Connection對象必須是線程安全的。在典型的服務器應用程序中,線程從連接池中獲得一個Connection對象,並且用該對象來處理請求,使用完後再將對象返還給連接池。由於大多數請求(例如Servlet請求或EJB調用等)都是由單個線程採用同步的方式來處理,並且在Connection對象返回之前,連接池不會再將它分配給其他線程,因此,這種連接管理模式在處理請求時隱含地將Connection對象封閉在線程中。
  在Java語言中並沒有強制規定某個變量必須由鎖來保護,同樣在Java語言中也無法強制將對象封閉在某個線程中。線程封閉是在程序設計中的一個考慮因素,必須在程序中實現。Java語言及其核心庫提供了一些機制來幫助維持線程封閉性,例如局部變量和ThreadLocal類,但即便如此,程序員仍然需要負責確保封閉在線程中的對象不會從線程中逸出。

Ad-hoc線程封閉

  Ad-hoc線程封閉是指,維護線程封閉性的職責完全由程序實現來承擔。Ad-hoc線程封閉是十分脆弱的,因爲沒有任何一種語言特性,例如可見性修飾符或局部變量,能將對象封閉到目標線程上,事實上,對線程封閉對象的引用通常保存在公有變量中。由於Ad-hoc線程封閉技術的脆弱性,因此在程序中儘量少使用它,在可能的情況下,應該使用更強的線程封閉技術(例如,棧封閉或ThreadLocal類)。

棧封閉

  棧封閉是線程封閉的一種特例,在棧封閉中,只能通過局部變量才能訪問對象。正如封裝能使得代碼更容易維持不變性條件那樣,同步變量也能使對象更易於封閉在線程中。局部變量的固有屬性之一就是封閉在執行線程中。它們位於執行線程的棧中,其他線程無法訪問這個棧。棧封閉(也被稱爲線程內部使用或者線程局部使用,不要與核心類庫中的ThreadLocal混淆)比Ad-hoc線程封閉更易於維護,也更加健壯。
  對於基本類型的局部變量,例如程序loadTheArk()方法的numPairs,無論如何都不會破壞棧封閉性。由於任何方法都無法獲得對基本類型的引用,因此Java語言的這種語義就確保了基本類型的局部變量始終封閉在線程內。

    public int loadTheArk(Collection<Animal> candidates) {
        SortedSet<Animal> animals;
        int numPairs = 0;
        Animal candidate = null;

        // animals 被封閉在方法中,不要使它們逸出
        animals = new TreeSet<Animal>(new SpeciesGenderComparator());
        animals.addAll(candidates);
        for (Animal a : animals) {
            if (candidate == null || !candidate.isPotentialMate(a)) {
                candidate = a;
            } else {
                ark.load(new AnimalPair(candidate, a));
                ++numPairs;
                candidate = null;
            }
        }
        return numPairs;
    }

  在維持對象引用的棧封閉性時,程序員需要多做一些工作以確保被引用的對象不會逸出。在loadTheArk()中實例化一個TreeSet對象,並將指向該對象的一個引用保存到animals中。此時,只有一個引用指向集合animals,這個引用被封閉在局部變量中,因此也被封閉在執行線程中。然而,如果發佈了對集合 animals(或者該對象中的任何內部數據)的引用,那麼封閉性將被破壞,並導致對象 animals的逸出。
  如果在線程內部上下文中使用非線程安全的對象,那麼該對象仍然是線程安全的。然而,要小心的是,只有編寫代碼的開發人員才知道哪些對象需要被封閉到執行線程中,以及被封閉的對象是否是線程安全的。如果沒有明確地說明這些需求,那麼後續的維護人員很容易錯誤地使對象逸出。

ThreadLocal類

  維持線程封閉性的一種更規範方法是使用ThreadLocal,這個類能使線程中的某個值與保存值的對象關聯起來。ThreadLocal提供了get與set等訪問接口或方法,這些方法爲每個使用該變量的線程都存有一份獨立的副本,因此get總是返回由當前執行線程在調用set時設置的最新值。
  ThreadLocal對象通常用於防止對可變的單實例變量(Singleton)或全局變量進行共享。例如,在單線程應用程序中可能會維持一個全局的數據庫連接,並在程序啓動時初始化這個連接對象,從而避免在調用每個方法時都要傳遞一個Connection對象。由於JDBC的連接對象不一定是線程安全的,因此,當多線程應用程序在沒有協同的情況下使用全局變量時,就不是線程安全的。通過將JDBC的連接保存到ThreadLocal對象中,每個線程都會擁有屬於自己的連接,如程序ConnectionHolder所示:

    private ThreadLocal<Connection> connectionHolder = new ThreadLocal<Connection>() {

        @Override
        public Connection initialValue() {
            try {
                return DriverManager.getConnection(DB_URL);
            } catch (SQLException e) {
                throw new RuntimeException("Unable to acquire Connection, e");
            }
        };
    };

    public Connection getConnection() {
        return connectionHolder.get();
    }

  當某個線程初次調用ThreadLocal.get()方法時,就會調用initialValue()方法來獲取初始值,從概念上看,你可以將ThreadLocal視爲包含了Map<Thread, T>對象,其中保存了特定於該線程的值,但ThreadLocal的實現並非如此。這些特定於線程的值保存在Thread對象中,當線程終止後,這些值會作爲垃圾回收。
  開發人員經常濫用ThreadLocal,例如將所有全局變量都作爲ThreadLocal對象,或者作爲一種“隱藏”方法參數的手段。ThreadLocal變量類似於全局變量,它能降低代碼的可重用性,並在類之間引入隱含的耦合性,因此在使用時要格外小心。

不變性

  滿足同步需求的另一種方法是使用不可變對象(Immutable Object)。到目前爲止,我們介紹了許多與原子性和可見性相關的問題,例如得到失效數據,丟失更新操作或者觀察到某個對象處於不一致的狀態等等,都與多線程試圖同時訪問同一個可變的狀態相關。如果對象的狀態不會改變,那麼這些問題與複雜性也就自然消失了。

  如果某個對象在被創建後其狀態就不能被修改,那麼這個對象就稱爲不可變對象。線程安全性是不可變對象的固有屬性之一,它們的不變性條件是由構造函數創建的,只要它們的狀態不改變,那麼這些不變性條件就能得以維持。

  不可變對象一定是線程安全的。

  不可變對象很簡單。它們只有一種狀態,並且該狀態由構造函數來控制。在程序設計中,一個最困難的地方就是判斷複雜對象的可能狀態。然而,判斷不可變對象的狀態卻很簡單。
  同樣,不可變對象也更加安全。如果將一個可變對象傳遞給不可信的代碼,或者將該對象發佈到不可信代碼可以訪問它的地方,那麼就很危險——不可信代碼會改變它們的狀態,更糟的是,在代碼中將保留一個對該對象的引用並稍後在其他線程中修改對象的狀態。另一方面,不可變對象不會像這樣被惡意代碼或者有問題的代碼破壞,因此可以安全地共享和發佈這些對象,而無須創建保護性的副本。

  雖然在Java語言規範和Java內存模型中都沒有給出不可變性的正式定義,但不可變性並不等於將對象中所有的域都聲明爲final類型,即使對象中所有的域都是final類型的,這個對象也仍然是可變的,因爲在final類型的域中可以保存對可變對象的引用。

當滿足以下條件時,對象纔是不可變的

  • 對象創建以後其狀態就不能修改。
  • 對象的所有城都是final類型。
  • 對象是正確創建的(在對象的創建期間,this引用沒有逸出)。

示例:使用Volatile類型來發布不可變對象
  因式分解Servlet將執行兩個原子操作:更新緩存的結果,以及通過判斷緩存中的數值是否等於請求的數值來決定是否直接讀取緩存中的因數分解結果。每當需要對一組相關數據以原子方式去執行某個操作時,就可以考慮創建一個不可變的類來包含這些數據,例如程序OneValueCache所示:

@Immutable
public class OneValueCache {
    private final BigInteger lastNumber;
    private final BigInteger[] lastFactors;

    public OneValueCache(BigInteger i,
                         BigInteger[] factors) {
        lastNumber = i;
        lastFactors = Arrays.copyOf(factors, factors.length);
    }

    public BigInteger[] getFactors(BigInteger i) {
        if (lastNumber == null || !lastNumber.equals(i))
            return null;
        else
            return Arrays.copyOf(lastFactors, lastFactors.length);
    }
}

  對於在訪問和更新多個相關變量時出現的競爭條件問題,可以通過將這些變量全部保存在一個不可變對象中來消除。如果是一個可變的對象,那麼就必須使用鎖來確保原子性。如果是一個不可變對象,那麼當線程獲得了對該對象的引用後,就不必擔心另一個線程會修改對象的狀態。如果要更新這些變量,那麼可以創建一個新的容器對象,但其他使用原有對象的線程仍然會看到對象處於一致的狀態。

  程序VolatileCachedFactorizer使用了OneValueCache 來保存緩存的數值及其因數。當一個線程將volatile類型的cache值設置爲一個新的OneValueCache 時,其他線程就會立即看到新緩存的數據。

@ThreadSafe
public class VolatileCachedFactorizer implements Servlet {
    private volatile OneValueCache cache = new OneValueCache(null, null);

    @Override
    public void service(ServletRequest req, ServletResponse resp) {
        BigInteger i = extractFromRequest(req);
        BigInteger[] factors = cache.getFactors(i);
        if (factors == null) {
            factors = factor(i);
            cache = new OneValueCache(i, factors);
        }
        encodeIntoResponse(resp, factors);
    }
}

  與cache相關的操作不會相互干擾,因爲OneValueCache是不可變的,並且在每條相應的代碼路徑中只會訪問它一次。通過使用包含多個狀態變量的容器對象來維持不變性條件,並使用一個 volatile類型的引用來確保可見性,使得 VolatileCachedFactorizer在沒有顯式地使用鎖的情況下仍然是線程安全的。

安全發佈

  到目前爲止,我們重點討論的是如何確保對象不被髮布,例如讓對象封閉在線程或另一個對象的內部。當然,在某些情況下我們希望在多個線程間共享對象,此時必須確保安全地進行共享。然而,如果只是像程序StuffIntoPublic那樣將對象引用保存到公有域中,那麼還不足以安全地發佈這個對象。

public class StuffIntoPublic {
    // 不安全的發佈
    public Holder holder;

    public void initialize() {
        holder = new Holder(42);
    }
}

  你可能會奇怪,這個看似沒有問題的示例何以會運行失敗。由於存在可見性問題,其他線程看到的Holder對象將處於不一致的狀態,即便在該對象的構造函數中已經正確地構建了不變性條件。這種不正確的發佈導致其他線程看到尚未創建完成的對象。

不正確的發佈:正確的對象被破壞

  你不能指望一個尚未被完全創建的對象擁有完整性。某個觀察該對象的線程將看到對象處於不一致的狀態,然後看到對象的狀態突然發生變化,即使線程在對象發佈後還沒有修改過它。事實上,如果程序Holder使用程序StuffIntoPublic中的不安全發佈方式,那麼另一個線程在調用 assertSanity()時將拋出AssertionError。

public class Holder {
    private int n;

    public Holder(int n) {
        this.n = n;
    }

    public void assertSanity() {
        if (n != n)
            throw new AssertionError("This statement is false.");
    }
}

  由於沒有使用同步來確保Holder對象對其他線程可見,因此將Holder稱爲“未被正確發佈”。在未被正確發佈的對象中存在兩個問題。首先,除了發佈對象的線程外,其他線程可以看到的Holder域是一個失效值,因此將看到一個空引用或者之前的舊值。然而,更糟糕的情況是,線程看到Holder引用的值是最新的,但Holder狀態的值卻是失效的。情況變得更加不可預測的是,某個線程在第一次讀取域時得到失效值,而再次讀取這個域時會得到一個更新值,這也是 assertSainty()拋出 AssertionError的原因。
  如果沒有足夠的同步,那麼當在多個線程間共享數據時將發生一些非常奇怪的事情。

  任何線程都可以在不需要額外同步的情況下安全地訪問不可變對象,即使在發佈這些對象時沒有使用同步
  這種保證還將延伸到被正確創建對象中所有final類型的域,在沒有額外同步的情況下,也可以安全地訪問final類型的域。然而,如果final類型的域所指向的是可變對象,那麼在訪問這些域所指向的對象的狀態時仍需要同步。

安全發佈的常用模式

  可變對象必須通過安全的方式來發布,這通常意味着在發佈和使用該對象的線程時都必須使用同步。現在,我們將重點介紹如何確保使用對象的線程能夠看到該對象處於已發佈的狀態,並稍後介紹如何在對象發佈後對其可見性進行修改。

要安全地發佈一個對象,對象的引用以及對象的狀態必須同時對其他線程可見。一個正確構造的對象可以通過以下方式來安全地發佈∶

  • 在靜態初始化函數中初始化一個對象引用。
  • 將對象的引用保存到volatile類型的城或者AtomicReferance對象中。
  • 將對象的引用保存到某個正確構造對象的final類型城中。
  • 將對象的引用保存到一個由鎖保護的域中。

  在線程安全容器內部的同步意味着,在將對象放入到某個容器,例如Vector或synchronizedList 時,將滿足上述最後一條需求。如果線程A將對象X放入一個線程安全的容器,隨後線程B 讀取這個對象,那麼可以確保B看到A設置的X狀態,即便在這段讀/寫X的應用程序代碼中沒有包含顯式的同步。儘管Javadoc在這個主題上沒有給出很清晰的說明,但線程安全庫中的容器類提供了以下的安全發佈保證∶

  • 通過將一個鍵或者值放入Hashtable、synchronizedMap或者ConcurrentMap中,可以安全地將它發佈給任何從這些容器中訪問它的線程(無論是直接訪問還是通過迭代器訪問)。
  • 通過將某個元素放入Vector、CopyOnWriteArrayList、CopyOnWriteArraySet、synchronizedList 或synchronizedSet中,可以將該元素安全地發佈到任何從這些容器中訪問該元素的線程。
  • 通過將某個元素放入BlockingQueue或者ConcurrentLinkedQueue中,可以將該元素安全地發佈到任何從這些隊列中訪問該元素的線程。

  類庫中的其他數據傳遞機制(例如Future和Exchanger)同樣能實現安全發佈,在介紹這些機制時將討論它們的安全發佈功能。
  通常,要發佈一個靜態構造的對象,最簡單和最安全的方式是使用靜態的初始化器:public static Holder holder - new Holder(42);。靜態初始化器由JVM在類的初始化階段執行。由於在JVM內部存在着同步機制,因此通過這種方式初始化的任何對象都可以被安全地發佈。

事實不可變對象

  如果對象從技術上來看是可變的,但其狀態在發佈後不會再改變,那麼把這種對象稱之爲“事實不可變對象”。
  在沒有額外同步的情況下,任何線程都可以安全地使用被安全發佈的事實不可變對象。

  例如,Date本身是可變的,但如果將它作爲不可變對象來使用,那麼在多個線程之間共享Date對象時,就可以省去對鎖的使用。假設需要維護一個Map對象,其中保存了每位用戶的最近登錄時間∶

public MapgString, Date> lastLogin = Collections.synchronizedMap(new HashMap<String,Date>();

  如果Date對象的值在被放入Map後就不會改變,那麼synchronizedMap中的同步機制就足以使Date值被安全地發佈,並且在訪問這些Date值時不需要額外的同步。

可變對象

  如果對象在構造後可以修改,那麼安全發佈只能確保“發佈當時”狀態的可見性。對於可變對象,不僅在發佈對象時需要使用同步,而且在每次對象訪問時同樣需要使用同步來確保後續修改操作的可見性。要安全地共享可變對象,這些對象就必須被安全地發佈,並且必須是線程安全的或者由某個鎖保護起來。

  對象的發佈需求取決於它的可變性

  • 不可變對象可以通過任意機制來發布。
  • 事實不可變對象必須通過安全方式來發布。
  • 可變對象必須通過安全方式來發布,並且必須是線程安全的或者由某個鎖保護起來。

安全地共享對象

  當獲得對象的一個引用時,你需要知道在這個引用上可以執行哪些操作。在使用它之前是否需要獲得一個鎖?是否可以修改它的狀態,或者只能讀取它?許多併發錯誤都是由於沒有理解共享對象的這些“既定規則”而導致的。當發佈一個對象時,必須明確地說明對象的訪問方式。

  在併發程序中使用和共享對象時,可以使用一些實用的策略,包括∶

  • 線程封閉:線程封閉的對象只能由一個線程擁有,對象被封閉在該線程中,並且只能由這個線程修改。
  • 只讀共享:在沒有額外同步的情況下,共享的只讀對象可以由多個線程併發訪問,但任何線程都不能修改它。共享的只讀對象包括不可變對象和事實不可變對象。
  • 線程安全共享:線程安全的對象在其內部實現同步,因此多個線程可以通過對象的公有接口來進行訪問而不需要進一步的同步。
  • 保護對象:被保護的對象只能通過持有特定的鎖來訪問。保護對象包括封裝在其他線程安全對象中的對象,以及已發佈的並且由某個特定鎮保護的對象。

對象的組合

  到目前爲止,我們已經介紹了關於線程安全與同步的一些基礎知識。然而,我們並不希望對每一次內存訪問都進行分析以確保程序是線程安全的,而是希望將一些現有的線程安全組件組合爲更大規模的組件或程序。本章將介紹一些組合模式,這些模式能夠使一個類更容易成爲線程安全的,並且在維護這些類時不會無意中破壞類的安全性保證。

設計線程安全的類

  在線程安全的程序中,雖然可以將程序的所有狀態都保存在公有的靜態域中,但與那些將狀態封裝起來的程序相比,這些程序的線程安全性更難以得到驗證,並且在修改時也更難以始終確保其線程安全性。通過使用封裝技術,可以使得在不對整個程序進行分析的情況下就可以判斷一個類是否是線程安全的。
  在設計線程安全類的過程中,需要包含以下三個基本要素:

  • 找出構成對象狀態的所有變量。
  • 找出約束狀態變量的不變性條件。
  • 建立對象狀態的併發訪問管理策略。

  要分析對象的狀態,首先從對象的域開始。如果對象中所有的域都是基本類型的變量,那麼這些域將構成對象的全部狀態。程序Counter只有一個域value,因此這個域就是Counter的全部狀態。對於含有n個基本類型域的對象,其狀態就是這些域構成的n元組。例如,二維點的狀態就是它的座標值(x,y)。如果在對象的域中引用了其他對象,那麼該對象的狀態將包含被引用對象的域。例如,LinkedList的狀態就包括該鏈表中所有節點對象的狀態。

@ThreadSafe
public final class Counter {
    private long value = 0;

    public synchronized long getValue() {
        return value;
    }

    public synchronized long increment() {
        if (value == Long.MAX_VALUE)
            throw new IllegalStateException("counter overflow");
        return ++value;
    }
}

  同步策略(Synchronization Policy)定義瞭如何在不違背對象不變條件或後驗條件的情況下對其狀態的訪問操作進行協同。同步策略規定了如何將不可變性、線程封閉與加鎖機制等結合起來以維護線程的安全性,並且還規定了哪些變量由哪些鎖來保護。要確保開發人員可以對這個類進行分析與維護,就必須將同步策略寫爲正式文檔。
  如果不瞭解對象的不變性條件與後驗條件,那麼就不能確保線程安全性。要滿足在狀態變量的有效值或狀態轉換上的各種約束條件,就需要藉助於原子性與封裝性。

實例封閉

  如果某對象不是線程安全的,那麼可以通過多種技術使其在多線程程序中安全地使用。你可以確保該對象只能由單個線程訪問(線程封閉),或者通過一個鎖來保護對該對象的所有訪問。
  封裝簡化了線程安全類的實現過程,它提供了一種實例封閉機制(Instance Confinement),通常也簡稱爲“封閉”。當一個對象被封裝到另一個對象中時,能夠訪問被封裝對象的所有代碼路徑都是已知的。與對象可以由整個程序訪問的情況相比,更易於對代碼進行分析。通過將封閉機制與合適的加鎖策略結合起來,可以確保以線程安全的方式來使用非線程安全的對象。

  將數據封裝在對象內部,可以將數據的訪問限制在對象的方法上,從而更容易確保線程在訪問數據時總能持有正確的鎖。
  被封閉對象一定不能超出它們既定的作用域。對象可以封閉在類的一個實例(例如作爲類的一個私有成員)中,或者封閉在某個作用域內(例如作爲一個局部變量),再或者封閉在線程內(例如在某個線程中將對象從一個方法傳遞到另一個方法,而不是在多個線程之間共享該對象)。當然,對象本身不會逸出———出現逸出情況的原因通常是由於開發人員在發佈對象時超出了對象既定的作用域。

  程序PersonSet說明了如何通過封閉與加鎖等機制使一個類成爲線程安全的(即使這個類的狀態變量並不是線程安全的)。PersonSet的狀態由HashSet來管理的,而HashSet並非線程安全的。但由於mySet是私有的並且不會逸出,因此HashSet被封閉在PersonSet中。唯一能訪問mySet的代碼路徑是addPerson與containsPerson,在執行它們時都要獲得PersonSet上的鎖。PersonSet的狀態完全由它的內置鎖保護,因而PersonSet是一個線程安全的類。

@ThreadSafe
public class PersonSet {
    private final Set<Person> mySet = new HashSet<Person>();

    public synchronized void addPerson(Person p) {
        mySet.add(p);
    }

    public synchronized boolean containsPerson(Person p) {
        return mySet.contains(p);
    }
}

  這個示例並未對Person的線程安全性做任何假設,但如果Person類是可變的,那麼在訪問從PersonSet中獲得的Person對象時,還需要額外的同步。要想安全地使用Person對象,最可靠的方法就是使Person成爲一個線程安全的類。另外,也可以使用鎖來保護Person對象,並確保所有客戶代碼在訪問Person對象之前都已經獲得正確的鎖。

  實例封閉是構建線程安全類的一個最簡單方式,它還使得在鎖策略的選擇上擁有了更多的靈活性。在PersonSet中使用了它的內置鎖來保護它的狀態,但對於其他形式的鎖來說,只要自始至終都使用同一個鎖,就可以保護狀態。實例封閉還使得不同的狀態變量可以由不同的鎖來保護。
  在Java平臺的類庫中還有很多線程封閉的示例,其中有些類的唯一用途就是將非線程安全的類轉化爲線程安全的類。一些基本的容器類並非線程安全的,例如ArrayList和HashMap,但類庫提供了包裝器工廠方法(例如Collections.synchronizedList及其類似方法),使得這些非線程安全的類可以在多線程環境中安全地使用。這些工廠方法通過“裝飾器(Decorator)”模式將容器類封裝在一個同步的包裝器對象中,而包裝器能將接口中的每個方法都實現爲同步方法,並將調用請求轉發到底層的容器對象上。只要包裝器對象擁有對底層容器對象的唯一引用(即把底層容器對象封閉在包裝器中),那麼它就是線程安全的。在這些方法的Javadoc中指出,對底層容器對象的所有訪問必須通過包裝器來進行。
  當然,如果將一個本該被封閉的對象發佈出去,那麼也能破壞封閉性。如果一個對象本應該封閉在特定的作用域內,那麼讓該對象逸出作用域就是一個錯誤。當發佈其他對象時,例如迭代器或內部的類實例,可能會間接地發佈被封閉對象,同樣會使被封閉對象逸出。
  封閉機制更易於構造線程安全的類,因爲當封閉類的狀態時,在分析類的線程安全性時就無須檢查整個程序。

線程安全性的委託

  在前面的CountingFactorizer類中,我們在一個無狀態的類中增加了一個AtomicLong 類型的域,並且得到的組合對象仍然是線程安全的。由於CountingFactorizer的狀態就是AtomicLong的狀態,而AtomicLong是線程安全的,因此CountingFactorizer不會對counter的狀態施加額外的有效性約束,所以很容易知道CountingFactorizer是線程安全的。我們可以說CountingFactorizer將它的線程安全性委託給AtomicLong來保證∶之所以CountingFactorizer是線程安全的,是因爲AtomicLong是線程安全的。

獨立的狀態變量

  到目前爲止,這些委託示例都僅僅委託給了單個線程安全的狀態變量。我們還可以將線程安全性委託給多個狀態變量,只要這些變量是彼此獨立的,即組合而成的類並不會在其包含的多個狀態變量上增加任何不變性條件。
  程序VisualComponent是一個圖形組件,允許客戶程序註冊監控鼠標和鍵盤等事件的監聽器。它爲每種類型的事件都備有一個已註冊監聽器列表,因此當某個事件發生時,就會調用相應的監聽器。然而,在鼠標事件監聽器與鍵盤事件監聽器之間不存在任何關聯,二者是彼此獨立的,因此VisualComponent可以將其線程安全性委託給這兩個線程安全的監聽器列表。

public class VisualComponent {
    private final List<KeyListener> keyListeners = new CopyOnWriteArrayList<>();
    private final List<MouseListener> mouseListeners = new CopyOnWriteArrayList<>();

    public void addKeyListener(KeyListener listener) {
        keyListeners.add(listener);
    }

    public void addMouseListener(MouseListener listener) {
        mouseListeners.add(listener);
    }

    public void removeKeyListener(KeyListener listener) {
        keyListeners.remove(listener);
    }

    public void removeMouseListener(MouseListener listener) {
        mouseListeners.remove(listener);
    }

}

  VisualComponent使用CopyOnWriteArrayList來保存各個監聽器列表。它是一個線程安全的鏈表,特別適用於管理監聽器列表。每個鏈表都是線程安全的,此外,由於各個狀態之間不存在耦合關係,因此VisualComponent可以將它的線程安全性委託給mouseListeners和keyListeners等對象。

當委託失效時

  大多數組合對象都不會像VisualComponent這樣簡單∶在它們的狀態變量之間存在着某些不變性條件。程序NumberRange使用了兩個AtomicInteger來管理狀態,並且含有一個約束條件,即第一個數值要小於或等於第二個數值。

public class NumberRange {
    // 不變性條件: lower <= upper
    private final AtomicInteger lower = new AtomicInteger(0);
    private final AtomicInteger upper = new AtomicInteger(0);

    public void setLower(int i) {
        // 注意 -- 不安全的“先檢查後執行”
        if (i > upper.get())
            throw new IllegalArgumentException("can't set lower to " + i + " > upper");
        lower.set(i);
    }

    public void setUpper(int i) {
        // 注意 -- 不安全的“先檢查後執行”
        if (i < lower.get())
            throw new IllegalArgumentException("can't set upper to " + i + " < lower");
        upper.set(i);
    }

    public boolean isInRange(int i) {
        return (i >= lower.get() && i <= upper.get());
    }
}

  NumberRange不是線程安全的,沒有維持對下界和上界進行約束的不變性條件。setLower() 和 setUpper() 等方法都嘗試維持不變性條件,但卻無法做到。setLower()和setUpper()都是“先檢查後執行”的操作,但它們沒有使用足夠的加鎖機制來保證這些操作的原子性。假設取值範圍爲(0,10),如果一個線程調用setLower(5),而另一個線程調用setUpper(4),那麼在一些錯誤的執行時序中,這兩個調用都將通過檢查,並且都能設置成功。結果得到的取值範圍就是(5,4),那麼這是一個無效的狀態。因此,雖然Atomiclnteger是線程安全的,但經過組合得到的類卻不是。由於狀態變量lower和upper不是彼此獨立的,因此NumberRange不能將線程安全性委託給它的線程安全狀態變量。
  NumberRange可以通過加鎖機制來維護不變性條件以確保其線程安全性,例如使用一個鎖來保護lower和upper。此外,它還必須避免發佈lower和upper,從而防止客戶代碼破壞其不變性條件。
  如果某個類含有複合操作,例如NumberRange,那麼僅靠委託並不足以實現線程安全性。在這種情況下,這個類必須提供自己的加鎖機制以保證這些複合操作都是原子操作,除非整個複合操作都可以委託給狀態變量。
  如果一個類是由多個獨立且線程安全的狀態變量組成,並且在所有的操作中都不包含無效狀態轉換,那麼可以將線程安全性委託給底層的狀態變量。
  即使NumberRange的各個狀態組成部分都是線程安全的,也不能確保NumberRange的線程安全性。

基礎構建模塊

  Java平臺類庫包含了豐富的併發基礎構建模塊,例如線程安全容器類以及各種用於協調多個相互協作的線程控制流的同步工具類(Synchronizer)。下面將介紹一些最有用的併發構建構建模塊,特別是在Java 5.0和Java 6中引入的一些新模塊,以及在使用這些模塊來構造併發應用程序時的一些常用模式。

併發容器

  Java 5.0提供了多種併發容器類來改進同步容器的性能。同步容器將所有對容器狀態的訪問都串行化,以實現它們的線程安全性。這種方法的代價是嚴重降低併發性,當多個線程競爭容器的鎖時,吞吐量將嚴重減低。
  另一方面,併發容器是針對多個線程併發訪問設計的。在Java 5.0中增加了ConcurrentHashMap,用來替代同步且基於散列的Map,以及CopyOnWriteArrayList,用於在遍歷操作爲主要操作的情況下代替同步的List。在新的ConcurrentMap接口中增加了對一些常見覆合操作的支持,例如“若沒有則添加”、替換以及有條件刪除等。
  通過併發客器來代替同步客器,可以極大地提高仲編性並降低風險。

  Java 5.0增加了兩種新的容器類型∶Queue和BlockingQueue。Queue用來臨時保存一組等待處理的元素。它提供了幾種實現,包括∶ConcurrentLinkedQueue,這是一個傳統的先進先出隊列,以及PriorityQueue,這是一個(非併發的)優先隊列。Queue上的操作不會阻塞,如果隊列爲空,那麼獲取元素的操作將返回空值。雖然可以用List來模擬Queue的行爲————事實上,正是通過LinkedList來實現Queue的,但還需要一個Queue的類,因爲它能去掉List的隨機訪問需求,從而實現更高效的併發。
  BlockingQueue擴展了Queue,增加了可阻塞的插入和獲取等操作。如果隊列爲空,那麼獲取元素的操作將一直阻塞,直到隊列中出現一個可用的元素。如果隊列已滿(對於有界隊列來說),那麼插入元素的操作將一直阻塞,直到隊列中出現可用的空間。在“生產者一消費者”這種設計模式中,阻塞隊列是非常有用的。
  正如ConcurrentHashMap用於代替基於散列的同步Map,Java6也引入了ConcurrentSkipListMap和ConcurrentSkipListSet,分別作爲同步的SortedMap和SortedSet的併發替代品(例如用synchronizedMap包裝的TreeMap或TreeSet)。

ConcurrentHashMap

  同步容器類在執行每個操作期間都持有一個鎖。在一些操作中,例如HashMap.get()或List.contains(),可能包含大量的工作∶當遍歷散列桶或鏈表來查找某個特定的對象時,必須在許多元素上調用equals()方法(而equals()本身還包含一定的計算量)。在基於散列的容器中,如果hashCode()不能很均勻地分佈散列值,那麼容器中的元素就不會均勻地分佈在整個容器中。某些情況下,某個糟糕的散列函數還會把一個散列表變成線性鏈表。當遍歷很長的鏈表並且在某些或者全部元素上調用equals()方法時,會花費很長的時間,而其他線程在這段時間內都不能訪問該容器。
  與HashMap一樣,ConcurrentHashMap也是一個基於散列的Map,但它使用了一種完全不同的加鎖策略來提供更高的併發性和伸縮性。ConcurrentHashMap並不是將每個方法都在同一個鎖上同步並使得每次只能有一個線程訪問容器,而是使用一種粒度更細的加鎖機制來實現更大程度的共享,這種機制稱爲分段鎖。在這種機制中,任意數量的讀取線程可以併發地訪問Map,執行讀取操作的線程和執行寫入操作的線程可以併發地訪問Map,並且一定數量的寫入線程可以併發地修改 Map。ConcurrentHashMap 帶來的結果是,在併發訪問環境下將實現更高的吞吐量,而在單線程環境中只損失非常小的性能。
  ConcurrentHashMap與其他併發容器一起增強了同步容器類∶它們提供的迭代器不會拋出ConcurrentModificationException,因此不需要在迭代過程中對容器加鎖。ConcurrentHashMap 返回的迭代器具有弱一致性(Weakly Consistent),而並非"及時失敗"。弱一致性的迭代器可以容忍併發的修改,當創建迭代器時會追歷已有的元素,並可以(但是不保證)在迭代器被構造後將修改操作反映給容器。

  儘管有這些改進,但仍然有一些需要權衡的因素。對於一些需要在整個Map上進行計算的方法,例如size()和isEmpty(),這些方法的語義被略微減弱了以反映容器的併發特性。由於size()返回的結果在計算時可能已經過期了,它實際上只是一個估計值,因此允許size()返回一個近似值而不是一個精確值。雖然這看上去有些令人不安,但事實上size()和isEmpty()這樣的方法在併發環境下的用處很小,因爲它們的返回值總在不斷變化。因此,這些操作的需求被弱化了,以換取對其他更重要操作的性能優化,包括 get()、put()、containsKey()和remove()等。
  在ConcurrentHashMap中沒有實現對Map加鎖以提供獨佔訪問。在Hashtable和synchronizedMap 中,獲得 Map 的鎖能防止其他線程訪問這個Map。在一些不常見的情況中需要這種功能,例如通過原子方式添加一些映射,或者對Map迭代若干次並在此期間保持元素順序相同。然而,總體來說這種權衡還是合理的,因爲併發容器的內容會持續變化。
  與Hashtable和synchronizedMap相比,ConcurrentHashMap有着更多的優勢以及更少的劣勢,因此在大多數情況下,用ConcurrentHashMap來代替同步Map能進一步提高代碼的可伸縮性。只有當應用程序需要加鎖Map以進行獨佔訪問時,才應該放棄使用ConcurrentHashMap。

CopyOnWriteArrayList

  CopyOnWriteArrayList用於替代同步List,在某些情況下它提供了更好的併發性能,並且在迭代期間不需要對容器進行加鎖或複製。(類似地,CopyOnWriteArraySet的作用是替代同步Set。)
  “寫入時複製(Copy-On-Write)”容器的線程安全性在於,只要正確地發佈一個事實不可變的對象,那麼在訪問該對象時就不再需要進一步的同步。在每次修改時,都會創建並重新發佈一個新的容器副本,從而實現可變性。“寫入時複製”容器的迭代器保留一個指向底層基礎數組的引用,這個數組當前位於迭代器的起始位置,由於它不會被修改,因此在對其進行同步時只需確保數組內容的可見性。因此,多個線程可以同時對這個容器進行迭代,而不會彼此干擾或者與修改容器的線程相互干擾。“寫入時複製”容器返回的迭代器不會拋出ConcurrentModificationException,並且返回的元素與迭代器創建時的元素完全一致,而不必考慮之後修改操作所帶來的影響。

  顯然,每當修改容器時都會複製底層數組,這需要一定的開銷,特別是當容器的規模較大時。僅當迭代操作遠遠多於修改操作時,才應該使用“寫入時複製”容器。這個準則很好地描述了許多事件通知系統∶在分發通知時需要迭代已註冊監聽器鏈表,並調用每一個監聽器,在大多數情況下,註冊和註銷事件監聽器的操作遠少於接收事件通知的操作。

阻塞隊列和生產者 - 消費者模式

  阻塞隊列提供了可阻塞的put()和take()方法,以及支持定時的offer()和poll()方法。如果隊列已經滿了,那麼put()方法將阻塞直到有空間可用;如果隊列爲空,那麼take()方法將會阻塞直到有元素可用。隊列可以是有界的也可以是無界的,無界隊列永遠都不會充滿,因此無界隊列上的put()方法也永遠不會阻塞。

  阻塞隊列支持生產者一消費者這種設計模式。該模式將“找出需要完成的工作”與“執行工作”這兩個過程分離開來,並把工作項放入一個“待完成”列表中以便在隨後處理,而不是找出後立即處理。生產者一消費者模式能簡化開發過程,因爲它消除了生產者類和消費者類之間的代碼依賴性,此外,該模式還將生產數據的過程與使用數據的過程解耦開來以簡化工作負載的管理,因爲這兩個過程在處理數據的速率上有所不同。
  在基於阻塞隊列構建的生產者一消費者設計中,當數據生成時,生產者把數據放入隊列,而當消費者準備處理數據時,將從隊列中獲取數據。生產者不需要知道消費者的標識或數量,或者它們是否是唯一的生產者,而只需將數據放入隊列即可。同樣,消費者也不需要知道生產者是誰,或者工作來自何處。BlockingQueue簡化了生產者-消費者設計的實現過程,它支持任意數量的生產者和消費者。一種最常見的生產者一消費者設計模式就是線程池與工作隊列的組合,在Executor任務執行框架中就體現了這種模式。

  以兩個人洗盤子爲例,二者的勞動分工也是一種生產者一消費者模式∶其中一個人把洗好的盤子放在盤架上,而另一個人從盤架上取出盤子並把它們烘乾。在這個示例中,盤架相當於阻塞隊列。如果盤架上沒有盤子,那麼消費者會一直等待,直到有盤子需要烘乾。如果盤架放滿了,那麼生產者會停止清洗直到盤架上有更多的空間。我們可以將這種類比擴展爲多個生產者(雖然可能存在對水槽的競爭)和多個消費者,每個工人只需與盤架打交道。人們不需要知道究竟有多少生產者或消費者,或者誰生產了某個指定的工作項。
“生產者”和“消費者”的角色是相對的,某種環境中的消費者在另一種不同的環境中可能會成爲生產者。烘乾盤子的工人將“消費”洗乾淨的溼盤子,而產生烘乾的盤子。第三個人把洗乾淨的盤子整理好,在這種情況中,烘乾盤子的工人既是消費者,也是生產者,從而就有了兩個共享的工作隊列(每個隊列都可能阻塞烘乾工作的運行)。

  阻塞隊列簡化了消費者程序的編碼,因爲take()操作會一直阻塞直到有可用的數據。如果生產者不能儘快地產生工作項使消費者保持忙碌,那麼消費者就只能一直等待,直到有工作可做。在某些情況下,這種方式是非常合適的(例如,在服務器應用程序中,沒有任何客戶請求服務),而在其他一些情況下,這也表示需要調整生產者線程數量和消費者線程數量之間的比率,從而實現更高的資源利用率(例如,在"網頁爬蟲【Web Crawler】"或其他應用程序中,有無窮的工作需要完成)。
  如果生產者生成工作的速率比消費者處理工作的速率快,那麼工作項會在隊列中累積起來,最終耗盡內存。同樣,put()方法的阻塞特性也極大地簡化了生產者的編碼。如果使用有界隊列,那麼當隊列充滿時,生產者將阻塞並且不能繼續生成工作,而消費者就有時間來趕上工作處理進度。
  阻塞隊列同樣提供了一個offer()方法,如果數據項不能被添加到隊列中,那麼將返回一個失敗狀態。這樣你就能夠創建更多靈活的策略來處理負荷過載的情況,例如減輕負載,將多餘的工作項序列化並寫入磁盤,減少生產者線程的數量,或者通過某種方式來抑制生產者線程。
  在構建高可靠的應用程序時,有界隊列是一種強大的資源管理工具∶它們能抑制並防止產生過多的工作項,使應用程序在負荷過載的情況下變得更加健壯。

  在類庫中包含了BlockingQueue的多種實現,其中,LinkedBlockingQueue和ArrayBlockingQueue是FIFO隊列,二者分別與LinkedList和ArrayList類似,但比同步List擁有更好的併發性能。PriorityBlockingQueue是一個按優先級排序的隊列,當你希望按照某種順序而不是FIFO來處理元素時,這個隊列將非常有用。正如其他有序的容器一樣,PriorityBlockingQueue既可以根據元素的自然順序來比較元素(如果它們實現了Comparable方法),也可以使用Comparator 來比較。
  最後一個BlockingQueue實現是SynchronousQueue,實際上它不是一個真正的隊列,因爲它不會爲隊列中元素維護存儲空間。與其他隊列不同的是,它維護一組線程,這些線程在等待着把元素加入或移出隊列。如果以洗盤子的比喻爲例,那麼這就相當於沒有盤架,而是將洗好的盤子直接放入下一個空閒的烘乾機中。這種實現隊列的方式看似很奇怪,但由於可以直接交付工作,從而降低了將數據從生產者移動到消費者的延遲。(在傳統的隊列中,在一個工作單元可以交付之前,必須通過串行方式首先完成入列【Enqueue】或者出列【Dequeue】等操作。)直接交付方式還會將更多關於任務狀態的信息反饋給生產者。當交付被接受時,它就知道消費者已經得到了任務,而不是簡單地把任務放入一個隊列——這種區別就好比將文件直接交給同事,還是將文件放到她的郵箱中並希望她能儘快拿到文件。因爲SynchronousQueue沒有存儲功能,因此 put()和 take() 會一直阻塞,直到有另一個線程已經準備好參與到交付過程中。僅當有足夠多的消費者,並且總是有一個消費者準備好獲取交付的工作時,才適合使用同步隊列。

雙端隊列與工作密取

  Java 6增加了兩種容器類型,Deque(發音爲"deck")和BlockingDeque,它們分別對Queue和BlockingQueue進行了擴展。Deque是一個雙端隊列,實現了在隊列頭和隊列尾的高效插入和移除。具體實現包括ArrayDeque和LinkedBlockingDeque。

  正如阻塞隊列適用於生產者-消費者模式,雙端隊列同樣適用於另一種相關模式,即工作密取(Work Stealing)。在生產者一消費者設計中,所有消費者有一個共享的工作隊列,而在工作密取設計中,每個消費者都有各自的雙端隊列。如果一個消費者完成了自己雙端隊列中的全部工作,那麼它可以從其他消費者雙端隊列末尾祕密地獲取工作。密取工作模式比傳統的生產者一消費者模式具有更高的可伸縮性,這是因爲工作者線程不會在單個共享的任務隊列上發生競爭。在大多數時候,它們都只是訪問自己的雙端隊列,從而極大地減少了競爭。當工作者線程需要訪問另一個隊列時,它會從隊列的尾部而不是從頭部獲取工作,因此進一步降低了隊列上的競爭程度。
  工作密取非常適用於既是消費者也是生產者問題————當執行某個工作時可能導致出現更多的工作。例如,在網頁爬蟲程序中處理一個頁面時,通常會發現有更多的頁面需要處理。類似的還有許多搜索圖的算法,例如在垃圾回收階段對堆進行標記,都可以通過工作密取機制來實現高效並行。當一個工作線程找到新的任務單元時,它會將其放到自己隊列的末尾(或者在工作共享設計模式中,放入其他工作者線程的隊列中)。當雙端隊列爲空時,它會在另一個線程的隊列隊尾查找新的任務,從而確保每個線程都保持忙碌狀態。

同步工具類

  同步工具類可以是任何一個對象,只要它根據其自身的狀態來協調線程的控制流。阻塞隊列可以作爲同步工具類,其他類型的同步工具類還包括信號量(Semaphore)、柵欄(Barrier)以及閉鎖(Latch)。在平臺類庫中還包含其他一些同步工具類的類。
  所有的同步工具類都包含一些特定的結構化屬性它們封裝了一些狀態,這些狀態將決定執行同步工具類的線程是繼續執行還是等待,此外還提供了一些方法對狀態進行操作,以及另一些方法用於高效地等待同步工具類進入到預期狀態。

閉鎖

  閉鎖是一種同步工具類,可以延遲線程的進度直到其達到終止狀態。閉鎖的作用相當於一扇門:在閉鎖到達結束狀態之前,這扇門一直是關閉的,並且沒有任何線程能通過。當達到結束狀態時,這扇門會打開並允許所有的線程通過。當閉鎖狀態到達結束狀態後,將不會再改變狀態,因此這扇門將永遠保持打開狀態。閉鎖可以用來確保某些活動直到其他活動都完成後才能繼續執行。
  CountDownLatch是一種靈活的閉鎖實現,它可以使一個或多個線程等待一組事件發生。閉鎖狀態包括一個計數器,該計數器被初始化成一個正數,表示需要等待事件的數量。countDown()方法遞減計數器,表示已經有一個事件發生了,而await()方法等待計數器達到零,這表示所有需要等待的事件都已經發生。如果計數器的值非零,那麼await()會一直阻塞到計數器的值爲零,或者等待中的線程中斷,或者等待超時。下面是一個測試n個線程併發執行某個任務所需要的時間示例:

public class TestHarness {

    public static void main(String[] args) throws InterruptedException {
        long time = timeTasks(5, () -> System.out.println("Mr.Sun"));
        System.out.println(time);
    }

    public static long timeTasks(int nThreads, final Runnable task) throws InterruptedException {
        // 起始門,初始值爲1
        final CountDownLatch startGate = new CountDownLatch(1);
        // 結束門,初始值爲工作線程數
        final CountDownLatch endGate = new CountDownLatch(nThreads);

        for (int i = 0; i < nThreads; i++) {
            Thread t = new Thread(() -> {
                try {
                    // 每個工作線程都會在啓動門上等待,從而確保所有線程都就緒後纔開始執行
                    startGate.await();
                    try {
                        task.run();
                    } finally {
                        // 每個線程要做的最後一件事就是調用結束門的countDown()方法減1
                        endGate.countDown();
                    }
                } catch (InterruptedException e) {
                }
            });
            t.start();
        }

        long start = System.nanoTime();
        // 起始門:使得主線程能夠同時釋放所有工作線程
        startGate.countDown();
        // 結束門:使主線程能夠等待最後一個工作線程執行完成,而不是順序地等待每個線程執行完成
        endGate.await();
        // 統計消耗時間
        long end = System.nanoTime();
        return end - start;
    }
}

FutureTask

  FutureTask實現了Future語義,表示一種抽象的可生成結果的計算。FutureTask表示的計算是通過Callable來實現的,相當於一種可生成結果的Runnable,並且可以處於以下三種狀態:等待運行、正在運行和運行完成。“執行完成”表示計算的所有可能結束方式,包括正常結束、由於取消而結束和由於異常而結束等。當FutureTask進入完成狀態後,他會永遠停止在這個狀態上。
  Future.get()的行爲取決於任務的狀態。如果任務已完成,那麼get()會立即返回結果,否則get()將阻塞直到任務進行完成狀態,然後返回結果或者拋出異常。FutureTask將計算結果從執行線程傳遞到獲取這個結果的線程,而FutureTask的規範確保了這種傳遞過程能實現結果的安全發佈。
  FutureTask在Executor框架中表示異步任務,此外還可以用來表示一些計算時間較長的計算,這些計算可以在使用結算結果之前啓動。如下代碼中的Preloader就使用了FutureTask來執行一個高開銷的計算,並且計算結果將在稍後使用,通過提前啓動結算,可以減少等待結果時需要的時間。

/**
 * @author: Mr.Sun
 * @create: 2022-11-14 09:54
 * @description: 使用FutureTask來提前加載稍後需要的數據
 **/
public class Preloader {
    private final FutureTask<ProductInfo> future = new FutureTask<>(new Callable<ProductInfo>() {
        @Override
        public ProductInfo call() throws DataLoadException {
            // 模擬 從數據庫加載產品信息
            return loadProductInfo();
        }
    });

    private final Thread thread = new Thread(future);

    /**
     * 由於在構造函數或靜態初始化方法中啓動線程並不是一種好方法,因此提供了一個start()方法來啓動線程
     */
    public void start() { thread.start(); }

    /**
     * 獲取產品信息
     * 
     * @return 加載好的產品信息
     */
    public ProductInfo get() throws DataLoadException, InterruptedException {
        try {
            return future.get();
        } catch (ExecutionException e) {
            Throwable cause = e.getCause();
            if (cause instanceof DataLoadException)
                throw (DataLoadException) cause;
            else
                throw LaunderThrowable.launderThrowable(cause);
        }
    }

    /**
     * 高開銷計算實現邏輯
     * 
     * @throws DataLoadException 數據加載異常
     */
    private ProductInfo loadProductInfo() throws DataLoadException {
        
        return null;
    }
}

class ProductInfo {}

class DataLoadException extends Exception { }

信號量

  計數信號量用來控制同時訪問某個特定的資源的操作數量,或者同時執行某個指定操作的數量。計數信號量還可以用來實現某種資源池,或者對容器施加邊界。
  Semaphore中管理着一組虛擬的許可,許可的初始數量可通過構造函數來指定。在執行操作時可以首先獲得許可(只要還有剩餘的許可),並在使用以後釋放許可。如果沒有許可,那麼acquire()將阻塞直到有許可(或者直到被中斷或者操作超時)。release()方法將返回一個許可給信號量。計數信號量的一種簡化形式是二值信號量,即初始值爲1的Semaphore。二值信號量可以用作互斥體(mutex),並具備不可重入的加鎖含義:誰擁有這個唯一的許可,誰就擁有了互斥鎖。
  使用Semaphore可以將任何一種容器變成有界阻塞容器,如程序BoundedHashSet所示:

/**
 * @author: Mr.Sun
 * @create: 2022-11-14 10:22
 * @description: 使用Semaphore爲容器設置邊界
 **/
public class BoundedHashSet<T> {
    private final Set<T> set;
    private final Semaphore semaphore;

    public BoundedHashSet(int bound) {
        this.set = Collections.synchronizedSet(new HashSet<>());
        // 初始化信號量的計數值爲容器容量的最大值
        this.semaphore = new Semaphore(bound);
    }

    /**
     * 向容器中添加一個元素
     * 
     * @param obj 代添加對象
     * @return 是否添加成功
     */
    public boolean add(T obj) throws InterruptedException {
        // 添加一個元素之前,需要先獲得一個許可
        semaphore.acquire();
        boolean wasAdded = false;
        try {
            wasAdded = set.add(obj);
            return wasAdded;
        } finally {
            // 如果add操作沒有添加任何元素,那麼會立刻釋放許可
            if (! wasAdded) {
                semaphore.release();
            }
        }
    }

    /**
     * 刪除操作
     * 
     * @param obj 待刪除對象
     * @return 是否刪除成功
     */
    public boolean remove(T obj) {
        boolean wasRemoved = set.remove(obj);
        if (wasRemoved) {
            // 若刪除一個元素,就會釋放一個許可
            semaphore.release();
        }
        return wasRemoved;
    }
}

柵欄

  我們已經看到通過閉鎖來啓動一組相關的操作,或者等待一組相關的操作結束。閉鎖是一次性對象,一旦進入終止狀態,就不能被重置。
  柵欄(Barrier)類似於閉鎖,他能阻塞一組線程直到某個事件發生。柵欄和閉鎖的關鍵區別在於,所有的線程必須同時達到柵欄位置,才能繼續執行。閉鎖用於等待事件,而柵欄用於等待其他線程。
  CyclicBarrier可以使一定數量的參與方反覆在柵欄位置彙集,它在並行迭代算法中非常有用:這種算法通常將一個問題拆分成一系列相互獨立的子問題。當線程達到柵欄位置時將調用await()方法,這個方法將阻塞直到所有線程都達到柵欄位置。如果所有線程都達到了柵欄位置,那麼柵欄將打開,此時所有線程都被釋放,而柵欄將被重置以便下次使用,如果對await()的調用超時,或者await()阻塞的線程被中斷,那麼柵欄就被認爲是打破了,所有阻塞的await()調用都將終止並拋出BrokenBarrierException。如果成功通過柵欄,那麼await()將爲每個線程返回一個唯一的到達索引號,我們可以利於這些索引號來“選舉”產生一個領導線程,並在下一次迭代中由該領導線程執行一些特殊的工作。CyclicBarrier還可以使你將一個柵欄操作傳遞給構造函數,這是一個Runnable,當成功通過柵欄時會執行它,但在阻塞線程被釋放之前是不能執行的。

  在如下程序CellularAutomata中給出瞭如何通過柵欄來計算細胞的自動化模擬。再把模擬化過程進行並行化時,爲每個細胞分配一個獨立的線程是不現實的,因爲這將產生過多的線程,而在協調這些線程上導致的開銷將降低計算性能。合理的做法是,將問題分解成一定數量的子問題,爲每個子問題分配一個線程進行求解,之後再將所有的結果合併起來。CellularAutomata將問題分解成\(N_{cpu}\)個子問題,其中\(N_{cpu}\)等於可用CPU數量,並將每個子問題分配給一個線程,在每個步驟中,工作線程都會爲各自子問題中的所有細胞計算新值。當所有工作線程都到達柵欄時,柵欄會將這些新值提交給數據模型。在柵欄的操作執行完成後,工作線程將開始下一步的計算,包括調用isDone()方法判斷是否需要進行下一次迭代。

/**
 * @author: Mr.Sun
 * @create: 2022-11-14 11:18
 * @description: 通過CyclicBarrier協調細胞自動衍生系統中的計算
 **/
public class CellularAutomata {

    private final Board mainBoard;
    private final CyclicBarrier barrier;
    private Worker[] workers;

    public CellularAutomata(Board board) {
        this.mainBoard = board;
        // 獲取可用cpu數
        int count = Runtime.getRuntime().availableProcessors();
        this.barrier = new CyclicBarrier(count, new Runnable() {
            @Override
            public void run() {
                mainBoard.commitNewValues();
            }
        });
        this.workers = new Worker[count];
        for (int i = 0; i < count; i++) {
            workers[i] = new Worker(mainBoard.getSubBoard(count, i));
        }
    }

    public void start() {
        for (int i = 0; i < workers.length; i++) {
            new Thread(workers[i]).start();
        }
        mainBoard.waitForConvergence();
    }

    private class Worker implements Runnable {
        private final Board board;

        public Worker(Board board) {
            this.board = board;
        }

        @Override
        public void run() {
            while (!board.hasConverged()) {
                for (int x = 0; x < board.getMaxX(); x++)
                    for (int y = 0; y < board.getMaxY(); y++)
                        board.setNewValue(x, y, computeValue(x, y));
                try {
                    barrier.await(); //
                } catch (InterruptedException ex) {
                    return;
                } catch (BrokenBarrierException ex) {
                    return;
                }
            }
        }

        private int computeValue(int x, int y) {
            // Compute the new value that goes in (x,y)
            return 0;
        }
    }
}

  另一種形式的柵欄是Exchanger,他是一種兩方柵欄,各方在柵欄位置上交換數據,當兩方執行不對稱的操作時,Exchanger會非常有用,例如當一個線程向緩衝區寫入數據,而另一個線程從緩存區中讀取數據,這些線程可以使用Exchanger來匯合,並將滿的緩衝區與空的緩衝區進行交換,當兩個線程通過Exchanger交換對象時,這種交換就把這兩個對象安全發佈給另一方。

小結

  • 可變狀態是至關重要的。所有的併發訪問題都可以歸結爲如何協調對併發狀態的訪問,可變狀態越少,就越容易確保線程安全性。
  • 儘量將域聲明爲final類型,除非需要它們是可變的。
  • 不可變對象一定是線程安全的。不可變對象能極大地降低併發編程的複雜性,它們更爲簡單而且安全,可以任意共享而且無需使用加鎖或保護性複製等機制。
  • 封裝有助於管理複雜性。在編寫線程安全的程序時,雖然可以將所有的數據都保存在全局變量中,但爲什麼要這麼做?將數據封裝在對象中,更易於維持不變性條件;將同步機制封裝在對象中,更易於遵循同步策略。
  • 用鎖來保護每個可變變量。
  • 當保護同一個不變性條件中的所有變量時,要使用同一個鎖。
  • 在執行復合操作期間,要持有鎖。
  • 如果從多個線程中訪問同一個可變變量時沒有使用同步機制,那麼程序會出問題。
  • 不要故作聰明地推斷出不需要使用同步。
  • 在設計過程中考慮線程安全,或者在文檔中指出它不是線程安全的。
  • 將同步策略文檔化。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章