現在開始進入線程編程中最重要的話題---數據同步,它是線程編程的核心,也是難點,就算我們理解了 數據同步的基本原理,但是我們也無法保證能夠寫出正確的同步代碼,但基本原理是必須掌握的。
要 想理解數據同步的基本原理,首先就要明白,為什麼我們要數據同步?
public class CharacterDisplayCanvas extends JComponent implements CharacterListener { protected FontMetrics fm; protected char[] tmpChar = new char[1]; protected int fontHeight; public CharacterDisplayCanvas() { setFont(new Font("Monospaced", Font.BOLD, 18)); fm = Toolkit.getDefaultToolkit().getFontMetrics(getFont()); fontHeight = fm.getHeight(); } public CharacterDisplayCanvas(CharacterSource cs) { this(); setCharacterSource(cs); } public void setCharacterSource(CharacterSource cs) { cs.addCharacterListener(this); } public synchronized void newCharacter(CharacterEvent ce) { tmpChar[0] = (char) ce.character; repaint(); } public Dimension preferredSize() { return new Dimension(fm.getMaxAscent() + 10, fm.getMaxAdvance() + 10); } protected synchronized void paintComponent(Graphics gc) { Dimension d = getSize(); gc.clearRect(0, 0, d.width, d.height); if (tmpChar[0] == 0) { return; } int charWidth = fm.charWidth((int) tmpChar[0]); gc.drawChars(tmpChar, 0, 1, (d.width - charWidth) / 2, fontHeight); } }
仔細查看上面的代碼,我們就會發現,有兩個方法的前面多了一個新的關鍵字:synchronized。讓 我們看看這兩個方法為什麼要添加這個關鍵字。
newCharacter()用於顯示新字母,而paintComponent()負 責調整和重畫canvas。這兩個方法存在著race condition,也就是競爭,因為它們訪問的是同一份數據,最重 要的是它們是由不同的線程所調用的,這就導致我們無法保證它們的調用是按照正確的順序來進行,可能在 newCharacter()方法未被調用前paintComponent()方法就已經重新繪制canvas。
之所以產生競爭,除 了這兩個方法訪問的是同一份數據之外,還和它們是非automic有關。我們在初中的時候都學過,原子曾經被 認為是最小單元,不可分的,哪怕現在已經證明這是不正確的,但原子不可分的概念在計算機這裡保留了下來 。 一個程序如果被認為是automic,那麼就表示它是無法被中斷的,不會有中間狀態。使用synchronized,就 能保證該方法無法被中斷,那麼其他線程就無法在該方法沒有完成前調用它。
結合對象鎖的知識,我 們可以簡單的講解一下synchronized的原理:一個線程如果想要調用另一個線程的synchronized方法,而且該 方法正在被其他線程調用,那麼這個線程就必須等待,等待其他線程釋放該方法所在的對象的鎖,然後獲得該 鎖執行該方法。鎖機制能夠確保同一時間只有一個線程能夠調用該方法,也就能保證只有一個線程能夠訪問數 據。
還記得我們之前通過使用標記來結束線程的時候,將該標記用volatile修飾?如果我們不用 volatile,又能使用什麼方法呢?
如果單單只是上面的知識,我們可能會想到利用synchronized來同 步run()和setDone(),因為就是這兩個方法在競爭done這個數據。但是這樣存在很大的問題:run()會在done 沒有被設置true前永遠不會結束,但是done標記卻要等到run()方法結束後才能由setDone()方法進行設置。
這就是一個死鎖,永遠解不開的鎖。
產生死鎖的原因有很多,像是上面這種情況就是一個典型 的代表,主要原因就是run()方法的scope(范圍)太大。所謂的scope,指的是獲取鎖到釋放鎖的時間,而run() 方法的scope是一個循環,除非done設置為true。這種需要依賴其他線程的方法來結束執行的方法,如果將整 個方法設置為同步,就會出現死鎖。
所以,最好的方法就是將scope縮小。
我們可以不用對整 個方法進行同步,而是對需要訪問的數據進行同步,也就是對done使用volatile。
要想理解volatile 的工作原理,我們必須清楚變量的加載機制。java的內存模型允許線程能夠在local memory中持有變量的值, 所以這也就導致某個線程改變該變量的值時,其他線程可能不會察覺到該變量的變化。這種情況只是一種可能 ,並不代表一定會出現,但像是循環執行這種操作,就增加了這種可能。
所以,我們要做的事情其實 很簡單,就是讓線程從同一個地方取出變量而不是自己維護一份。使用volatile,每次使用該變量都要從主存 儲器中讀取,每次改變該變量時,也要存入主存儲器,而且加載和存儲都是automic,無論是否是long或者 double變量(這兩種類型的存儲是非automic的)。
值得注意的,run()方法和setDone()方法本身就是 automic,因為setDone()方法僅有一個存儲操作,而run()方法也只有一個讀取操作,其余部分根本就需要該 值保持不變,也就是說,這兩個方法其實本身就不存在競爭。
當然,如果還是堅持想要使用 synchronized的話,倒是有個比較丑陋的方法:對done提供setter和getter,然後synchronized這兩個方法, 因為取得同步化的鎖代表所有暫時存儲於寄存器的值都會被清空到主存儲器中,這樣run()方法中要想取得 done就必須等到setDone()方法設置完畢。
多麼丑陋的實現啊!!就為了同步一個變量,結果我們就要 平白對兩個方法進行同步,增加無謂的線程開銷!!但這也是沒有辦法的事,如果我們不知道還有volatile的 話,沒准還會為自己的小聰明而開心不已!!
這就是多線程編程的現實,如果我們無法知道還有更加 優雅的實現,我們永遠也只能寫出這樣的代碼。
但讓人更加困惑的是,volatile本身的存在現在也引 起人們的關注:它到底有沒有必要?
volatile是以moot point(未決點)來實現的:變量永遠都從主存 儲器中讀取,但這也只是JDK 1.2之前的情況,現在的虛擬機實現使得內存模式越來越復雜,而且也得到了極 大的優化,並且這種趨勢只會一直持續下去。也就是說,基於內存模式的volatile可能會因為內存模式的不斷 優化而逐漸變得沒有意義。
volatile的使用是有局限的,它僅僅解決因內存模式而引發的問題,而且 只能用在對變量的automic操作上,也就是訪問該變量的方法只可以有單一的加載或者存儲。但很多方法都是 非automic,像是遞增或者遞減操作,就允許存在中間狀態,因為它們本身就是載入,變更和存儲的簡化而已 ,也就是所謂的syntactic sugar(語法糖)。
我們大概可以這樣理解volatile的使用條件:強迫虛擬機 不要臨時復制變量,哪怕我們在許多情況下都不會使用它們。
volatile是否可以運用在數組上,讓整 個數組中的所有元素都被同步呢?凡是使用java的人都會對這樣的幻想嗤之以鼻,因為實際情況是只有數組的 引用才會被同步,數組中的元素不會是volatile的,虛擬機還是可以將個別元素存儲於local的寄存器中,沒 有任何方法可以指定數組的元素應該以volatile的方式來處理。
我們上面的同步問題是發生在展示隨 機數字與字母的顯示組件,現在我們繼續將功能完善:玩家可以輸入所顯示的字母,並且正確就會得分。