Java在語言層次上實現了對線程的支持
它提供了Thread/Runnable/ThreadGroup等一系列封裝的類和接口
讓程序員可以高效的開發Java多線程應用
為了實現同步
Java提供了synchronize關鍵字以及object的wait()/notify()機制
可是在簡單易用的背後
應藏著更為復雜的玄機
很多問題就是由此而起
一Java內存模型 在了解Java的同步秘密之前
先來看看JMM(Java Memory Model)
Java被設計為跨平台的語言
在內存管理上
顯然也要有一個統一的模型
而且Java語言最大的特點就是廢除了指針
把程序員從痛苦中解脫出來
不用再考慮內存使用和管理方面的問題
可惜世事總不盡如人意
雖然JMM設計上方便了程序員
但是它增加了虛擬機的復雜程度
而且還導致某些編程技巧在Java語言中失效
JMM主要是為了規定了線程和內存之間的一些關系
對Java程序員來說只需負責用synchronized同步關鍵字
其它諸如與線程/內存之間進行數據交換/同步等繁瑣工作均由虛擬機負責完成
如圖
所示
根據JMM的設計
系統存在一個主內存(Main Memory)
Java中所有變量都儲存在主存中
對於所有線程都是共享的
每條線程都有自己的工作內存(Working Memory)
工作內存中保存的是主存中某些變量的拷貝
線程對所有變量的操作都是在工作內存中進行
線程之間無法相互直接訪問
變量傳遞均需要通過主存完成
圖 Java內存模型示例圖 線程若要對某變量進行操作
必須經過一系列步驟
首先從主存復制/刷新數據到工作內存
然後執行代碼
進行引用/賦值操作
最後把變量內容寫回Main Memory
Java語言規范(JLS)中對線程和主存互操作定義了
個行為
分別為load
save
read
write
assign和use
這些操作行為具有原子性
且相互依賴
有明確的調用先後順序
具體的描述請參見JLS第
章
我們在前面的章節介紹了synchronized的作用
現在
從JMM的角度來重新審視synchronized關鍵字
假設某條線程執行一個synchronized代碼段
其間對某變量進行操作
JVM會依次執行如下動作
(
) 獲取同步對象monitor (lock)
(
) 從主存復制變量到當前工作內存 (read and load)
(
) 執行代碼
改變共享變量值 (use and assign)
(
) 用工作內存數據刷新主存相關內容 (store and write)
(
) 釋放同步對象鎖 (unlock)
可見
synchronized的另外一個作用是保證主存內容和線程的工作內存中的數據的一致性
如果沒有使用synchronized關鍵字
JVM不保證第
步和第
步會嚴格按照上述次序立即執行
因為根據JLS中的規定
線程的工作內存和主存之間的數據交換是松耦合的
什麼時候需要刷新工作內存或者更新主內存內容
可以由具體的虛擬機實現自行決定
如果多個線程同時執行一段未經synchronized保護的代碼段
很有可能某條線程已經改動了變量的值
但是其他線程卻無法看到這個改動
依然在舊的變量值上進行運算
最終導致不可預料的運算結果
二DCL失效 這一節我們要討論的是一個讓Java丟臉的話題
DCL失效
在開始討論之前
先介紹一下LazyLoad
這種技巧很常用
就是指一個類包含某個成員變量
在類初始化的時候並不立即為該變量初始化一個實例
而是等到真正要使用到該變量的時候才初始化之
例如下面的代碼
代碼
class Foo { private Resource res = null; public Resource getResource() { if (res == null) res = new Resource(); return res; }}
由於LazyLoad可以有效的減少系統資源消耗
提高程序整體的性能
所以被廣泛的使用
連Java的缺省類加載器也采用這種方法來加載Java類
在單線程環境下
一切都相安無事
但如果把上面的代碼放到多線程環境下運行
那麼就可能會出現問題
假設有
條線程
同時執行到了if(res == null)
那麼很有可能res被初始化
次
為了避免這樣的Race Condition
得用synchronized關鍵字把上面的方法同步起來
代碼如下
代碼
Class Foo { Private Resource res = null; Public synchronized Resource getResource() { If (res == null) res = new Resource(); return res; }}
現在Race Condition解決了
一切都很好
N天過後
好學的你偶然看了一本Refactoring的魔書
深深為之打動
准備自己嘗試這重構一些以前寫過的程序
於是找到了上面這段代碼
你已經不再是以前的Java菜鳥
深知synchronized過的方法在速度上要比未同步的方法慢上
倍
同時你也發現
只有第一次調用該方法的時候才需要同步
而一旦res初始化完成
同步完全沒必要
所以你很快就把代碼重構成了下面的樣子
代碼
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編譯器編譯過以後
最終會變成如下匯編碼在機器中執行
A mov eax
F
E
h
F call
F
B
;為Object申請內存空間 ; 返回值放在eax中
mov dword ptr [ebp]
eax ; EBP 中是objects[i]
reference的地址 ; 將返回的空間地址放入其中 ; 此時Object尚未初始化
mov ecx
dword ptr [eax] ; dereference eax所指向的內容 ; 獲得新創建對象的起始地址
mov dword ptr [ecx]
h ; 下面
行是內聯的構造函數
F mov dword ptr [ecx+
]
h
mov dword ptr [ecx+
]
h
D mov dword ptr [ecx+
Ch]
F
h
可見
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和寄存器的內容就不會及時和主內存的內容同步
從而導致一條線程無法看到另一條線程對一些變量的改動
結合代碼
來舉例說明
假設Resource類的實現如下
Class Resource{ Object obj;}
即Resource類有一個obj成員變量引用了Object的一個實例
假設
條線程在運行
其狀態用如下簡化圖表示
圖 現在Thread
構造了Resource實例
初始化過程中改動了obj的一些內容
退出同步代碼段後
因為采取了同步機制
Thread
所做的改動都會反映到主存中
接下來Thread
獲得了新的Resource實例變量res
由於沒有使用synchronized保護所以Thread
不會進行刷新工作內存的操作
假如之前Thread
的工作內存中已經有了obj實例的一份拷貝
那麼Thread
在對obj執行use操作的時候就不會去執行load操作
這樣一來就無法看到Thread
對obj的改變
這顯然會導致錯誤的運算結果
此外
Thread
在退出同步代碼段的時刻對ref和obj執行的寫入主存的操作次序也是不確定的
所以即使Thread
對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();}
From:http://tw.wingwit.com/Article/program/Java/gj/201311/27582.html