1. 程式人生 > >[JCIP筆記] (三)如何設計一個線程安全的對象

[JCIP筆記] (三)如何設計一個線程安全的對象

variable 原因 事情 display LV 技術 循環 reader 暴露

在當我們談論線程安全時,我們在談論什麽中,我們討論了怎樣通過Java的synchronize機制去避免幾個線程同時訪問一個變量時發生問題。憂國憂民的Brian Goetz大神在多年的開發過程中,也悟到了人性的懶惰,他深知許多程序員不會在設計階段就考慮到線程安全,只是假設自己的代碼能按照自己的想法很好地運轉。然而當程序上線、線程安全問題真的發生時,要花費多於前期設計數倍的時間和精力去進行排查、解決,甚至重新設計。於是,他在字裏行間一直秉持一種“凡事皆可發生”的小心翼翼的哲學,並以這種哲學努力影響讀者。或許我們在設計一個類時,對類中的各個域的訪問修飾符並不會過多地進行思考,對於構造函數也只是按照IDE的提示順手一填了事;當我們設計一個boolean類型作為線程是否應該睡眠的flag時,或許也不會立馬想到可能需要把它設置為volatile。Brian Goetz大神花了整整十四頁密密麻麻的英文告訴你,設計時懶得花時間思考的問題,總有一天代碼會逼你想得更加透徹。

本章目錄

  • 復習
  • 可見性
    • 內置鎖與可見性
    • volatile與可見性
  • 線程封閉
    • 純靠自覺的線程封閉
    • 棧封閉
    • ThreadLocal類
  • 不變對象
  • 安全發布
    • 發布(publication)
    • 安全發布的常用模式
    • 總結:任意對象的安全發布
  • 總結:如何安全地共享對象

復習

上一章講到,當多個線程同時訪問同一個變量時,由於線程調度的順序不定,或線程之間的執行剛好互相穿插,最終的結果可能不同,這種情況叫做競態條件。避免競態條件的方法大致分為三種:

  • 取消多線程對變量的共享
  • 把變量設為不可變
  • 設置同步機制去控制對變量的訪問

上一章中,我們講了第三點的一個方式——內在鎖,也就是synchronized關鍵字。這裏我們要提出第二種方法,即volatile關鍵字,並討論它跟synchronized有什麽不同。另外,我們要詳細討論前兩點如何實現。

此外,心系天下的Brian Goetz大神還要提出一個“安全發布”的概念,一個絕大多數編程菜鳥(比如我)從未想過的問題。

可見性

要知道,synchronized關鍵字不止保證原子性而已,它還能保證可見性,即一個線程對某變量做出的修改可以被另一個線程馬上看到。要明白可見性的重要,需要研究一個例子。

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; } }

例子中有兩個線程,其中一個會不停地查詢ready這個標誌位,當它為true時停止循環,打印number的值。另外一個線程先把number置為42,然後把ready寫為true。

寫這段代碼的程序員的本意是希望ReaderThread停止後打印42。然而出於某些原因,結果並不一定如他所料。其實這段代碼的執行結果有三種可能:

  • ReaderThread停止後打印42。
  • ReaderThread停止後打印0。
  • ReaderThread永不停止。

後兩種“錯誤”的結果反映了影響可見性的兩個行為:

  • ReaderThread停止後打印0。 ——指令重排序,即number = 42;和ready = true;的執行順序被編譯器改變。於是ReaderThread看到ready為true時,number還是默認的初始值0。
  • ReaderThread永不停止。 ——ReaderThread讀到的ready始終為線程棧上的緩存值。主線程執行的ready = true並未寫到內存中,或寫到了內存中,但ReaderThread線程並未看到。

保證可見性有兩種方法:給變量訪問加鎖,或者用volatile關鍵字修飾變量。

內置鎖與可見性

如果線程A和線程B先後獲取了某對象的內置鎖M,那麽線程B拿到鎖之後,可以看到線程A釋放鎖之前的所有操作結果。即線程A釋放鎖之前的操作happens-before線程B拿到鎖之後的操作。

happens-before並不是說事情發生的先後順序,而是說其他線程能看到某線程在某個時間點之前做過的事情。)

技術分享圖片

所以總結起來,加鎖可以同時保證原子性和可見性。

volatile與可見性

