天天看點

Java線程(二):線程同步synchronized和volatile

上篇通過一個簡單的例子說明了線程安全與不安全,在例子中不安全的情況下輸出的結果恰好是逐個遞增的(其實是巧合,多運作幾次,會産生不同的輸出結果),為什麼會産生這樣的結果呢,因為建立的Count對象是線程共享的,一個線程改變了其成員變量num值,下一個線程正巧讀到了修改後的num,是以會遞增輸出。

        要說明線程同步問題首先要說明Java線程的兩個特性,可見性和有序性。多個線程之間是不能直接傳遞資料互動的,它們之間的互動隻能通過共享變量來實作。拿上篇博文中的例子來說明,在多個線程之間共享了Count類的一個對象,這個對象是被建立在主記憶體(堆記憶體)中,每個線程都有自己的工作記憶體(線程棧),工作記憶體存儲了主記憶體Count對象的一個副本,當線程操作Count對象時,首先從主記憶體複制Count對象到工作記憶體中,然後執行代碼count.count(),改變了num值,最後用工作記憶體Count重新整理主記憶體Count。當一個對象在多個記憶體中都存在副本時,如果一個記憶體修改了共享變量,其它線程也應該能夠看到被修改後的值,此為可見性。多個線程執行時,CPU對線程的排程是随機的,我們不知道目前程式被執行到哪步就切換到了下一個線程,一個最經典的例子就是銀行彙款問題,一個銀行賬戶存款100,這時一個人從該賬戶取10元,同時另一個人向該賬戶彙10元,那麼餘額應該還是100。那麼此時可能發生這種情況,A線程負責取款,B線程負責彙款,A從主記憶體讀到100,B從主記憶體讀到100,A執行減10操作,并将資料重新整理到主記憶體,這時主記憶體資料100-10=90,而B記憶體執行加10操作,并将資料重新整理到主記憶體,最後主記憶體資料100+10=110,顯然這是一個嚴重的問題,我們要保證A線程和B線程有序執行,先取款後彙款或者先彙款後取款,此為有序性。本文講述了JDK5.0之前傳統線程的同步方式,更進階的同步方式可參見Java線程(八):鎖對象Lock-同步問題更完美的處理方式。

        下面同樣用代碼來展示一下線程同步問題。

        TraditionalThreadSynchronized.java:建立兩個線程,執行同一個對象的輸出方法。

        運作結果:

        顯然輸出的字元串被打亂了,我們期望的輸出結果是zhangsanlisi,這就是線程同步問題,我們希望output方法被一個線程完整的執行完之後再切換到下一個線程,Java中使用synchronized保證一段代碼在多線程執行時是互斥的,有兩種用法:

        1. 使用synchronized将需要互斥的代碼包含起來,并上一把鎖。

        這把鎖必須是需要互斥的多個線程間的共享對象,像下面的代碼是沒有意義的。

        每次進入output方法都會建立一個新的lock,這個鎖顯然每個線程都會建立,沒有意義。

        2. 将synchronized加在需要互斥的方法上。

        這種方式就相當于用this鎖住整個方法内的代碼塊,如果用synchronized加在靜态方法上,就相當于用××××.class鎖住整個方法内的代碼塊。使用synchronized在某些情況下會造成死鎖,死鎖問題以後會說明。使用synchronized修飾的方法或者代碼塊可以看成是一個原子操作。

        每個鎖對象(JLS中叫monitor)都有兩個隊列,一個是就緒隊列,一個是阻塞隊列,就緒隊列存儲了将要獲得鎖的線程,阻塞隊列存儲了被阻塞的線程,當一個線程被喚醒(notify)後,才會進入到就緒隊列,等待CPU的排程,反之,當一個線程被wait後,就會進入阻塞隊列,等待下一次被喚醒,這個涉及到線程間的通信,下一篇博文會說明。看我們的例子,當第一個線程執行輸出方法時,獲得同步鎖,執行輸出方法,恰好此時第二個線程也要執行輸出方法,但發現同步鎖沒有被釋放,第二個線程就會進入就緒隊列,等待鎖被釋放。一個線程執行互斥代碼過程如下:

        1. 獲得同步鎖;

        2. 清空工作記憶體;

  3. 從主記憶體拷貝對象副本到工作記憶體;

4. 執行代碼(計算或者輸出等);

5. 重新整理主記憶體資料;

6. 釋放同步鎖。

        是以,synchronized既保證了多線程的并發有序性,又保證了多線程的記憶體可見性。

        volatile是第二種Java多線程同步的機制,根據JLS(Java LanguageSpecifications)的說法,一個變量可以被volatile修飾,在這種情況下記憶體模型(主記憶體和線程工作記憶體)確定所有線程可以看到一緻的變量值,來看一段代碼:

        一些線程執行one方法,另一些線程執行two方法,two方法有可能列印出j比i大的值,按照之前分析的線程執行過程分析一下:

        1. 将變量i從主記憶體拷貝到工作記憶體;

        2. 改變i的值;

        3. 重新整理主記憶體資料;

        4. 将變量j從主記憶體拷貝到工作記憶體;

        5. 改變j的值;

        6. 重新整理主記憶體資料;

        這個時候執行two方法的線程先讀取了主存i原來的值又讀取了j改變後的值,這就導緻了程式的輸出不是我們預期的結果,要阻止這種不合理的行為的一種方式是在one方法和two方法前面加上synchronized修飾符:

       根據前面的分析,我們可以知道,這時one方法和two方法再也不會并發的執行了,i和j的值在主記憶體中會一直保持一緻,并且two方法輸出的也是一緻的。另一種同步的機制是在共享變量之前加上volatile:

       one方法和two方法還會并發的去執行,但是加上volatile可以将共享變量i和j的改變直接響應到主記憶體中,這樣保證了主記憶體中i和j的值一緻性,然而在執行two方法時,在two方法擷取到i的值和擷取到j的值中間的這段時間,one方法也許被執行了好多次,導緻j的值會大于i的值。是以volatile可以保證記憶體可見性,不能保證并發有序性。

       沒有明白JLS中為什麼使用兩個變量來闡述volatile的工作原理,這樣不是很好了解。volatile是一種弱的同步手段,相對于synchronized來說,某些情況下使用,可能效率更高,因為它不是阻塞的,尤其是讀操作時,加與不加貌似沒有影響,處理寫操作的時候,可能消耗的性能更多些。但是volatile和synchronized性能的比較,我也說不太準,多線程本身就是比較玄的東西,依賴于CPU時間分片的排程,JVM更玄,還沒有研究過虛拟機,從頂層往底層看往往是比較難看透的。在JDK5.0之前,如果沒有參透volatile的使用場景,還是不要使用了,盡量用synchronized來處理同步問題,線程阻塞這玩意簡單粗暴。另外volatile和final不能同時修飾一個字段,可以想想為什麼。