多核系統上的 Java 併發缺陷模式(bug patterns)

對於多線程編程經驗較少的程序員而言,開發多核系統軟件將面臨兩個方面的問題:首先,併發會給 Java 程序引入新的缺陷,如數據速度和死鎖,它們是很是難以復現和發現的。其次,許多程序員並不知道特定多線程編程方法的微妙細節,而這可能會致使代碼錯誤。java

爲了不給併發程序引入缺陷,Java 程序員必須瞭解如何識別缺陷在多線程代碼中極可能出現的關鍵位置,而後纔可以編寫出沒有缺陷的軟件。在本文中,咱們將幫助 Java 開發人員在理解併發編程早期和中期會遇到的問題。咱們並不會關注於常見的 Java 併發缺陷模式,如雙重檢查鎖、循環等待和等待不在循環內項目,咱們將介紹 6 個不爲人知的模式,可是卻常常出如今真實的 Java 應用程序中。事實上,咱們的前兩個例子就是在兩個流行的 Web 服務器上發現的缺陷。程序員

1. Jetty 的一個反模式

咱們要介紹的第一個併發缺陷是在普遍使用的開源 HTTP 服務器 Jetty 上發現的。這是已通過 Jetty 社區確認的一個真實缺陷(見 參考資料 的缺陷報告)。編程

清單 1. 在一個易變(volatile)域上不獲取鎖的狀況下執行非原子操做數組

// Jetty 7.1.0,
// org.eclipse.jetty.io.nio,
// SelectorManager.java, line 105

private volatile int _set;
......
public void register(SocketChannel channel, Object att)
{
   int s=_set++;
   ......
}
......
public void addChange(Object point)
{
   synchronized (_changes)
   {
      ......
   }
}

清單 1 中的錯誤有如下幾個部分:安全

  • 首先,_set 被聲明爲 volatile,這表示這個域能夠由多個線程訪問。
  • 可是, _set++ 並非原子操做,這意味着它不會以單個不可分割操做執行。相反,它只是包含三個具體操做序列的簡寫方法:read-modify-write
  • 最後, _set++ 並無鎖保護。若是方法 register 同時由多個線程調用,那麼它會產生一個競爭狀態,致使出現錯誤的 _set 值。

您的代碼也可能和 Jetty 同樣出現這種類型的錯誤,因此讓我更詳細地分析一下它是如何發生的。性能優化

缺陷模式構成元素

分析它的邏輯執行代碼有助於弄清楚這個缺陷模式。變量 i 的操做,如bash

i++
--i
i += 1
i -= 1
i *= 2

等,另外就是非原子操做(即 read-modify-write)。若是您知道 volatile 關鍵字在 Java 語言中僅僅保證變量的可見性,而不保證原子性,那麼這應該會引發您的注意。一個易變域上的不受鎖保護的非原子操做可能 會產生一個競爭情況 — 可是隻有在多個線程併發訪問非原子操做時纔可能出現。服務器

在一個線程安全的程序中,只有一個寫線程可以修改這個變量;而其餘的線程則能夠讀取 volatile 聲明變量的最新值。數據結構

因此,代碼是否有問題取決於有多少線程可以併發地訪問這個操做。若是這個非原子操做僅僅由一個線程調用,因爲是有一個開始聯合關係或者外部鎖,那麼這樣的編碼方法也是線程安全的。多線程

必定要謹記 volatile 關鍵字在 Java 代碼中僅僅保證這個變量是可見的:它不保證原子性。在那些非原子且可由多個線程訪問的易變操做中,必定不可以依賴於 volatile 的同步機制。相反,要使用 java.util.concurrent 包的同步語句、鎖類和原子類。它們在設計上可以保證程序是線程安全的。

2. 在易變域上的同步

在 Java 語言中,咱們使用了同步語句來獲取互斥鎖,這能夠保護多線程系統的共享資源訪問。然而,易變域的同步中會有一個漏洞,它可能破壞互斥。解決的方法是必定要將同步的域聲明爲 private final。讓咱們先來仔細看看問題是如何產生的。

修改域上的同時訪問鎖

同步語句是由同步域所引用對象保護的,而不是由域自己保護的。若是一個同步域是易變的(這意味着這個域在初始化以後可能在程序的其餘位置賦值),這極可能不是有用的語義,由於不一樣的線程可能同時訪問不一樣的對象。

您能夠在清單 2 中看到這個問題,這是節選自開源 Web 應用服務器 Tomcat 的代碼片段:

清單 2. Tomcat 的錯誤

public void addInstanceListener(InstanceListener listener) {
   synchronized (listeners) {
       InstanceListener results[] =
        new InstanceListener[listeners.length + 1];
     for (int i = 0; i < listeners.length; i++)
          results[i] = listeners[i];
      results[listeners.length] = listener;
      listeners = results;
   }

}

