程序師世界是廣大編程愛好者互助、分享、學習的平台,程序師世界有你更精彩!
首頁
編程語言
C語言|JAVA編程
Python編程
網頁編程
ASP編程|PHP編程
JSP編程
數據庫知識
MYSQL數據庫|SqlServer數據庫
Oracle數據庫|DB2數據庫
 程式師世界 >> 編程語言 >> JAVA編程 >> 關於JAVA >> Java理論和實踐: 安全構造技術

Java理論和實踐: 安全構造技術

編輯:關於JAVA

Java 語言提供了靈活的、看上去很簡單的線程功能,使得您很容易在您的應用程序中使用多線程。然而,Java應用程序中的並發編程比看上去要復雜:在 Java 程序中,有一些微妙(也許並不是那麼微妙)方式會造成數據爭用(data race)以及並發問題。在這篇 Java 理論和實踐中,Brian探討了一個常見的線程方面的危險:在構造過程中,允許 this 引用逃脫(escape)。這個看上去沒有什麼危害的做法可以在 Java 程序中造成無法可預料和不期望的結果。

測試和調試多線程程序是極其困難的,因為並發性方面的危險常常不是以一致的方式顯現出來,甚至有時未必會顯現這種危險性。就線程問題的本質而言,大多數這些問題是無法預料的,甚至在某些平台上(如單處理器系統),或者低於一定的負載,問題可能根本就不出現。由於測試多線程程序的正確性是如此困難,以及查找錯誤是如此費時,因此從一開始開發應用程序就要在心中牢記線程的安全性,這一點就顯得尤為重要。在本文中,我們將研究一個特殊的線程安全方面的問題 ― 在構造過程中,允許 this 引用逃脫(我們稱之為 逃脫的引用問題) ― 該問題引起了一些未曾期望的結果。然後,為了編寫出線程安全的構造函數,我們給出一些准則。

遵循“安全構造”技術

分析程序來找出線程安全的違例是非常困難的,這需要專門的經驗。幸運的是(也許會感到吃驚),從一開始創建線程安全的類並不是那樣的困難,盡管這需要一種其它專門的技巧:規程。大多數並發性錯誤是來自程序員以方便、改善性能或只是一時的懶惰為名企圖違規而造成的。如許多其它並發性問題一樣,在編寫構造函數時,遵循一些簡單的規則就可以避免這個逃脫的引用問題。

危險的爭用狀態

大多數並發性危險歸根結底是由某類 數據爭用引起的。在多個線程或進程正在讀取和寫入一個共享數據項時,會發生數據爭用或進入 爭用狀態 ,最終結果取決於這些線程的調度次序。清單 1 給出了一個簡單的數據爭用的示例,其中程序可以打印 0 或者 1,這取決於對線程的調度。

清單 1. 簡單的數據爭用

