第十五章 Java 線程/內存模型的缺陷和增強(1 / 3)

第十五章 Java 線程/內存模型的缺陷和增強

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();