轉自:http://www.iteye.com/topic/875420
如果你搜索網上分析dcl為什么在java中失效的原因,都會談到編譯器會做優化雲雲,我相信大家看到這個一定會覺得很沮喪、很無助,對自己寫的程序很沒信心。我很理解這種感受,因為我也經歷過,這或許是為什么網上一直有人喜歡談dcl的原因。如果放在java5之前,從編譯器的角度去解釋dcl也無可厚非,在java5的JMM(內存模型)已經得到很大的修正,如果到現在還只能從編譯器的角度去解釋dcl,那簡直就在污辱java,要知道java的最大優勢就是只需要考慮一個平台。你可以完全無視網上絕大多數關於dcl的討論,很多時候他們自己都說不清楚,除Doug Lea等幾個大牛,我不相信誰比誰更權威。
很多人不理解dcl,不是dcl有多么復雜,恰恰相反,而是對基礎掌握得不夠。所以,我會先從基礎講起,然后再分析DCL。
我們都知道,當兩個線程同時讀寫(或同時寫)一個共享變量時會發生數據競爭。那我怎么才能知道發生了數據競爭呢?我需要去讀取那個變量,發生數據競爭通常有兩個表現:一是讀取到陳舊數據,即讀取到雖是曾經寫入的數據,但不是最新的。二是讀取到之前根本沒有寫入的值,也就是說讀到垃圾。
數據陳舊性
為了讀取到另一個線程寫入的最新數據,JMM定義了一系列的規則,最基本的規則就是要利用同步。在Java中,同步的手段有synchronized和volatile兩種,這里我只會涉及到syncrhonized。請大家先記住以下規則,接下來我會細講。
規則一:必須對變量的所有寫和所有讀同步,才能讀取到該最新的數據。
先看下面的代碼:
public class A { private int some; public int another; public int getSome() { return some; } public synchronized int getSomeWithSync() { return some; } public void setSome(int v) { some = v; } public synchronized void setSomeWithSync(int v) { some = v; } }
讓我們來分析一個線程寫,另一個線程讀的情形,一共四種情形。初始情況都是a = new A(),暫不考慮其它線程。
情形一:讀寫都不同步。
Thread1 | Thread2 |
(1) a.setSome(13) | |
(2) a.getSome() |
這種情況下,即使thread1先寫入some為13,thread2再讀取some,它能讀到13嗎?在沒有同步協調下,結果是不確定的。從圖上看出,兩個線程獨立運行,JMM並不保證一個線程能夠看到另一個線程寫入的值。在這個例子中,就是thread2可能讀到0(即some的初始值)而不是13。注意,在理論上,即使thread2在thread1寫入some之后再等上一萬年也還是可能讀到some的初始值0,盡管這在實際幾乎不可能發生。
情形二:寫同步,讀不同步
Thread1 | Thread2 |
(1) a.setSomeWithSync(13) | |
(2) a.getSome() |
情形三:讀同步,寫不同步
Thread1 | Thread2 |
(1) a.setSome(13) | |
(2) a.getSomeWithSync() |
在這兩種情況下,thread1和thread2只對讀或只對寫some加了鎖,這不起任何作用,和[情形一]一樣,thread2仍有可能讀到some的初始值0。從圖上也可看出,thread1和thread2互相之間並沒有任何影響,一方加鎖並不影響另一方的繼續運行。圖中也顯示,同步操作相當於在同步開始執行lock操作,在同步結束時執行unlock操作。
情形四:讀寫都同步
Thread1 | Thread2 |
(1) a.setSomeWithSync(13) | |
(2) a.getSomeWithSync() |
在情形四中,thread1寫入some時,thread2等待thread1寫入完成,並且它能看到thread1對some做的修改,這時thread2保證能讀到13。實際上,thread2不僅能看到thread1對some的修改,而且還能看到thread1在修改some之前所做的任何修改。說得更精確一些,就是一個線程的lock操作能看見另一線程對同一個對象unlock操作之前的所有修改,請注意圖中的紅色箭頭。 沿着圖中箭頭指示方向,箭頭結尾處總能看到箭頭開始處操作做的修改。這樣,a.some[thread2]能看見lock[thread2],lock[thread2]能看見unlock[thread1],unlock[thread1]又能看見a.some=13[thread1],即能看到some的值為13。
再來看一個稍微復雜一點的例子:
例子五
Thread1 | Thread2 |
(1) a.another = 5 | |
(2) a.setSomeWithSync(13) | |
(3) a.getSomeWithSync() | |
(4) a.another = 7 | |
(5) a.another |
thread2最后會讀到another的什么值呢?會不會讀到another的初始值0呢,畢竟所有對another的訪問都沒有同步?不會。從圖中很清晰地可以看出,thread2的another至少到看到thread1在lock之前寫入的5,卻並不能保證它能看到thread1在unlock寫入的7。因此,thread2可以什么讀到another的值可能5或7,但不會是0。你或許已經發現,如果去掉圖中thread2讀取a.some的操作,這時相當於一個空的同步塊,對結論並沒有任何影響。這說明空的同步塊是起作用的,編譯器不能擅自將空的同步塊優化掉,但你在使用空的同步塊應該特別小心,通常它都不是你想要的結果。另外需要注意,unlock操作和lock操作必須針對同一個對象,才能保證unlock操作能看到lock操作之前所做的修改。
例子六:不同的鎖
Thread1 | Thread2 |
(1) b.setSome(13) | |
(2) b.getSome() |
在這種情況下,雖然getSome和setSome都加了鎖,但由於它們是不同的鎖,一個線程運行時並不能阻塞另一個線程運行。因此這里的情形和情形一、二、三一樣,thread2不保證讀到thread1寫入的some最新值。
現在來看DCL:
例子七: DCL
public class LazySingleton { private int someField; private static LazySingleton instance; private LazySingleton() { this.someField = 201; // (1) } public static LazySingleton getInstance() { if (instance == null) { // (2) synchronized(LazySingleton.class) { // (3) if (instance == null) { // (4) instance = new LazySingleton(); // (5) } } } return instance; // (6) } public int getSomeField() { return this.someField; // (7) } }
假設thread1先調用getInstance(),由於此時還沒有任何線程創建LazySingleton實例,它會創建一個實例s並返回。這是thread2再調用getInstance(),當它運行到(2)處,由於這時讀instance沒有同步,它有可能讀到s或者null(參考情形二)。先考慮它讀到s的情形,畫出流程圖就是下面這樣的:
由於thread2已經讀到s,所以getInstance()會立即返回s,這是沒有任何問題,但當它讀取s.someFiled時問題就發生了。 從圖中可以看thread2沒有任何同步,所以它可能看不到thread1寫入someField的值20,對thread2來說,它可能讀到s.someField為0,這就是DCL的根本問題。從上面的分析也可以看出,為什么試圖修正DCL但又希望完全避免同步的方法幾乎總是行不通的。
接下來考慮thread2在(2)處讀到instance為null的情形,畫出流程圖:
接下來thread2會在有鎖的情況下讀取instance的值,這時它保證能讀到s,理由參考情形四或者通過圖中箭頭指示方向來判定。
關於DCL就說這么多,留下兩個問題:
- 接着考慮thread2在(2)讀到instance為null的情形,它接着調用s.someFiled會得到什么?會得到0嗎?
- DCL為什么要double check,能不能去掉(4)處的check?若不能,為什么?
原子性
回到情形一,為什么我們說thread2讀到some的值只可能為為0或13,而不可能為其它?這是由java對int、引用讀寫都是原子性所決定的。所謂“原子性”,就是不可分割的最小單元,有數據庫事務概念的同學們應該對此容易理解。當調用some=13時,要么就寫入成功要么就寫入失敗,不可能寫入一半。但是,java對double, long的讀寫卻不是原子操作,這意味着可能發生某些極端意外的情況。看例子:
public class C { private /* volatile */ long x; // (1) public void setX(long v) { x = v; } public long getX() { return x; } }
Thread1 | Thread2 |
(1) c.setX(0x1122334400112233L) | |
(2) c.getX() |
thread2讀取x的值可能為0,1122334400112233外,還可能為別的完全意想不到的值。一種情況假設jvm對long的寫入是先寫低4字節,再寫高4字節,那么讀取到x的值還可能為112233。但是我們不對jvm做如此假設,為了保證對long或double的讀寫是原子操作,有兩種方式,一是使用volatile,二是使用synchronized。對上面的例子,如果取消(1)處的volatile注釋,將能保證thread2讀取到x的值要么為0,要么為1122334400112233。如果使用同步,則必須像下面這樣對getX,setX都同步:
public class C { private /* volatile */ long x; // (1) public synchronized void setX(long v) { x = v; } public synchronized long getX() { return x; } }
因此對原子性也有規則(volatile其實也是一種同步)。
規則二:對double, long變量,只有對所有讀寫都同步,才能保證它的原子性
有時候我們需要保證一個復合操作的原子性,這時就只能使用synchronized。
public class Canvas { private int curX, curY; public /* synchronized */ getPos() { return new int[] { curX, curY }; } public /* synchronized */ void moveTo(int x, int y) { curX = x; curY = y; } }
Thread1 | Thread2 |
(1) c.moveTo(1, 1) | |
(2) c.moveTo(2, 2) | |
(3) c.getPos() |
當沒有同步的情況下,thread2的getPos可能會得到[1, 2], 盡管該點可能從來沒有出現過。之所以會出現這樣的結果,是因為thread2在調用getPos()時,curX有0,1或2三種可能,同樣curY也有0,1或2三種可能,所以getPos()可能返回[0,0], [0,1], [0,2], [1,0], [1,1], [1,2], [2,0], [2,1], [2,2]共九種可能。要避免這種情況,只有將getPos()和moveTo都設為同步方法。
總結
以上分析了數據競爭的兩種症狀,陳舊數據和非原子操作,都是由於沒有恰當同步引起的。這些其實都是相當基礎的知識,同步可有兩種效果:一是保證讀取最新數據,二是保證操作原子性,但是大多數書籍都對后者過份強調,對前者認識不足,以致對多線程的認識上存在很多誤區。如果想要掌握java線程高級知識,我只推薦《Java並發編程設計原則與模式》。其實我已經好久沒有寫Java了,這些東西都是我兩年前的知識,如果存在問題,歡迎大家指出,千萬不要客氣。
還有一篇講的也很不錯:http://www.iteye.com/topic/260515