Java安全執行緒兼談DCL

來源:互聯網
上載者:User

我之前寫過一篇談DCL的文章,最近又收到一個問題,本想直接回複,但我又不想再看原來寫的文章,那些順序分析其實很繞。這次我不會直接分析順序,而是從基礎概念講起,希望大家能看得輕鬆一些。

如果你搜尋網上分析dcl為什麼在java中失效的原因,都會談到編譯器會做最佳化云云,我相信大家看到這個一定會覺得很沮喪、很無助,對自己寫的程式很沒信心。我很理解這種感受,因為我也經曆過,這或許是為什麼網上一直有人喜歡談dcl的原因。如果放在java5之前,從編譯器的角度去解釋dcl也無可厚非,在java5的JMM(記憶體模型)已經得到很大的修正,如果到現在還只能從編譯器的角度去解釋dcl,那簡直就在汙辱java,要知道java的最大優勢就是只需要考慮一個平台。你可以完全無視網上絕大多數關於dcl的討論,很多時候他們自己都說不清楚,除Doug
Lea等幾個大牛,我不相信誰比誰更權威。

很多人不理解dcl,不是dcl有多麼複雜,恰恰相反,而是對基礎掌握得不夠。所以,我會先從基礎講起,然後再分析DCL。

我們都知道,當兩個線程同時讀寫(或同時寫)一個共用變數時會發生資料競爭。那我怎麼才能知道發生了資料競爭呢?我需要去讀取那個變數,發生資料競爭通常有兩個表現:一是讀取到陳舊資料,即讀取到雖是曾經寫入的資料,但不是最新的。二是讀取到之前根本沒有寫入的值,也就是說讀到垃圾。

資料陳舊性

為了讀取到另一個線程寫入的最新資料,JMM定義了一系列的規則,最基本的規則就是要利用同步。在Java中,同步的手段有synchronized和volatile兩種,這裡我只會涉及到syncrhonized。請大家先記住以下規則,接下來我會細講。

規則一:必須對變數的所有寫和所有讀同步,才能讀取到該最新的資料。

先看下面的代碼:Java代碼  

  1. public class A {  
  2.     private int some;  
  3.     public int another;  
  4.   
  5.     public int getSome() { return some; }  
  6.     public synchronized int getSomeWithSync() { return some; }  
  7.     public void setSome(int v) { some = v; }  
  8.     public synchronized void setSomeWithSync(int v) { some = v; }  
  9. }  

讓我們來分析一個線程寫,另一個線程讀的情形,一共四種情形。初始情況都是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操作之前所做的修改。

例子六:不同的鎖Java代碼  

  1. class B {  
  2.     private Object lock1 = new Object();  
  3.     private Object lock2 = new Object();  
  4.   
  5.     private int some;  
  6.   
  7.     public int getSome() {  
  8.         synchronized(lock1) { return some; }  
  9.     }  
  10.   
  11.     public void setSome(int v) {  
  12.         synchronized(lock2) { some = v; }  
  13.     }  
  14. }  

Thread1 Thread2
(1) b.setSome(13)  
  (2) b.getSome()

在這種情況下,雖然getSome和setSome都加了鎖,但由於它們是不同的鎖,一個線程運行時並不能阻塞另一個線程運行。因此這裡的情形和情形一、二、三一樣,thread2不保證讀到thread1寫入的some最新值。

現在來看DCL:

例子七: DCLJava代碼  

  1. public class LazySingleton {  
  2.     private int someField;  
  3.       
  4.     private static LazySingleton instance;  
  5.       
  6.     private LazySingleton() {  
  7.         this.someField = 201;                                 // (1)  
  8.     }  
  9.       
  10.     public static LazySingleton getInstance() {  
  11.         if (instance == null) {                               // (2)  
  12.             synchronized(LazySingleton.class) {               // (3)  
  13.                 if (instance == null) {                       // (4)  
  14.                     instance = new LazySingleton();           // (5)  
  15.                 }  
  16.             }  
  17.         }  
  18.         return instance;                                      // (6)  
  19.     }  
  20.       
  21.     public int getSomeField() {  
  22.         return this.someField;                                // (7)  
  23.     }  
  24. }  

假設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就說這麼多,留下兩個問題:

  1. 接著考慮thread2在(2)讀到instance為null的情形,它接著調用s.someFiled會得到什嗎?會得到0嗎?
  2. DCL為什麼要double check,能不能去掉(4)處的check?若不能,為什嗎?

原子性
回到情形一,為什麼我們說thread2讀到some的值只可能為為0或13,而不可能為其它?這是由java對int、引用讀寫都是原子性所決定的。所謂“原子性”,就是不可分割的最小單元,有資料庫事務概念的同學們應該對此容易理解。當調用some=13時,要麼就寫入成功要麼就寫入失敗,不可能寫入一半。但是,java對double, long的讀寫卻不是原子操作,這意味著可能發生某些極端意外的情況。看例子:Java代碼  

  1. public class C {  
  2.     private /* volatile */ long x;                           // (1)  
  3.   
  4.     public void setX(long v) { x = v; }  
  5.     public long getX() { return x; }  
  6. }  

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都同步:

Java代碼  
  1. public class C {  
  2.     private /* volatile */ long x;                           // (1)  
  3.   
  4.     public synchronized void setX(long v) { x = v; }  
  5.     public synchronized long getX() { return x; }  
  6. }  

因此對原子性也有規則(volatile其實也是一種同步)。

規則二:對double, long變數,只有對所有讀寫都同步,才能保證它的原子性

有時候我們需要保證一個複合操作的原子性,這時就只能使用synchronized。

Java代碼  
  1. public class Canvas {  
  2.     private int curX, curY;  
  3.   
  4.     public /* synchronized */ getPos() {  
  5.         return new int[] { curX, curY };  
  6.           
  7.     }  
  8.   
  9.     public /* synchronized */ void moveTo(int x, int y) {  
  10.         curX = x;  
  11.         curY = y;  
  12.     }  
  13. }  

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/875420

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.