假設 listeners 引用的是數組 A,而線程 T1 首先獲取數組 A 的鎖,而後開始建立數組 B。同時,T2 開始執行,而且因爲數據 A 的鎖而被阻擋。當 T1 完成數組 B 的 listeners 設置後,退出這個語句,T2 會鎖住數組 A,而後開始複製數組 B。而後 T3 開始執行,並鎖住數組 B。由於它們得到了不一樣的鎖,T2 和 T3 如今能夠同時複製數組 B。

圖 1 更進一步地說明了這個執行順序:

圖 1. 因爲易變域的同步而失去互斥鎖

圖片說明了因爲易變域的同步而失去互斥鎖。

無數的意外行爲可能會致使這種狀況出現。至少,其中一個新的監聽器可能會丟失,或者其中一個線程可能會發生 ArrayIndexOutOfBoundsException 異常(因爲 listeners 引用及其長度可能在方法的任意時刻發生變化)。

好的作法是老是將同步域聲明爲 private final,這可以保證鎖對象保持不變,而且保證了互斥(mutex)。

3. java.util.concurrent 鎖泄漏

一個實現 java.util.concurrent.locks.Lock 接口的鎖控制着多個線程是如何訪問一個共享資源的。這些鎖不須要使用語句結構,因此它們比同步方法或語句更靈活。然而,這種靈活性可能致使編碼錯誤,由於不使用語句的鎖是不會自動釋放的。若是一個 Lock.lock() 調用沒有在同一個實例上執行相應的 unlock() 調用,其結果就可能形成一個鎖泄漏。

若是忽視關鍵代碼中的方法行爲,咱們就很容易形成 java.util.concurrent 鎖泄漏,有可能拋出的異常。您能夠從清單 3 的代碼看到這一點,其中 accessResource 方法在訪問共享資源時拋出了一個 InterruptedException 異常。結果,unlock() 是不會被調用的。

清單 3. 分析一個鎖泄漏

private final Lock lock = new ReentrantLock();

