讀古今文學網 > Java程序員修煉之道 > 4.2 塊結構並發(Java 5之前) >

4.2 塊結構並發(Java 5之前)

本章大部分內容都在討論塊同步並發方式的替代方案。如果你想從我們的討論中獲益,就需要深刻理解傳統並發的優缺點的重要性。

為此我們要討論用到synchronizedvolatile等並發關鍵字的那種原始、低級的多線程編程方式。我們把這個討論放在設計原則的情境中,並且會著眼於下一節將要討論的內容。

之後我們會簡略地解釋一下線程的生命週期,然後討論常見的並發編程技巧和陷阱,比如完全同步的對象,死鎖,volatile關鍵字和不變性。

我們先重溫一下同步吧。

4.2.1 同步與鎖

你知道的,synchronized既可以用在代碼塊上也可以用在方法上。它表明在執行整個代碼塊或方法之前線程必須取得合適的鎖。對於方法而言,這意味著要取得對像實例鎖(對於靜態方法而言則是類鎖)。對於代碼塊,程序員則應該指明要取得哪個對象的鎖。

在任何一個對象的同步塊或方法中,每次只能有一個線程進入;如果其他線程試圖進入,JVM會掛起它們。無論其他線程試圖進入的是該對象的同一同步塊還是不同的同步塊,JVM都會如此處理。這種結構在並發理論中被稱為臨界區。

注意 你有沒有想過Java中用於確立臨界區的關鍵字為什麼是synchronized?為什麼不是「critical」或「locked」?同步的是什麼?我們會在4.2.5節回到這一話題上來,但如果你不知道,或者從來沒想過這個問題,你最好花幾分鐘想一想再繼續。

本章要討論一些比較新的並發技術。但既然說到了同步,我們就順便看看與Java中的同步和鎖相關的一些基本事實吧。希望你對這裡的大多數(或全部)知識都已經爛熟於心了。

  • 只能鎖定對象,不能鎖定原始類型。

  • 被鎖定的對象數組中的單個對象不會被鎖定。

  • 同步方法可以視同為包含整個方法的同步(this) { ... }代碼塊(但要注意它們的二進制碼表示是不同的)。

  • 靜態同步方法會鎖定它的Class對象,因為沒有實例對象可以鎖定。

  • 如果要鎖定一個類對象,請慎重考慮是用顯式鎖定,還是用getClass,兩種方式對子類的影響不同。

  • 內部類的同步是獨立於外部類的(要明白為什麼會這樣,請記住內部類是如何實現的)。

  • synchronized並不是方法簽名的組成部分,所以不能出現在接口的方法聲明中。

  • 非同步的方法不查看或關心任何鎖的狀態,而且在同步方法運行時它們仍能繼續運行。

  • Java的線程鎖是可重入的。也就是說持有鎖的線程在遇到同一個鎖的同步點(比如一個同步方法調用同一個類內的另一個同步方法)時是可以繼續的。

警告 在其他語言中存在不可重入的鎖機制(用java也能實現相同的效果,如果你想瞭解那些讓人看了毛骨悚然的詳細信息,請參見java.util.concurrent.locks中的ReentrantLock的Javadoc),但和它們打交道太痛苦了,除非你真的知道自己在做什麼,否則還是躲之為妙。

對Java同步的溫習就到此為止吧。現在我們來看一下線程在其生命週期中的狀態變遷。

4.2.2 線程的狀態模型

圖4-2展示了線程生命週期的發展過程——從創建到運行,到再次運行之前(或被資源阻塞)可能被掛起,再到最終完成。

圖4-2 Java的線程狀態模型

線程最初創建時處於就緒(Ready)狀態。然後調度器會找個核心來運行它,如果機器負載過重,那它就可能需要多些時間。開始運行之後,線程通常會消耗掉分配給它的時間,然後回到就緒狀態,等到下次再有處理器分配時間片給它。這是我們在4.1.1節提過的搶佔式線程調度的標準動作。

