多線程:volatile,synchronized關鍵字

volatile就是表示某人或某物是不穩定的、易變的。

volatile作爲java中的關鍵詞之一,用以聲明變量的值可能隨時會別的線程修改,使用volatile修飾的變量會強制將修改的值立即寫入主存,主存中值的更新會使緩存中的值失效(非volatile變量不具備這樣的特性,非volatile變量的值會被緩存,線程A更新了這個值,線程B讀取這個變量的值時可能讀到的並不是是線程A更新後的值)。volatile會禁止指令重排。

volatile特性

volatile具有可見性、有序性,不具備原子性。

注意,volatile不具備原子性,這是volatile與java中的synchronized、java.util.concurrent.locks.Lock最大的功能差異,這一點在面試中也是非常容易問到的點。

下面來分別看下可見性、有序性、原子性:

原子性:如果你瞭解事務,那這個概念應該好理解。原子性通常指多個操作不存在只執行一部分的情況,如果全部執行完成那沒毛病,如果只執行了一部分,那對不起,你得撤銷(即事務中的回滾)已經執行的部分。可見性:當多個線程訪問同一個變量x時,線程1修改了變量x的值,線程1、線程2...線程n能夠立即讀取到線程1修改後的值。有序性:即程序執行時按照代碼書寫的先後順序執行。在Java內存模型中,允許編譯器和處理器對指令進行重排序,但是重排序過程不會影響到單線程程序的執行,卻會影響到多線程併發執行的正確性。(本文不對指令重排作介紹,但不代表它不重要,它是理解JAVA併發原理時非常重要的一個概念)。

volatile適用場景

適用於對變量的寫操作不依賴於當前值,對變量的讀取操作不依賴於非volatile變量。適用於讀多寫少的場景。可用作狀態標誌。JDK中volatie應用:JDK中ConcurrentHashMap的Entry的value和next被聲明爲volatile,AtomicLong中的value被聲明爲volatile。AtomicLong通過CAS原理(也可以理解爲樂觀鎖)保證了原子性。

volatile VS synchronized

synchronized關鍵字是防止多個線程同時執行一段代碼,那麼就會很影響程序執行效率,而volatile關鍵字在某些情況下性能要優於synchronized,但是要注意volatile關鍵字是無法替代synchronized關鍵字的,因爲volatile關鍵字無法保證操作的原子性。通常來說,使用volatile必須具備以下2個條件:

  1)對變量的寫操作不依賴於當前值

  2)該變量沒有包含在具有其他變量的不變式中

  實際上,這些條件表明,可以被寫入 volatile 變量的這些有效值獨立於任何程序的狀態,包括變量的當前狀態。

  事實上,我的理解就是上面的2個條件需要保證操作是原子性操作,才能保證使用volatile關鍵字的程序在併發時能夠正確執行。

大家都知道,計算機在執行程序時,每條指令都是在CPU中執行的,而執行指令過程中,勢必涉及到數據的讀取和寫入。由於程序運行過程中的臨時數據是存放在主存(物理內存)當中的,這時就存在一個問題,由於CPU執行速度很快,而從內存讀取數據和向內存寫入數據的過程跟CPU執行指令的速度比起來要慢的多,因此如果任何時候對數據的操作都要通過和內存的交互來進行,會大大降低指令執行的速度。因此在CPU裏面就有了高速緩存。

  也就是,當程序在運行過程中,會將運算需要的數據從主存複製一份到CPU的高速緩存當中,那麼CPU進行計算時就可以直接從它的高速緩存讀取數據和向其中寫入數據,當運算結束之後,再將高速緩存中的數據刷新到主存當中。舉個簡單的例子,比如下面的這段代碼:

1