volatile關鍵字會促使Java編譯器做如下兩件事情:

  1. 禁止指令重排序。對volatile變量的讀寫操作之前和之後的指令可以被分別重排序,但前、後的指令不能發生混雜。
    1 nonVolatile1 = 123;
    2 nonVolatile2 = 456;
    3 nonVolatile3 = 789; //以上三條可以內部重排序,但必須發生在*前
    4 
    5 volatileVariable = 666; (*) //volatile變量操作
    6 
    7 someValue1 = nonVolatile4;
    8 someValue2 = nonVolatile5;
    9 someValue3 = nonVolatile6; //以上三條可以內部重排序,但必須發生在*後
  2. 讀寫操作會直接從內存讀、向內存寫,而非暫存在其他處理器看不到的register或cache中。實際上,volatile也提供與內置鎖相似的happens-before性質,即若線程A對某volatile變量進行寫操作,而線程B隨後對該變量進行讀操作,則A寫操作之前的所有操作對B讀操作之後的所有操作可見。它的內部機理是:A寫這個volatile變量時,在此之前A修改過的所有變量都會被flush到主存;而B讀這個volatile變量時會將其他變量一起從主存讀出。

volatile的典型用法是做標誌位來標示一個生命周期事件的發生,如初始化或關閉。

volatile boolean asleep;

while(!asleep)
    countSomeSheep();

需要額外註意的是,volatile並不保證操作的原子性。所以牽涉復合操作的變量用volatile修飾並不能保證線程安全,除非只有一個線程對該變量進行寫操作。

說到原子性還有一件事需要註意,就是Java基本類型中的long和double。因為它們是64位的,而JVM的基本尋址單位是32位,所以long和double的讀寫並不一定是原子的(與JVM implementation有關)。也就是說,當兩個線程同時對一個long變量進行寫操作時,有可能的結果是這個變量的高字節是線程A寫的,而低字節是線程B寫的。雖然我們剛說完volatile不保證原子性,但涉及到long和double時,JLS規定volatile的long和double讀寫操作始終是原子的。也就是說,代碼中共享可變的long或double變量需要加volatile或加鎖保護。

線程封閉

如果讓某個變量只能被單個線程訪問,那麽即使這個變量對象本身不是線程安全的,也不會出現安全問題。這種技術叫做線程封閉。它的典型例子有兩個:

  1. Swing中的可視化組件和數據模型對象都不是線程安全的。Swing專門設置了一個事件分發線程(EDT, the Event Dispatching Thread)去管理這些對象,只有這個線程有權做UI更新等操作。如果其他線程也想修改UI,可以通過invokeLater()機制提交修改,這些修改事件會存放在message queue中等待EDT逐一處理。
  2. JDBC (Java Database Connectivity) 池提供的Connection對象也不是線程安全的(JDBC Spec未要求它們線程安全)。這是因為通常每處理一個請求時,會有一個線程去池中拿到一個Connection,而這個Connection在該請求的處理結束前並不會被分配給其他的線程,也就是達到了線程封閉。

線程封閉的實現方式大致有三種,以下逐一介紹。

純靠自覺的線程封閉

開發者們商量好某些對象是線程封閉的,然後依靠代碼實現去實現線程封閉。通常會決定把一個子系統(如GUI)做成線程封閉的。

這種方法一般非常脆弱,因為沒有硬性的語法規範,很容易被新來的不懂事的程序員或者因為沒寫文檔所以過幾個月就忘了自己當初怎麽想的老開發打破。不過,它帶來的簡潔性在一定程度上可以彌補脆弱性。

另外,如上文所說的,對於volatile變量來說,存在一種特殊的線程封閉,即寫封閉。即使有多個線程會去讀取volatile變量,只要保證只有一個線程在寫,那麽這個變量還是線程安全的,即使寫操作是一個復合操作也沒關系。因為這種情況相當於把修改操作封閉在單個線程中而防止了競態條件,而volatile又能保證其他線程看到最新的值。

棧封閉

通過在方法中定義局部變量來保證線程封閉。由於局部變量在線程棧上,所以無法被其他線程拿到。

  • 如果局部變量是基本類型的,那麽就算程序員想,也沒辦法傳給別的線程。
  • 如果局部變量是個對象就要註意了,不要犯傻傳給其它的線程。最好把這一點註釋好,免得新來的程序員不知道我們是這麽設計的。

ThreadLocal類

ThreadLocal類實際上是一種機制,通過程序員自定義的方式去給每一個線程都分配某個類的實例。這樣可以防止線程之間共享一個對象。

比如,SimpleDataFormat是線程不安全的。如果兩個線程同時調用同一個SimpleDataFormat實例的.format()方法,可能會造成數據的破壞。因此,我們可以通過以下代碼,給每個線程都分配一個SimpleDataFormat實例,這樣,某個線程想使用SimpleDataFormat時,只要調用Foo.format(...)就可以了。

