熱點推薦:
您现在的位置: 電腦知識網 >> 編程 >> Java編程 >> Java高級技術 >> 正文

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

2022-06-13   來源: Java高級技術 

  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 MemoryJava語言規范(JLS)中對線程和主存互操作定義了個行為分別為loadsavereadwriteassign和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; }}
  
  這種看起來很完美的優化技巧就是DoubleChecked Locking但是很遺憾根據Java的語言規范上面的代碼是不可靠的
  
  造成DCL失效的原因之一是編譯器的優化會調整代碼的次序只要是在單個線程情況下執行結果是正確的就可以認為編譯器這樣的自作主張的調整代碼次序的行為是合法的JLS在某些方面的規定比較自由就是為了讓JVM有更多余地進行代碼優化以提高執行效率而現在的CPU大多使用超流水線技術來加快代碼執行速度針對這樣的CPU編譯器采取的代碼優化的方法之一就是在調整某些代碼的次序盡可能保證在程序執行的時候不要讓CPU的指令流水線斷流從而提高程序的執行速度正是這樣的代碼調整會導致DCL的失效為了進一步證明這個問題引用一下《DCL Broken Declaration》文章中的例子
  
  設一行Java代碼
  
  Objects[i]reference = new Object();
  
  經過Symantec JIT編譯器編譯過以後最終會變成如下匯編碼在機器中執行
  
  A mov eaxFEhF call FB ;為Object申請內存空間 ; 返回值放在eax中 mov dword ptr [ebp]eax ; EBP 中是objects[i]reference的地址 ; 將返回的空間地址放入其中 ; 此時Object尚未初始化 mov ecxdword ptr [eax] ; dereference eax所指向的內容 ; 獲得新創建對象的起始地址 mov dword ptr [ecx]h ; 下面行是內聯的構造函數F mov dword ptr [ecx+]h mov dword ptr [ecx+]hD mov dword ptr [ecx+Ch]Fh
  
  可見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
    推薦文章
    Copyright © 2005-2022 電腦知識網 Computer Knowledge   All rights reserved.