public void lockLeak() {
   lock.lock();
   try {
      // access the shared resource
      accessResource();
      lock.unlock();
   } catch (Exception e) {}

public void accessResource() throws InterruptedException {...}

要保證鎖獲得釋放,咱們只須要在每個 lock 以後對應執行一個 unlock 方法,並且它們應該置於 try-finally 複雜語句中。清單 4 說明了這種方法:

清單 4. 老是將 unlock 調用置於 finally 語句中

private final Lock lock = new ReentrantLock();

public void lockLeak() {
   lock.lock();
   try {
      // access the shared resource
      accessResource();
   } catch (Exception e) {}
   finally {
      lock.unlock();
   }

public void accessResource() throws InterruptedException {...}

4. 同步語句的性能優化

有一些併發缺陷有時不會使代碼出錯,可是它們可能會下降應用程序的性能。考慮清單 5 中的 synchronized 語句:

清單 5. 帶有不變代碼的同步語句

public class Operator {
   private int generation = 0; //shared variable
   private float totalAmount = 0; //shared variable
   private final Object lock = new Object();

   public void workOn(List<Operand> operands) {
      synchronized (lock) {
         int curGeneration = generation; //requires synch
         float amountForThisWork = 0;
         for (Operand o : operands) {
            o.setGeneration(curGeneration);
            amountForThisWork += o.amount;
         }
         totalAmount += amountForThisWork; //requires synch
         generation++; //requires synch
      }
   }
}

清單 5 代碼中兩個共享變量的訪問是同步且正確的,可是若是仔細檢查,您會注意到 synchronized 語句所須要進行的計算過多。咱們能夠經過調整代碼順序來解決這個問題,如清單 6 所示:

清單 6. 沒有不變代碼的同步語句

public void workOn(List<Operand> operands) {
   int curGeneration;
   float amountForThisWork = 0;
   synchronized (lock) {
      int curGeneration = generation++;
   }
   for (Operand o : operands) {
      o.setGeneration(curGeneration);
      amountForThisWork += o.amount;
   }
   synchronized (lock)
      totalAmount += amountForThisWork;
   }
}

第二個版本代碼在多核機器上執行效果會更好。其緣由是清單 5 的同步代碼阻止了並行執行。這個方法循環可能會消耗大量的計算時間。在清單 6 中,循環被移出同步語句,因此它可能由多個線程並行執行。通常而言,在保證線程安全的前提下要儘量地簡化同步語句。

其餘方面……

您可能會有疑問,是否使用 AtomicInteger 和 AtomicFloat 來表示清單 5 和 6 中的兩個共享變量,而後去掉全部同步代碼,是否會更好一些。這取決於其餘方法是如何處理這些變量的,以及它們之間有什麼樣的依賴關係。

5. 多階段訪問

假設您的應用程序有兩個表:第一個表將員工姓名映射到一個員工號,另外一個將這個員工號映射到一個薪水記錄。這些數據須要支持併發訪問和更新,而您能夠經過線程安全的 ConcurrentHashMap 實現,如清單 7 所示:

清單 7. 兩個階段的訪問

public class Employees {
   private final ConcurrentHashMap<String,Integer> nameToNumber;
   private final ConcurrentHashMap<Integer,Salary> numberToSalary;

   ... various methods for adding, removing, getting, etc...

   public int geBonusFor(String name) {
      Integer serialNum = nameToNumber.get(name);
      Salary salary = numberToSalary.get(serialNum);
      return salary.getBonus();
   }
}

這種方法看起來是線程安全的,可是事實上不是這樣的。它的問題是 getBonusFor 方法並非線程安全的。在獲取這個序列號和使用它獲取薪水之間,另外一個線程可能從兩個表刪除員工信息。在這種狀況下,第二個映射訪問可能會返回 null,並拋出一個異常。

保證每個 Map 自己的線程安全是不夠的。它們之間存在一個依賴關係,並且訪問這兩個 Map 的一些操做必須是原子操做。在這裏,您可使用非線程安全的容器(如 java.util.HashMap),而後使用顯式的同步語句來保護每個訪問,從而實現線程安全。而後這個同步語句能夠在須要時包含這兩個訪問。

6. 對稱鎖死鎖

能夠考慮使用一個線程安全容器類 — 一個保證用戶操做線程安全的數據結構。(這與 java.util 中的大多數容器不一樣,它不須要用戶同步容器的使用。)在清單 8 中,一個可修改的成員變量負責保存數據,而一個鎖對象則保護全部對它的訪問。

清單 8. 一個線程安全的容器

public <E> class ConcurrentHeap {
   private E[] elements;
   private final Object lock = new Object(); //protects elements

   public void add (E newElement) {
      synchronized(lock) {
         ... //manipulate elements
      }
   }

   public E removeTop() {
      synchronized(lock) {
         E top = elements[0];
         ... //manipulate elements
         return top;
      }
   }
}

如今讓我添加一個方法,使用另外一個實例,並將它的全部元素添加到當前的實例中。這個方法須要訪問這兩個實例的 elements 成員,如清單 9 所示:

清單 9. 下面代碼會產生一個死鎖

public void addAll(ConcurrentHeap other) {
   synchronized(other.lock) {
      synchronized(this.lock) {
         ... //manipulate other.elements and this.elements
      }
   }
}

您認識到了死鎖的可能性嗎?假設一個程序只有兩個實例 heap1 和 heap2。若是其中一個線程調用了 heap1.addAll(heap2),而另外一個線程同時調用 heap2.addAll(heap1),那麼這兩個線程就可能遇到死鎖。換言之,假設第一個線程得到了 heap2 的鎖,可是它開始執行以前,第二個線程就開始執行方法,同時獲取了 heap1 鎖。結果,每個線程都會等待另外一個線程所保持的鎖。

您能夠經過肯定實例順序來防止對稱鎖死鎖,這樣當須要獲取兩個實例的鎖時,其順序是動態計算獲得的,並決定哪個鎖先獲取到。Brian Goetz 在他撰寫的書 Java Concurrency in Practice 中詳細討論這個方法(見 參考資料)。

不只僅發生在容器上

這個對稱鎖死鎖場景是很常見的,由於它出如今 Java 1.4 版本上,其中 Collections.synchronized 方法返回的一些同步容器會發生死鎖。可是,不只僅容器容易受到對稱鎖死鎖的影響。若是一個類有一個方法使用同一個類的其餘實例做爲參數,那麼這個類對這兩個實例成員的操做也必須是原子的。其中 compareTo 和 equals方法就是很好的兩個例子。

結束語

許多 Java 開發人員都只是剛剛開始瞭解多核環境併發程序的編寫方法。在這個過程當中,咱們要放棄所掌握的單線程編寫方法,而使用更復雜的多線程環境方法。研究併發缺陷模式是一個發現多線程編程問題的好方法,也有利於掌握這些方法的微妙之處。

您能夠學習從總體上把握缺陷模式的方法,這樣在您編寫代碼或檢查代碼時就可以發現一些特定的問題線索。您也可使用一些靜態分析工具來發現問題。FindBugs 就是一個可以查找代碼中可能的缺陷模式的開源靜態分析工具。事實上,FindBugs 可用於檢查本文討論的第二和第三個缺陷模式。

靜態分析工具的另外一個常見的缺點是它們會產生錯誤的警告,這樣您可能會浪費更多時間去檢查原本不是缺陷的問題。可是,如今出現了新的更適合於測試併發程序的動態分析工具。其中兩種是 IBM® Multicore Software Development Kit (MSDK) 和 ConcurrentTesting (ConTest),它們均可以從 IBM alphaWorks 上免費下載。

原文地址:IBM 開發者博客

相關文章
相關標籤/搜索