1 public class Foo{
2     private static final ThreadLocal<SimpleDataFormat> threadLocalFormatter = new ThreadLocal<SimpleDataFormat>(){
3         @Override
4         protected SimpleDataFormat initialValue(){
5             return new SimpleDataFormat("yyyy MMdd HHmm");
6         }
7     }
      public String format(Date date){
          return threadLocalFormatter.get().format(date);
      }
8 }

當某個線程初次調用ThreadLocal.get()方法時,會調用initialValue()來獲取初始值。每個線程中會有一個ThreadLocalMap,它會以ThreadLocal<T>對象為key,保存T的實例。在以上的例子中,每個調用過ThreadLocal.get()方法的線程的ThreadLocalMap都會存有一個Entry,它的key是threadLocalFormatter,value是new SimpleDataFormat("yyyy MMdd HHmm")所創建的對象。

在Java 5.0之前,Integer.toString()是通過ThreadLocal來為每個線程分配緩沖區,用來對結果進行格式化的,因為使用共享的靜態緩沖區需要持鎖訪問,而用這種方法又不用每次分配一個新的緩沖區。不過Java 5.0中改成了每次分配新的緩沖區,因為ThreadLocal只在分配的頻率/開銷非常高時才會帶來明顯的性能提升。而對於緩沖區這種簡單的對象來說,ThreadLocal沒有太多性能優勢。

作者認為,使用ThreadLocal會降低代碼的可重用性,並在類之間引入隱含的耦合性,所以要小心使用。雖然我並不理解他為什麽這麽講,但顯然ThreadLocal的應用場景還是有限的,它的真實用途很容易被誤解。

不變對象

為了跟invariant(不變性)區分開,這裏將immutability翻成了不變對象,即創建之後狀態不可改變的對象。不變對象永遠是線程安全的。