i = i + 1;

   當線程執行這個語句時,會先從主存當中讀取i的值,然後複製一份到高速緩存當中,然後CPU執行指令對i進行加1操作,然後將數據寫入高速緩存,最後將高速緩存中i最新的值刷新到主存當中。

  這個代碼在單線程中運行是沒有任何問題的,但是在多線程中運行就會有問題了。在多核CPU中,每條線程可能運行於不同的CPU中,因此每個線程運行時有自己的高速緩存(對單核CPU來說,其實也會出現這種問題,只不過是以線程調度的形式來分別執行的)。本文我們以多核CPU爲例。

  比如同時有2個線程執行這段代碼,假如初始時i的值爲0,那麼我們希望兩個線程執行完之後i的值變爲2。但是事實會是這樣嗎?

  可能存在下面一種情況:初始時,兩個線程分別讀取i的值存入各自所在的CPU的高速緩存當中,然後線程1進行加1操作,然後把i的最新值1寫入到內存。此時線程2的高速緩存當中i的值還是0,進行加1操作之後,i的值爲1,然後線程2把i的值寫入內存。

  最終結果i的值是1,而不是2。這就是著名的緩存一致性問題。通常稱這種被多個線程訪問的變量爲共享變量。

  也就是說,如果一個變量在多個CPU中都存在緩存(一般在多線程編程時纔會出現),那麼就可能存在緩存不一致的問題。

  爲了解決緩存不一致性問題,通常來說有以下2種解決方法:

  1)通過在總線加LOCK#鎖的方式

  2)通過緩存一致性協議

  這2種方式都是硬件層面上提供的方式。

  在早期的CPU當中,是通過在總線上加LOCK#鎖的形式來解決緩存不一致的問題。因爲CPU和其他部件進行通信都是通過總線來進行的,如果對總線加LOCK#鎖的話,也就是說阻塞了其他CPU對其他部件訪問(如內存),從而使得只能有一個CPU能使用這個變量的內存。比如上面例子中 如果一個線程在執行 i = i +1,如果在執行這段代碼的過程中,在總線上發出了LCOK#鎖的信號,那麼只有等待這段代碼完全執行完畢之後,其他CPU才能從變量i所在的內存讀取變量,然後進行相應的操作。這樣就解決了緩存不一致的問題。

  但是上面的方式會有一個問題,由於在鎖住總線期間,其他CPU無法訪問內存,導致效率低下。

  所以就出現了緩存一致性協議。最出名的就是Intel 的MESI協議,MESI協議保證了每個緩存中使用的共享變量的副本是一致的。它核心的思想是:當CPU寫數據時,如果發現操作的變量是共享變量,即在其他CPU中也存在該變量的副本,會發出信號通知其他CPU將該變量的緩存行置爲無效狀態,因此當其他CPU需要讀取這個變量時,發現自己緩存中緩存該變量的緩存行是無效的,那麼它就會從內存重新讀取。

volatile關鍵字的兩層語義

  一旦一個共享變量(類的成員變量、類的靜態成員變量)被volatile修飾之後,那麼就具備了兩層語義:

  1)保證了不同線程對這個變量進行操作時的可見性,即一個線程修改了某個變量的值,這新值對其他線程來說是立即可見的。

  2)禁止進行指令重排序。

  先看一段代碼,假如線程1先執行,線程2後執行:

1

2

3

4

5

6

7

8

//線程1

boolean stop = false;

while(!stop){

    doSomething();

}

 

//線程2

stop = true;

   這段代碼是很典型的一段代碼,很多人在中斷線程時可能都會採用這種標記辦法。但是事實上,這段代碼會完全運行正確麼?即一定會將線程中斷麼?不一定,也許在大多數時候,這個代碼能夠把線程中斷,但是也有可能會導致無法中斷線程(雖然這個可能性很小,但是隻要一旦發生這種情況就會造成死循環了)。

  下面解釋一下這段代碼爲何有可能導致無法中斷線程。在前面已經解釋過,每個線程在運行過程中都有自己的工作內存,那麼線程1在運行的時候,會將stop變量的值拷貝一份放在自己的工作內存當中。

  那麼當線程2更改了stop變量的值之後,但是還沒來得及寫入主存當中,線程2轉去做其他事情了,那麼線程1由於不知道線程2對stop變量的更改,因此還會一直循環下去。

  但是用volatile修飾之後就變得不一樣了:

  第一:使用volatile關鍵字會強制將修改的值立即寫入主存;

  第二:使用volatile關鍵字的話,當線程2進行修改時,會導致線程1的工作內存中緩存變量stop的緩存行無效(反映到硬件層的話,就是CPU的L1或者L2緩存中對應的緩存行無效);

  第三:由於線程1的工作內存中緩存變量stop的緩存行無效,所以線程1再次讀取變量stop的值時會去主存讀取。

  那麼在線程2修改stop值時(當然這裏包括2個操作,修改線程2工作內存中的值,然後將修改後的值寫入內存),會使得線程1的工作內存中緩存變量stop的緩存行無效,然後線程1讀取時,發現自己的緩存行無效,它會等待緩存行對應的主存地址被更新之後,然後去對應的主存讀取最新的值。

  那麼線程1讀取到的就是最新的正確的值。