除了由調度器發起的標準動作,線程本身也能表明它此時無法使用核心工作。這可能是因為程序代碼通過Thread.sleep告訴線程在繼續之前應該暫停,或者因為線程必須等待通知(通常需要滿足某些外部條件)。這時線程會從核心中移走,並釋放它持有的鎖。只有通過喚醒才能再次運行線程(在達到睡眠時長之後,或收到了恰當的信號),進入就緒狀態。

線程可能會因為等待I/O或等待獲取其他線程持有的鎖而被阻塞。這時線程並沒有被交換出核心,而是仍然處於繁忙狀態,等著獲取可用的鎖或數據。在得到鎖或數據之後,線程會繼續執行直到它的時間片結束。

我們接下來討論一個著名的解決同步問題的辦法——完全同步對象。

4.2.3 完全同步對像

前面介紹了並發類型安全的概念,還提到了一種用來達成這種安全性的策略(在「保證安全」的邊欄中)。現在我們來看一下這個策略更完整的描述,它通常被稱為完全同步對象。如果一個類遵從下面所有規則,就可以認為它是線程安全並且活躍的。

一個滿足下面所有條件的類就是完全同步類。

  • 所有域在任何構造方法中的初始化都能達到一致的狀態。
  • 沒有公共域。
  • 從任何非私有方法返回後,都可以保證對像實例處於一致的狀態(假定調用方法時狀態是一致的)。
  • 所有方法經證明都可在有限時間內終止。
  • 所有方法都是同步的。
  • 當處於非一致狀態時,不會調用其他實例的方法。
  • 當處於非一致狀態時,不會調用非私有方法。

假定有一個分佈式微博工具,代碼清單4-1是其後台中的類。在它的propagateUpdate方法被調用時,ExampleTimingNode類會收到更新,也可以通過查詢看它是否收到了特定更新。這是經典的讀寫操作相互衝突的情景,需要通過同步防止出現不一致狀態。

代碼清單4-1 完全同步類

public class ExampleTimingNode implements SimpleMicroBlogNode {
    /*沒有公開域*/
  private final String identifier;
  private final Map<Update, Long> arrivalTime = new HashMap<>;
  //所有域在構造方法中初始化
  public ExampleTimingNode(String identifier_) {
    identifier = identifier_;
  }
  /*所有方法都是同步的*/
  public synchronized String getIdentifier {
    return identifier;
  }
  public synchronized void propagateUpdate(Update update_) {
    long currentTime = System.currentTimeMillis;
    arrivalTime.put(update_, currentTime);
  }
    public synchronized boolean confirmUpdateReceived(Update update_) {
      Long timeRecvd = arrivalTime.get(update_);
      return timeRecvd != null;
  }
}
  

這是一個既安全又活躍的類,第一眼看上去讓人感覺很了不起。但隨之而來的是性能問題,既安全又活躍的東西速度不一定也能很快。必須用synchronized去協調對Map arrivalTime的所有訪問(getput),而這個鎖最終會把你的速度拖慢。這是並發處理方式的主要問題。

代碼的脆弱性

除了性能問題,代碼清單4-1中的代碼還很脆弱。你看,它從來不會在同步方法之外去碰arrivalTime,實際上只是調用getput方法,但這只有在代碼量很小的情況下才有可能。在真實的大型系統中,代碼太多而無法實現這種方法。同時,bug也很容易潛伏在龐大的代碼庫中,這也是Java社區開始尋求更完善的解決方法的另一個原因。

4.2.4 死鎖

並發的另一個經典問題是死鎖。代碼清單4-2稍微擴展了一下上個例子。在這一版中,除了記錄最近一次更新的時間,每個節點收到更新時還會通知另外一個節點。

這段代碼試圖構建一個多線程的更新處理系統。注意,這段代碼是為了解釋死鎖,不要把它用到你的工作中。

代碼清單4-2 死鎖的例子

