Java 線程/內存模型的缺陷和增強

原文地址:http://www.uml.org.cn/j2ee/j2ee090601.htm

JSR133(JMM):http://www.cs.umd.edu/~pugh/java/memoryModel/jsr133.pdf

shyguy 原創    
本文是由JR主持寫作的《J2SE進階》一書的部分章節整理而成,《J2SE進階》正在寫作、完善階段。您閱讀後,有任何建議、批評,請和我聯繫,或在這兒留言。《J2SE進階》寫作項目組感謝您閱讀本文。


Java在語言層次上實現了對線程的支持。它提供了Thread/Runnable/ThreadGroup等一系列封裝的類和接口,讓程序員可以高效的開發Java多線程應用。爲了實現同步,Java提供了synchronize關鍵字以及object的wait()/notify()機制,可是在簡單易用的背後,應藏着更爲複雜的玄機,很多問題就是由此而起。

一、Java內存模型


在瞭解Java的同步祕密之前,先來看看JMM(Java Memory Model)。
Java被設計爲跨平臺的語言,在內存管理上,顯然也要有一個統一的模型。而且Java語言最大的特點就是廢除了指針,把程序員從痛苦中解脫出來,不用再考慮內存使用和管理方面的問題。
可惜世事總不盡如人意,雖然JMM設計上方便了程序員,但是它增加了虛擬機的複雜程度,而且還導致某些編程技巧在Java語言中失效。

JMM主要是爲了規定了線程和內存之間的一些關係。對Java程序員來說只需負責用synchronized同步關鍵字,其它諸如與線程/內存之間進行數據交換/同步等繁瑣工作均由虛擬機負責完成。如圖1所示:根據JMM的設計,系統存在一個主內存(Main Memory),Java中所有變量都儲存在主存中,對於所有線程都是共享的。每條線程都有自己的工作內存(Working Memory),工作內存中保存的是主存中某些變量的拷貝,線程對所有變量的操作都是在工作內存中進行,線程之間無法相互直接訪問,變量傳遞均需要通過主存完成。

圖1 Java內存模型示例圖

線程若要對某變量進行操作,必須經過一系列步驟:首先從主存複製/刷新數據到工作內存,然後執行代碼,進行引用/賦值操作,最後把變量內容寫回Main Memory。Java語言規範(JLS)中對線程和主存互操作定義了6個行爲,分別爲load,save,read,write,assign和use,這些操作行爲具有原子性,且相互依賴,有明確的調用先後順序。具體的描述請參見JLS第17章。

我們在前面的章節介紹了synchronized的作用,現在,從JMM的角度來重新審視synchronized關鍵字。
假設某條線程執行一個synchronized代碼段,其間對某變量進行操作,JVM會依次執行如下動作:
(1) 獲取同步對象monitor (lock)
(2) 從主存複製變量到當前工作內存 (read and load)
(3) 執行代碼,改變共享變量值 (use and assign)
(4) 用工作內存數據刷新主存相關內容 (store and write)
(5) 釋放同步對象鎖 (unlock)
可見,synchronized的另外一個作用是保證主存內容和線程的工作內存中的數據的一致性。如果沒有使用synchronized關鍵字,JVM不保證第2步和第4步會嚴格按照上述次序立即執行。因爲根據JLS中的規定,線程的工作內存和主存之間的數據交換是鬆耦合的,什麼時候需要刷新工作內存或者更新主內存內容,可以由具體的虛擬機實現自行決定。如果多個線程同時執行一段未經synchronized保護的代碼段,很有可能某條線程已經改動了變量的值,但是其他線程卻無法看到這個改動,依然在舊的變量值上進行運算,最終導致不可預料的運算結果。

二、DCL失效


這一節我們要討論的是一個讓Java丟臉的話題:DCL失效。在開始討論之前,先介紹一下LazyLoad,這種技巧很常用,就是指一個類包含某個成員變量,在類初始化的時候並不立即爲該變量初始化一個實例,而是等到真正要使用到該變量的時候才初始化之。
例如下面的代碼:
代碼1

class Foo {
  private Resource res = null;

  public Resource getResource() {
    if (res == null)
      res = new Resource();
    return res;
  }
}

由於LazyLoad可以有效的減少系統資源消耗,提高程序整體的性能,所以被廣泛的使用,連Java的缺省類加載器也採用這種方法來加載Java類。
在單線程環境下,一切都相安無事,但如果把上面的代碼放到多線程環境下運行,那麼就可能會出現問題。假設有2條線程,同時執行到了if(res == null),那麼很有可能res被初始化2次,爲了避免這樣的Race Condition,得用synchronized關鍵字把上面的方法同步起來。代碼如下:
代碼2