2.volatile保證原子性嗎?

  從上面知道volatile關鍵字保證了操作的可見性,但是volatile能保證對變量的操作是原子性嗎?

  下面看一個例子:

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

17

18

19

20

21

22

23

public class Test {

    public volatile int inc = 0;

     

    public void increase() {

        inc++;

    }

     

    public static void main(String[] args) {

        final Test test = new Test();

        for(int i=0;i<10;i++){

            new Thread(){

                public void run() {

                    for(int j=0;j<1000;j++)

                        test.increase();

                };

            }.start();

        }

         

        while(Thread.activeCount()>1)  //保證前面的線程都執行完

            Thread.yield();

        System.out.println(test.inc);

    }

}

   大家想一下這段程序的輸出結果是多少?也許有些朋友認爲是10000。但是事實上運行它會發現每次運行結果都不一致,都是一個小於10000的數字。

  可能有的朋友就會有疑問,不對啊,上面是對變量inc進行自增操作,由於volatile保證了可見性,那麼在每個線程中對inc自增完之後,在其他線程中都能看到修改後的值啊,所以有10個線程分別進行了1000次操作,那麼最終inc的值應該是1000*10=10000。

  這裏面就有一個誤區了,volatile關鍵字能保證可見性沒有錯,但是上面的程序錯在沒能保證原子性。可見性只能保證每次讀取的是最新的值,但是volatile沒辦法保證對變量的操作的原子性。

  在前面已經提到過,自增操作是不具備原子性的,它包括讀取變量的原始值、進行加1操作、寫入工作內存。那麼就是說自增操作的三個子操作可能會分割開執行,就有可能導致下面這種情況出現:

  假如某個時刻變量inc的值爲10,

  線程1對變量進行自增操作,線程1先讀取了變量inc的原始值,然後線程1被阻塞了;

  然後線程2對變量進行自增操作,線程2也去讀取變量inc的原始值,由於線程1只是對變量inc進行讀取操作,而沒有對變量進行修改操作,所以不會導致線程2的工作內存中緩存變量inc的緩存行無效,所以線程2會直接去主存讀取inc的值,發現inc的值時10,然後進行加1操作,並把11寫入工作內存,最後寫入主存。

  然後線程1接着進行加1操作,由於已經讀取了inc的值,注意此時在線程1的工作內存中inc的值仍然爲10,所以線程1對inc進行加1操作後inc的值爲11,然後將11寫入工作內存,最後寫入主存。

  那麼兩個線程分別進行了一次自增操作後,inc只增加了1。

  解釋到這裏,可能有朋友會有疑問,不對啊,前面不是保證一個變量在修改volatile變量時,會讓緩存行無效嗎?然後其他線程去讀就會讀到新的值,對,這個沒錯。這個就是上面的happens-before規則中的volatile變量規則,但是要注意,線程1對變量進行讀取操作之後,被阻塞了的話,並沒有對inc值進行修改。然後雖然volatile能保證線程2對變量inc的值讀取是從內存中讀取的,但是線程1沒有進行修改,所以線程2根本就不會看到修改的值。

  根源就在這裏,自增操作不是原子性操作,而且volatile也無法保證對變量的任何操作都是原子性的。

  把上面的代碼改成以下任何一種都可以達到效果:

  採用synchronized:

 View Code

  採用Lock:

 View Code

  採用AtomicInteger:

 View Code

  在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作類,即對基本數據類型的 自增(加1操作),自減(減1操作)、以及加法操作(加一個數),減法操作(減一個數)進行了封裝,保證這些操作是原子性操作。atomic是利用CAS來實現原子性操作的(Compare And Swap),CAS實際上是利用處理器提供的CMPXCHG指令實現的,而處理器執行CMPXCHG指令是一個原子性操作。

