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保護的程式碼片段,很有可能某條線程已經改動了變數的值,但是其他線程卻無法看到這個改動,依然在舊的變數值上進行運算,最終導致不可預料的運算結果。