public class MicroBlogNode implements SimpleMicroBlogNode {
  private final String ident;
  public MicroBlogNode(String ident_) {
    ident = ident_;
  }
    public String getIdent {
      return ident;
  }
    public synchronized void propagateUpdate(Update upd_, MicroBlogNode
       backup_) {
      System.out.println(ident +\": recvd: \"+ upd_.getUpdateText +\" ; backup: \"+backup_.getIdent);
      backup_.confirmUpdate(this, upd_);
  }
  public synchronized void confirmUpdate(MicroBlogNode other_, Update
     update_) {
    System.out.println(ident +\": recvd confirm: \"+update_.getUpdateText +\" from \"+other_.getIdentk);
  }
}
//關鍵字final是必需的
final MicroBlogNode local = new MicroBlogNode(\"localhost:8888\");
final MicroBlogNode other = new MicroBlogNode(\"localhost:8988\");
final Update first = getUpdate(\"1\");
final Update second = getUpdate(\"2\");

new Thread(new Runnable {
  public void run {
    local.propagateUpdate(first, other);//第一個更新發送給第一個線程
    }
 }).start;
 new Thread(new Runnable {
   public void run {
     other.propagateUpdate(second, local);//第二個更新發送給第二個線程
  }
}).start;
  

乍一看,這段代碼沒什麼毛病。有兩個更新分別發送給不同的線程,每個都必須由後備線程進行確認。這看起來不是什麼離奇古怪的設計——如果一個線程失效,另外一個線程還可以挑起重擔。

如果你運行這段代碼,一般都會碰到死鎖——兩個線程都說自己收到了更新,但它倆誰都不會以備份線程的身份確認收到了更新。因為每個線程在確認方法能夠確認之前都要求另外一個線程釋放線程鎖,如圖4-3所示。

圖4-3 死鎖線程

有一個處理死鎖的技巧,就是在所有線程中都以相同的順序獲取線程鎖。在前例中,第一個線程以A、B的順序獲取鎖,而第二個線程獲取鎖的順序是B、A。如果兩個線程都用A、B的順序,死鎖的情況就可以避免,因為第二個線程在第一個線程完成並釋放鎖之前會一直被阻塞住。

就完全同步對像方式而言,要防止這種死鎖出現是因為代碼破壞了狀態一致性規則。當有消息到達時,接受節點會在消息處理過程中調用另外一個對像——它發起這個調用時狀態是不一致的。

接下來,我們會返回來解釋前面拋出的那個問題:為什麼Java中用來標識臨界區的關鍵字是synchronized?這會引導我們轉而討論不可變性和關鍵字volatile

4.2.5 為什麼是synchronized

最近幾年並發編程變化最大的是硬件領域。在以前,程序員可能常年累月都碰不到需要支持多處理器核心(兩個或最多三個)的系統。因此並發編程過去主要考慮如何分享CPU時間——線程們在單核上輪流上位,相互調換。

現如今,任何比手機大點兒的東西都是多核的,所以我們的認知模型也該換換了,應該把多個線程在同一物理時刻運行在不同核心(並且很可能會操作共享的數據)的情況也考慮在內。如圖4-4所示。為了提高效率,同時運行的每個線程可能都會有它正在處理的數據的緩存復本。記住這幅圖,讓我們回到選擇用什麼關鍵字來表示被鎖定的代碼塊或方法這個問題上。

圖4-4 考慮並發和線程的新、老方式

我們在前面問過,代碼清單4-1中被同步的是什麼?答案是:被同步的是在不同線程中表示被鎖定對象的內存塊。也就是說,在synchronized代碼塊(或方法)執行完之後,對被鎖定對像所做的任何修改全部都會在線程鎖釋放之前刷回到主內存中,如圖4-5所示:

圖4-5 不同線程對一個對象的修改通過主內存傳播

另外,當進入一個同步的代碼塊,得到線程鎖之後,對被鎖定對象的任何修改都是從主內存中讀出來的,所以在鎖定區域代碼開始執行之前,持有鎖的線程就和鎖定對像主內存中的視圖同步了。

4.2.6 關鍵字volatile

Java在其混沌初開的時期(Java 1.0)就已經把volatile作為關鍵字了,它是一種簡單的對象域同步處理辦法,包括原始類型。一個volatile域需遵循如下規則:

  • 線程所見的值在使用之前總會從主內存中再讀出來。
  • 線程所寫的值總會在指令完成之前被刷回到主內存中。

可以把圍繞該域的操作看成是一個小小的同步塊。程序員可以借此編寫簡化的代碼,但付出的代價是每次訪問都要額外刷一次內存。還有一點要注意,volatile變量不會引入線程鎖,所以使用volatile變量不可能發生死鎖。

更加微妙的是,volatile變量是真正線程安全的,但只有寫入時不依賴當前狀態(讀取的狀態)的變量才應該聲明為volatile變量。對於要關注當前狀態的變量,只能借助線程鎖保證其絕對安全性。

4.2.7 不可變性

不可變對象的應用是十分有價值的技術。這些對像或沒有狀態,或只有final域(因此只能在構造方法中賦值)。它們總是安全而又活躍的。它們的狀態不能修改,所以不可能出現不一致的情況。

可這樣對像初始化的所有值都必須傳入構造方法。這會導致構造方法的參數很多,看起來又蠢又笨。因此很多程序員選擇工廠方法FactoryMethod代替構造方法。工廠方法很簡單,就是類中的一個靜態方法,用來代替構造方法創建新對象。此時構造方法通常被聲明為protectedprivate的,從而使工廠方法成為實例化對象的唯一辦法。

但是還存在要將眾多參數傳入FactoryMethod的問題。有時候這不太方便,尤其是初始化對像所需的狀態參數有多個不同來源時。

構建器模式可以解決這個問題。它由兩部分組成:一個是實現了構建器泛型接口的內部靜態類,另一個是構建不可變類實例的私有構造方法。

內部靜態類是不可變類的構建器,開發人員只能通過它獲取不可變類的新實例。比較常見的實現方式是讓構建器類擁有與不可變類一模一樣的域,但構建器的域是可修改的。

下面這段代碼展示了如何建立不可變的微博更新模型(根據本章前面的例子所構建)。

代碼清單4-3 不可變對象及構建器

/**構建器接口*/
public interface ObjBuilder<T> {
  T build;
}

public class Update {
  //必須在構造方法中初始化final域
  private final Author author;
  private final String updateText;

  private Update(Builder b_) {
    author = b_.author;
    updateText = b_.updateText;
  }
  /**構造器類必須是靜態內部類*/
  public static class Builder implements ObjBuilder<Update> {
    private Author author;
    private String updateText;
    /**可用在調用鏈中返回Builder的方法*/
    public Builder author(Author author_) {
      author = author_;
      return this;
    }
    public Builder updateText(String updateText_) {
      updateText = updateText_;
      return this;
    }
    public Update build {
      return new Update(this);
    }
  }
  //略去hashCode 和equals方法
}
  

有了這段代碼,你就可以創建新的Update對像:

Update.Builder ub = new Update.Builder;
Update u = ub.author(myAuthor).updateText(\"Hello\").build;
  

這是一個得到廣泛應用的通用模式。實際上,在代碼清單4-1和4-2中我們已經使用了不可變對象的特性。

關於不可變對象的最後一點——關鍵字final僅對其直接指向的對象有用。如圖4-6所示,對主對象的引用不能賦值為對像3,但在主對像內部,對1的引用可以改為指向對像2。也就是說final引用可以指向帶有非final域的對象。

圖4-6 值的不可變性與引用

不可變是非常強的技術,用處十分廣泛。但有時候只用不可變對像開發效率不行,因為每次修改對像狀態就需要構建一個新對象。所以可變對像很有必要保留。

我們馬上就要開始討論本章最重要的主題——java.util.concurrent中更加現代化、概念更簡單的並發API。看看怎麼用它們寫代碼。