volatile能保證有序性嗎?

  在前面提到volatile關鍵字能禁止指令重排序,所以volatile能在一定程度上保證有序性。

  volatile關鍵字禁止指令重排序有兩層意思:

  1)當程序執行到volatile變量的讀操作或者寫操作時,在其前面的操作的更改肯定全部已經進行,且結果已經對後面的操作可見;在其後面的操作肯定還沒有進行;

  2)在進行指令優化時,不能將在對volatile變量訪問的語句放在其後面執行,也不能把volatile變量後面的語句放到其前面執行。

  可能上面說的比較繞,舉個簡單的例子:

1

2

3

4

5

6

7

8

//x、y爲非volatile變量

//flag爲volatile變量

 

x = 2;        //語句1

y = 0;        //語句2

flag = true;  //語句3

x = 4;         //語句4

y = -1;       //語句5

   由於flag變量爲volatile變量,那麼在進行指令重排序的過程的時候,不會將語句3放到語句1、語句2前面,也不會講語句3放到語句4、語句5後面。但是要注意語句1和語句2的順序、語句4和語句5的順序是不作任何保證的。

  並且volatile關鍵字能保證,執行到語句3時,語句1和語句2必定是執行完畢了的,且語句1和語句2的執行結果對語句3、語句4、語句5是可見的。

  那麼我們回到前面舉的一個例子:

1

2

3

4

5

6

7

8

9

//線程1:

context = loadContext();   //語句1

inited = true;             //語句2

 

//線程2:

while(!inited ){

  sleep()

}

doSomethingwithconfig(context);

   前面舉這個例子的時候,提到有可能語句2會在語句1之前執行,那麼久可能導致context還沒被初始化,而線程2中就使用未初始化的context去進行操作,導致程序出錯。

  這裏如果用volatile關鍵字對inited變量進行修飾,就不會出現這種問題了,因爲當執行到語句2時,必定能保證context已經初始化完畢。

volatile的原理和實現機制

  前面講述了源於volatile關鍵字的一些使用,下面我們來探討一下volatile到底如何保證可見性和禁止指令重排序的。

  下面這段話摘自《深入理解Java虛擬機》:

  “觀察加入volatile關鍵字和沒有加入volatile關鍵字時所生成的彙編代碼發現,加入volatile關鍵字時,會多出一個lock前綴指令”

  lock前綴指令實際上相當於一個內存屏障(也成內存柵欄),內存屏障會提供3個功能:

  1)它確保指令重排序時不會把其後面的指令排到內存屏障之前的位置,也不會把前面的指令排到內存屏障的後面;即在執行到內存屏障這句指令時,在它前面的操作已經全部完成;

  2)它會強制將對緩存的修改操作立即寫入主存;

  3)如果是寫操作,它會導致其他CPU中對應的緩存行無效。

Java併發環境下指令重排會帶來很多問題,那到底什麼是指令重排?

大概意思就是,同一個方法中,兩個變量的定義前後,會影響程序最後的運行結果。

寫後讀 a = 1;b = a; 寫一個變量之後,再讀這個位置。
寫後寫 a = 1;a = 2; 寫一個變量之後,再寫這個變量。
讀後寫 a = b;b = 1; 讀一個變量之後,再寫這個變量。
以上語句不可重排
編譯器不考慮多線程間的語義
可重排: a=1;b=2;

指令重排 會 破壞線程間的有序性。
如:

Java code?

1

2

3

4

5

6

7

8

9

10

11

12

13

14

15

16

class OrderExample {

int a = 0;

boolean flag = false;

 

public void writer() {

    a = 1;                   

    flag = true;           

}

 

public void reader() {

    if (flag) {                

        int i =  a +1;      

        ……

    }

}

}



線程A首先執行writer()方法
線程B線程接着執行reader()方法
線程B在int i=a+1 是不一定能看到a已經被賦值爲1

因爲在writer中,兩句話順序可能打亂

線程A
flag=true
a=1

線程B
flag=true(此時a=0)

避免指令重排:加上同步鎖synchronized,當然,當保證同步時,就犧牲了程序的效率

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章