並不是設為final就是不變對象,因為final對象的域還可能指向可變對象。滿足以下條件的對象才是不變對象:

  1. 對象創建之後狀態不可更改。
  2. 所有的域都是final類型的。(其實String中的hash域並不是final的,但因為hash只與value[]有關而value[]是不可變的,所以hash理論上也是不可變的,不把hash設為final是為了將hash的計算推遲到第一次調用hashCode()時進行。不過作者並不建議程序員嘗試這種方法,可能是因為會難以維護。
  3. 對象是正確創建的(創建期間this引用沒有逸出)。

以下類滿足了這三個要求:

 1 @Immutable
 2 public final class ThreeStooges{
 3       private final Set<String> stooges = new HashSet<String>();
 4 
 5       public ThreeStooges(){
 6           stooges.add("Moe");
 7           stooges.add("Larry");
 8            stooges.add("Curly");
 9       }
10 
11       public boolean isStooge(String name){
12           return stooges.contains(name);
13       }
14 }  

雖然看起來stooges會指向一些可變對象,但是ThreeStooges類並沒有提供可以改變這些對象的接口,所以ThreeStooges是不可變的。

作者建議開發者多考慮不可變對象的使用。它們看起來不怎麽實用(因為不可變),但其實我們對對象的引用是可變的,所以需要時只要創建一個新的不可變對象就可以。它的好處是不用加鎖,而且會降低對generational garbage collection的影響(作者說的)。而且內存分配的開銷通常比我們想象的要低。

就算對象是可變的,也應該把盡可能多的域設為final,這和把盡可能多的域設為private一樣是一種好習慣,因為這樣減少了對象可能的狀態的數量,便於分析問題和維護。

以下闡釋了把UnsafeCachingFactorizer改成用一個volatile的不變對象進行緩存的線程安全代碼的方法。

 1 @NotThreadSafe
 2 public class UnsafeCachingFactorizer extends GenericServlet implements Servlet {
 3     private final AtomicReference<BigInteger> lastNumber
 4             = new AtomicReference<BigInteger>();
 5     private final AtomicReference<BigInteger[]> lastFactors
 6             = new AtomicReference<BigInteger[]>();
 7 
 8     public void service(ServletRequest req, ServletResponse resp) {
 9         BigInteger i = extractFromRequest(req);
10         if (i.equals(lastNumber.get()))
11             encodeIntoResponse(resp, lastFactors.get()); //!沒維護不變性
12         else {
13             BigInteger[] factors = factor(i);
14             lastNumber.set(i);
15             lastFactors.set(factors);  //!沒維護不變性
16             encodeIntoResponse(resp, factors);
17         }
18     }19 }

 1 @Immutable
 2 public class OneValueCache {
 3     private final BigInteger lastNumber;
 4     private final BigInteger[] lastFactors;
 5 
 6     public OneValueCache(BigInteger i,
 7                          BigInteger[] factors) {
 8         lastNumber = i;
 9         lastFactors = Arrays.copyOf(factors, factors.length);
10     }
11 
12     public BigInteger[] getFactors(BigInteger i) {
13         if (lastNumber == null || !lastNumber.equals(i))
14             return null;
15         else
16             return Arrays.copyOf(lastFactors, lastFactors.length); //用copyOf()保證lastFactors不可變
17     }
18 }
19 
20 @ThreadSafe
21 public class VolatileCachedFactorizer extends GenericServlet implements Servlet {
22     private volatile OneValueCache cache = new OneValueCache(null, null);
23 
24     public void service(ServletRequest req, ServletResponse resp) {
25         BigInteger i = extractFromRequest(req);
26         BigInteger[] factors = cache.getFactors(i);
27         if (factors == null) {
28             factors = factor(i);
29             cache = new OneValueCache(i, factors); 
30         }
31         encodeIntoResponse(resp, factors);
32     }
33 }

安全發布

這個話題對於我這種菜鳥來說是一個全新領域,或許只有提高經驗值才能慢慢明白作者在此處的煞費苦心。

發布(Publication)

發布一個對象指把這個對象暴露給當前作用域之外的代碼使用。以下是四種發布對象的方式:

  1. 直接把它存成一個public static域,所有其他類和線程都可以訪問。
  2. 從一個非private的方法中返回對象引用。
  3. 把對象引用傳給一個陌生方法(包括其他類的方法;自己類中可以被繼承的,即非private且非final的方法)。
  4. 在構造函數中隱式發布this引用。(<-作者極力禁止)

禁止4的原因是在構造函數中發布this時,this對象可能還沒初始化好。而陌生代碼可能對this作任意操作而引起問題。如:

1 public class ThisEscape {
2     public ThisEscape(EventSource source) {
3         source.registerListener(new EventListener() {
4             public void onEvent(Event e) {
5                 doSomething(e);
6             }
7         });
8     }
9 }

這裏的this對象就隱式逸出了。作者建議按照private構造函數 + public工廠方法的方式去修改:

 1 public class SafeListener {
 2     private final EventListener listener;
 3 
 4     private SafeListener() {
 5         listener = new EventListener() {
 6             public void onEvent(Event e) {
 7                 doSomething(e);
 8             }
 9         };
10     }
11 
12     public static SafeListener newInstance(EventSource source) {
13         SafeListener safe = new SafeListener();
14         source.registerListener(safe.listener);
15         return safe;
16     }
17 }

此外,作者還建議不要在構造函數中啟動線程,因為線程啟動時可能用到對象中還沒構造好的域。但是在構造函數中創建線程是可以的。我個人表示懷疑。(在構造函數中創建線程也有可能導致線程使用對象中未創建好的域進行初始化)。

安全發布的常用模式

安全發布:對象發布後,對象的引用本身和對象中的域的最新值對其他線程都可見。

由於緩沖區的存在,一個構造完成的對象在其他線程的眼中可能處於各種狀態。

可通過以下方式安全地發布一個對象:

  1. 用static initializer初始化。
  2. 用volatile或AtomicReference存儲要發布的對象。
  3. 用final存儲對象。
  4. 用鎖保護對象。

1能保證安全發布,是因為static initializers是JVM在類初始化的時期執行的,而JVM的類初始化是synchronized的,即獲得鎖才能進行初始化。所以static initialization後的對象及其狀態對其他線程可見。

3能保證安全發布,是因為Java內存模型保證了對象構造後(只要this不在構造時溢出)它的所有final域和final域指向的對象的最新值馬上對其它線程可見。

總結:任意對象的安全發布

對象的發布策略取決於它是否可變。

  1. 不可變對象可以被任意發布。
  2. effectively不可變的對象(程序中可以保證不對其進行修改的對象)須被安全發布,但發布後不必加鎖使用。
  3. 可變對象必須安全發布。且如果不是線程安全對象,使用時需加鎖保護。

總結:如何安全地共享對象

  • Thread-confined:只有一個線程可以修改。
  • Shared read-only:不可變和effective不可變對象
  • Shared thread-safe:對象內部是synchronized
  • Guarded:放在線程安全容器中使用,或使用時加鎖保護。

[JCIP筆記] (三)如何設計一個線程安全的對象