public class DataRace {
  static int a = 0;
  public static void main() {
   new MyThread().start();
   a = 1;
  }
  public static class MyThread extends Thread {
   public void run() {
   public void run() {
    System.out.println(a);
   }
  }
}

可以立即調度第二個線程,打印 a 的初始值 0。另一種情形,第二個線程可能 不立即運行,則導致打印值 1。這個程序的輸出取決於您正在使用的 JDK、底層操作系統的調度程序或者隨機計時構件。重復運行該程序,會得到不同的結果。

可見性危險

在清單 1 中,除了這個明顯的爭用 ― 第二個線程是在第一個線程將 a 置為 1 之前還是之後開始執行 ― 之外,實際上還有另一種數據爭用。第二種爭用是一種可見性方面的爭用:兩個線程沒有使用同步,而同步能保證線程之間數據更改的可見性。因為沒有同步,如果在第一個線程對 a 賦值完成之後,運行第二個線程,則第二個線程可能或 不可能立即看見第一個線程所做的更改。第二個線程可能看到 a 仍然為 0,即使第一個線程已經將值 1 賦給了 a。這種第二類的數據爭用(在沒有正確同步的情況下,兩個線程正在訪問同一變量)是一種復雜的問題,但幸運的是,每當讀取一個其它線程可能已寫過的變量,或者寫一個接下來可能會被其它線程讀取的變量時,使用同步就可以避免這類數據爭用。在這裡,我們不想進一步探討這類數據爭用,關於這類復雜問題,您可以參閱側欄 “用 Java Memory Model 同步”,也可以參閱 參考資料以獲取更多有關這類復雜問題的詳細信息。

在構造期間,不要公布“this”引用

一種可以將數據爭用引入類中的錯誤是,在構造函數完成之前,使 this 引用暴露給另一個線程。有時這個引用是顯式的,(譬如,直接將 this 存儲在靜態字段或集合),但還有一些時候它可以是隱式的(譬如,當將一個引用公布給構造函數中的非靜態內部類的實例時)。構造函數不是一般的方法 ― 它們有特殊的用於初始化安全的語義。在構造函數完成之後,可以認為對象是處於一種可預測和一致的狀態,將引用公布給一個還未完成構造的對象是危險的。清單 2 顯示了將這類爭用條件引入構造函數的示例。這個示例看上去可能沒有危害性,但它可以引發嚴重的並發性問題。

清單 2. 可能發生的數據爭用

public class EventListener {
  public EventListener(EventSource eventSource) {
   // do our initialization
   ...
   // register ourselves with the event source
   eventSource.registerListener(this);
  }
  public onEvent(Event e) {
   // handle the event
  }
}

乍一看, EventListener 類似乎沒有危害性。構造函數最後完成的工作是注冊偵聽器,這會將引用公布給新對象,這時其它線程可能會看到這個引用。但即使不考慮所有 Java 內存模型(JMM)問題(譬如,多個線程所看見同一數據的不一致性以及內存訪問重排序的不同),該代碼仍然有將還未完成構造的 EventListener 對象暴露給其它線程的危險。考慮清單 3 中這種情況,當創建 EventListener 的一個子類時,會發生什麼:

清單 3. 創建 EventListener 的一個子類時,問題產生了

public class RecordingEventListener extends EventListener {
  private final ArrayList list;
  public RecordingEventListener(EventSource eventSource) {
   super(eventSource);
   list = Collections.synchronizedList(new ArrayList());
  }
  public onEvent(Event e) {
   list.add(e);
   super.onEvent(e);
  }
  public Event[] getEvents() {
   return (Event[]) list.toArray(new Event[0]);
  }
}

由於 Java 語言規范要求對 super() 的調用應該是子類構造函數中的第一條語句,所以在完成子類字段的初始化之前,還未構造完的事件偵聽器已經對事件源進行了注冊。現在, list 字段存在數據爭用。如果事件偵聽器決定從注冊調用內發送一個事件,或者我們很不幸,在這不恰當的時刻,一個事件到來了,則會調用 RecordingEventListener.onEvent() ,而這時 list 仍然是 null 值,結果會拋出 NullPointerException 異常。就象 onEvent() 這樣的類方法一樣,在編碼時,應該避免使用還未初始化完的 final 字段。

清單 2 中的問題在於, EventListener 在構造完成之前會向正在構造的對象公布一個引用。雖然看上去 幾乎已經完整地構造了對象,所以將 this 傳遞給事件源好象是安全的,但這種表像是具有欺騙性的。公布來自構造函數內的 this 引用(如清單 2 所示)就象放置了一顆隨時會爆炸的定時炸彈。

不要隱式地暴露“this”引用

在根本不使用 this 引用情況下,也有可能會造成逃脫的引用問題。非靜態內部類維護著其父類的 this 引用的隱式副本,所以創建匿名的內部類實例,並將其傳遞給從當前線程外部可以看見的對象,會存在與暴露 this 引用本身所具有的所有相同的風險。考慮清單 4,它與清單 2 有同樣的根本問題,但這裡沒有顯式地使用 this 引用:

清單 4. 使用匿名內部類時,不正確地公布了“this”

public class EventListener2 {
  public EventListener2(EventSource eventSource) {
   eventSource.registerListener(
    new EventListener() {
  public onEvent(Event e) {
      eventReceived(e);
     }
    });
  }
  public onEvent(Event e) {
  }
}

EventListener2 類和其類似代碼 清單 2 中的 EventListener 有同樣的弊端:公布了對正在構造的對象的引用 ― 在這種情況下,是間接的 ― 另一個線程可以看見這個引用。如果打算創建 EventListener2 的子類,將會碰到同樣的問題,即在子類構造函數完成之前會調用子類方法。

不要從構造函數內啟動線程

清單 4 問題的一個特例是,從構造函數內啟動了一個線程,由於當一個對象擁有一個線程時,通常這個線程要麼是一個內部類,要麼我們將 this 引用傳遞給其構造函數(或者該類本身繼承了 Thread 類)。如果一個對象將擁有一個線程,那麼,如果對象如 Thread 那樣提供一個 start() 方法,並從 start() 方法而不是從構造函數啟動線程,那是最好的。雖然通過接口,這會暴露類的一些實現細節(譬如,可能存在擁有一個線程),這往往是我們不期望的,但在這種情況下,從構造函數啟動線程的害處要比隱藏實現所帶來的好處多。

“公布”是指什麼?

在構造期間,不是所有對 this 引用的引用都是有害的,只有那些公布引用,使其它線程看到該引用的引用才是有害的。確定與其它對象共享 this 引用是否安全,需要詳細了解對象的可見性以及對象將如何利用引用。關於讓 this 引用在構造期間逃脫,清單 5 列出了一些安全和不安全做法的示例:

清單 5. 在構造期間,安全和不安全地使用“this”引用

public class Safe {
  private Object me;
  private Set set = new HashSet();
  private Thread thread;
   public void run() {
   // Safe because "me" is not visible from any other thread
   me = this;
   // Safe because "set" is not visible from any other thread
   set.add(this);
   // Safe because MyThread won't start until construction is complete
   // and the constructor doesn't publish the reference
   thread = new MyThread(this);
  }
   public void run() {
   thread.start();
  }
  private class MyThread(Object o) {
   private Object theObject;
   public MyThread(Object o) {
    this.theObject = o;
   }
   ...
  }
}
public class Unsafe {
  public static Unsafe anInstance;
  public static Set set = new HashSet();
  private Set mySet = new HashSet();
   public void run() {
   // Unsafe because anInstance is globally visible
   anInstance = this;
   // Unsafe because SomeOtherClass.anInstance is globally visible
   SomeOtherClass.anInstance = this;
   // Unsafe because SomeOtherClass might save the "this" reference
   // where another thread could see it
   SomeOtherClass.registerObject(this);
   // Unsafe because set is globally visible
   set.add(this);
   // Unsafe because we are publishing a reference to mySet
   mySet.add(this);
   SomeOtherClass.someMethod(mySet);
   // Unsafe because the "this" object will be visible from the new
   // thread before the constructor completes
   thread = new MyThread(this);
   thread.start();
  }
   public void run() {
   // Unsafe because "c" may be visible from other threads
   c.add(this);
  }
}

正如您所見,在 Unsafe 類中許多不安全的構造與 Safe 類中安全的構造有很多相似之處。確定 this 引用對其它線程是否可見是一項很棘手的工作。最好的策略是,在構造函數中完全避免使用 this 引用(直接或間接)。然而,實際上這種完全避免使用 this 引用的可能並不總是存在。但只要記住,在構造函數中謹慎使用 this 引用和創建非靜態內部類的實例。

在構造期間不允許引用逃脫的其它理由

當我們考慮同步的影響時,上面所詳細講述的關於線程安全構造的那些做法就變得更為重要。例如,當線程 A 啟動線程 B 時,Java 語言規范(Java Language Specification (JLS))保證:當線程 A 啟動線程 B 時,所有那些對線程 A 可見的變量對線程 B 也是可見的,這非常類似 Thread.start() 中隱式同步。如果從構造函數中啟動一個線程,並且正在構造的對象還沒有完成,則我們會丟失這些可見性的保證。

因為 JMM 有一些令人迷惑的方面,Java Community Process JSR 133 正對其進行修訂,這將(尤其)會更改 volatile 和 final 的語義,使它們更符合其字面上含義。例如,在當前 JMM 語義下,一個線程在其生存期中可能看到 final 字段有多個值。新的內存模型語義將防止這種情形,但只有在正確定義了構造函數的情況下 ― 這意味著在構造期間不允許 this 引用逃脫。

結束語

對其它線程能夠看見的還未完成構造的對象進行引用顯然不是我們所期望的。歸根結底,我們如何正確辨別完全構造好的對象和尚未構造好的對象呢?但通過公布來自構造函數內的 this 引用 ― 直接或間接地通過內部類 ― 我們這樣做時,會導致無法預料的後果。為了防止這種危險,在創建內部類的實例或從構造函數啟動線程時,盡量避免使用 this 。如果無法在構造函數中避免直接或間接使用 this ,則確保不讓其它線程看見 this 引用。

  1. 上一頁:
  2. 下一頁:
Copyright © 程式師世界 All Rights Reserved