Class Foo {
  Private Resource res = null;
  Public synchronized Resource getResource() {
    If (res == null)
      res = new Resource();
    return res;
  }
}

現在Race Condition解決了,一切都很好。

N天過後,好學的你偶然看了一本Refactoring的魔書,深深爲之打動,準備自己嘗試這重構一些以前寫過的程序,於是找到了上面這段代碼。你已經不再是以前的Java菜鳥,深知synchronized過的方法在速度上要比未同步的方法慢上100倍,同時你也發現,只有第一次調用該方法的時候才需要同步,而一旦res初始化完成,同步完全沒必要。所以你很快就把代碼重構成了下面的樣子:
代碼3

Class Foo {
Private Resource res = null;
  Public Resource getResource() {
    If (res == null){
      synchronized(this){
        if(res == null){
          res = new Resource();
}
}
    }
    return res;
  }
}

這種看起來很完美的優化技巧就是Double-Checked Locking。但是很遺憾,根據Java的語言規範,上面的代碼是不可靠的。

造成DCL失效的原因之一是編譯器的優化會調整代碼的次序。只要是在單個線程情況下執行結果是正確的,就可以認爲編譯器這樣的“自作主張的調整代碼次序”的行爲是合法的。JLS在某些方面的規定比較自由,就是爲了讓JVM有更多餘地進行代碼優化以提高執行效率。而現在的CPU大多使用超流水線技術來加快代碼執行速度,針對這樣的CPU,編譯器採取的代碼優化的方法之一就是在調整某些代碼的次序,儘可能保證在程序執行的時候不要讓CPU的指令流水線斷流,從而提高程序的執行速度。正是這樣的代碼調整會導致DCL的失效。爲了進一步證明這個問題,引用一下《DCL Broken Declaration》文章中的例子:
設一行Java代碼:

Objects[i].reference = new Object();

經過Symantec JIT編譯器編譯過以後,最終會變成如下彙編碼在機器中執行:

0206106A  mov     eax,0F97E78h
0206106F  call      01F6B210             ;爲Object申請內存空間
                                         ; 返回值放在eax中
02061074  mov     dword ptr [ebp],eax       ; EBP 中是objects[i].reference的地址
                                         ; 將返回的空間地址放入其中
                                         ; 此時Object尚未初始化
02061077  mov     ecx,dword ptr [eax]       ; dereference eax所指向的內容
                                         ; 獲得新創建對象的起始地址
02061079  mov     dword ptr [ecx],100h      ; 下面4行是內聯的構造函數
0206107F  mov     dword ptr [ecx+4],200h    
02061086  mov     dword ptr [ecx+8],400h
0206108D  mov     dword ptr [ecx+0Ch],0F84030h

可見,Object構造函數尚未調用,但是已經能夠通過objects[i].reference獲得Object對象實例的引用。
如果把代碼放到多線程環境下運行,某線程在執行到該行代碼的時候JVM或者操作系統進行了一次線程切換,其他線程顯然會發現msg對象已經不爲空,導致Lazy load的判斷語句if(objects[i].reference == null)不成立。線程認爲對象已經建立成功,隨之可能會使用對象的成員變量或者調用該對象實例的方法,最終導致不可預測的錯誤。

原因之二是在共享內存的SMP機上,每個CPU有自己的Cache和寄存器,共享同一個系統內存。所以CPU可能會動態調整指令的執行次序,以更好的進行並行運算並且把運算結果與主內存同步。這樣的代碼次序調整也可能導致DCL失效。回想一下前面對Java內存模型的介紹,我們這裏可以把Main Memory看作系統的物理內存,把Thread Working Memory認爲是CPU內部的Cache和寄存器,沒有synchronized的保護,Cache和寄存器的內容就不會及時和主內存的內容同步,從而導致一條線程無法看到另一條線程對一些變量的改動。
結合代碼3來舉例說明,假設Resource類的實現如下:

Class Resource{
  Object obj;
}

即Resource類有一個obj成員變量引用了Object的一個實例。假設2條線程在運行,其狀態用如下簡化圖表示:

圖2
現在Thread-1構造了Resource實例,初始化過程中改動了obj的一些內容。退出同步代碼段後,因爲採取了同步機制,Thread-1所做的改動都會反映到主存中。接下來Thread-2獲得了新的Resource實例變量res,由於沒有使用synchronized保護所以Thread-2不會進行刷新工作內存的操作。假如之前Thread-2的工作內存中已經有了obj實例的一份拷貝,那麼Thread-2在對obj執行use操作的時候就不會去執行load操作,這樣一來就無法看到Thread-1對obj的改變,這顯然會導致錯誤的運算結果。此外,Thread-1在退出同步代碼段的時刻對ref和obj執行的寫入主存的操作次序也是不確定的,所以即使Thread-2對obj執行了load操作,也有可能只讀到obj的初試狀態的數據。(注:這裏的load/use均指JMM定義的操作)

有很多人不死心,試圖想出了很多精妙的辦法來解決這個問題,但最終都失敗了。事實上,無論是目前的JMM還是已經作爲JSR提交的JMM模型的增強,DCL都不能正常使用。在William Pugh的論文《Fixing the Java Memory Model》中詳細的探討了JMM的一些硬傷,更嘗試給出一個新的內存模型,有興趣深入研究的讀者可以參見文後的參考資料。

如果你設計的對象在程序中只有一個實例,即singleton的,有一種可行的解決辦法來實現其LazyLoad:就是利用類加載器的LazyLoad特性。代碼如下:

Class ResSingleton {
public static Resource res = new Resource();
}

這裏ResSingleton只有一個靜態成員變量。當第一次使用ResSingleton.res的時候,JVM纔會初始化一個Resource實例,並且JVM會保證初始化的結果及時寫入主存,能讓其他線程看到,這樣就成功的實現了LazyLoad。
除了這個辦法以外,還可以使用ThreadLocal來實現DCL的方法,但是由於ThreadLocal的實現效率比較低,所以這種解決辦法會有較大的性能損失,有興趣的讀者可以參考文後的參考資料。

最後要說明的是,對於DCL是否有效,個人認爲更多的是一種帶有學究氣的推斷和討論。而從純理論的角度來看,存取任何可能共享的變量(對象引用)都需要同步保護,否則都有可能出錯,但是處處用synchronized又會增加死鎖的發生機率,苦命的程序員怎麼來解決這個矛盾呢?事實上,在很多Java開源項目(比如Ofbiz/Jive等)的代碼中都能找到使用DCL的證據,我在具體的實踐中也沒有碰到過因DCL而發生的程序異常。個人的偏好是:不妨先大膽使用DCL,等出現問題再用synchronized逐步排除之。也許有人偏於保守,認爲穩定壓倒一切,那就不妨先用synchronized同步起來,我想這是一個見仁見智的問題,而且得針對具體的項目具體分析後才能決定。還有一個辦法就是寫一個測試案例來測試一下系統是否存在DCL現象,附帶的光盤中提供了這樣一個例子,感興趣的讀者可以自行編譯測試。不管結果怎樣,這樣的討論有助於我們更好的認識JMM,養成用多線程的思路去分析問題的習慣,提高我們的程序設計能力。

三、Java線程同步增強包


相信你已經瞭解了Java用於同步的3板斧:synchronized/wait/notify,它們的確簡單而有效。但是在某些情況下,我們需要更加複雜的同步工具。有些簡單的同步工具類,諸如ThreadBarrier,Semaphore,ReadWriteLock等,可以自己編程實現。現在要介紹的是牛人Doug Lea的Concurrent包。這個包專門爲實現Java高級並行程序所開發,可以滿足我們絕大部分的要求。更令人興奮的是,這個包公開源代碼,可自由下載。且在JDK1.5中該包將作爲SDK一部分提供給Java開發人員。

Concurrent Package提供了一系列基本的操作接口,包括sync,channel,executor,barrier,callable等。這裏將對前三種接口及其部分派生類進行簡單的介紹。

sync接口:專門負責同步操作,用於替代Java提供的synchronized關鍵字,以實現更加靈活的代碼同步。其類關係圖如下:

圖3 Concurrent包Sync接口類關係圖
Semaphore:和前面介紹的代碼類似,可用於pool類實現資源管理限制。提供了acquire()方法允許在設定時間內嘗試鎖定信號量,若超時則返回false。

Mutex:和Java的synchronized類似,與之不同的是,synchronized的同步段只能限制在一個方法內,而Mutex對象可以作爲參數在方法間傳遞,所以可以把同步代碼範圍擴大到跨方法甚至跨對象。

NullSync:一個比較奇怪的東西,其方法的內部實現都是空的,可能是作者認爲如果你在實際中發現某段代碼根本可以不用同步,但是又不想過多改動這段代碼,那麼就可以用NullSync來替代原來的Sync實例。此外,由於NullSync的方法都是synchronized,所以還是保留了“內存壁壘”的特性。

ObservableSync:把sync和observer模式結合起來,當sync的方法被調用時,把消息通知給訂閱者,可用於同步性能調試。

TimeoutSync:可以認爲是一個adaptor,其構造函數如下:
public TimeoutSync(Sync sync, long timeout){…}
具體上鎖的代碼靠構造函數傳入的sync實例來完成,其自身只負責監測上鎖操作是否超時,可與SyncSet合用。

Channel接口:代表一種具備同步控制能力的容器,你可以從中存放/讀取對象。不同於JDK中的Collection接口,可以把Channel看作是連接對象構造者(Producer)和對象使用者(Consumer)之間的一根管道。如圖所示:

圖4 Concurrent包Channel接口示意圖

通過和Sync接口配合,Channel提供了阻塞式的對象存取方法(put/take)以及可設置阻塞等待時間的offer/poll方法。實現Channel接口的類有LinkedQueue,BoundedLinkedQueue,BoundedBuffer,BoundedPriorityQueue,SynchronousChannel,Slot等。

圖5 Concurrent包Channel接口部分類關係圖

使用Channel我們可以很容易的編寫具備消息隊列功能的代碼,示例如下:
代碼4

Package org.javaresearch.j2seimproved.thread;

Import EDU.oswego.cs.dl.util.concurrent.*;

public class TestChannel {
  final Channel msgQ = new LinkedQueue(); //log信息隊列

  public static void main(String[] args) {
    TestChannel tc = new TestChannel();
    For(int i = 0;i < 10;i ++){
      Try{
        tc.serve();
        Thread.sleep(1000);
      }catch(InterruptedException ie){
      }
    }
  }

  public void serve() throws InterruptedException {
    String status = doService();
//把doService()返回狀態放入Channel,後臺logger線程自動讀取之
    msgQ.put(status); 
  }

  private String doService() {
    // Do service here
    return "service completed OK! ";
  }

  public TestChannel() { // start background thread
    Runnable logger = new Runnable() {
      public void run() {
        try {
          for (; ; )
            System.out.println("Logger: " + msgQ.take());
        }
        catch (InterruptedException ie) {}
      }
    };
    new Thread(logger).start();
  }
}

Excutor/ThreadFactory接口: 把相關的線程創建/回收/維護/調度等工作封裝起來,而讓調用者只專心於具體任務的編碼工作(即實現Runnable接口),不必顯式創建Thread類實例就能異步執行任務。
使用Executor還有一個好處,就是實現線程的“輕量級”使用。前面章節曾提到,即使我們實現了Runnable接口,要真正的創建線程,還是得通過new Thread()來完成,在這種情況下,Runnable對象(任務)和Thread對象(線程)是1對1的關係。如果任務多而簡單,完全可以給每條線程配備一個任務隊列,讓Runnable對象(任務)和Executor對象變成n:1的關係。使用了Executor,我們可以把上面兩種線程策略都封裝到具體的Executor實現中,方便代碼的實現和維護。
具體的實現有: PooledExecutor,ThreadedExecutor,QueuedExecutor,FJTaskRunnerGroup等
類關係圖如下:

圖6 Concurrent包Executor/ThreadFactory接口部分類關係圖
下面給出一段代碼,使用PooledExecutor實現一個簡單的多線程服務器
代碼5

package org.javaresearch.j2seimproved.thread;
import java.net.*;
import EDU.oswego.cs.dl.util.concurrent.*;

public class TestExecutor {
  public static void main(String[] args) {
    PooledExecutor pool =
        new PooledExecutor(new BoundedBuffer(10), 20);
    pool.createThreads(4);
    try {
      ServerSocket socket = new ServerSocket(9999);
      for (; ; ) {
        final Socket connection = socket.accept();
        pool.execute(new Runnable() {
          public void run() {
            new Handler().process(connection);
          }
        });
      }
    }
    catch (Exception e) {} // die
  }
  static class Handler {
    void process(Socket s){
    }
  }
}

限於篇幅,這裏只是蜻蜓點水式的介紹了Concurrent包,事實上還有相當多有用的接口和類沒有提到,我們的配套光盤中附帶了Concurrent包和源代碼,感興趣的讀者可以自行分析。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章