由于CPU的高速運算,主內存的頻率遠遠達不到要求,因此需要將用到的數據進行緩存,比如一個很簡單的操作:
i=i+1;
這樣CPU首先會從主內存中將i的值讀到CPU的高速緩存(工作內存)中,然后進行加1操作,完事之后將新的值寫入到主內存中。有人會想,MDZZ,這有什么好說的,看似簡單的一小步,確是并發編程爬坑的一大步。
假如i的值為0,兩個線程進行操作,最終的結果一定是2嗎?想象一下這樣的一種情況:
Thread1在CPU1上讀取i的值,此時Thread2在CPU2上也讀取了i的值。 Thread1加1操作后寫回主內存,而此時Thread2中緩存的還是0,加1后又寫回主內存,此時i的值是1而不是2。這就是多線程下的緩存一致性問題,那么在多線程環境下是如何解決的呢,有兩種方法:
總線加鎖。 通過緩存一致性協議。總線加鎖是一種獨占的方式占用內存,導致加鎖的內存同一時間只能被一個cpu訪問,比如,Thread1對i=i+1使用了總線鎖,從開始執行到結束的過程,其他線程都無法訪問該內存,也就不存在一致性問題。
緩存一致性協議的思想是:當CPU往主內存寫數據時,如果發現操作的變量是共享變量,即在其他CPU中也存在該變量的副本,會發出信號通知其他CPU將該變量的緩存行置為無效狀態,因此當其他CPU需要讀取這個變量時,發現自己緩存中緩存該變量的緩存行是無效的,那么它就會從內存重新讀取。
所謂的原子性,指的是要么執行成功,要么不執行。這看起來好像又是很簡單,舉個例子:
i++;
這個是原子操作嗎?很多人想當然地認為是,事實上卻是三個操作:1.讀取i的值。2.將i的值加1。3.寫入新的值。如果在多線程的環境下,并發的結果和期望值會不一致,比如有這么一個Counter類,就是一個簡單的自增操作:
package com.rancho945.concurrent;public class Counter { public int count = 0; public void increase() { count++; }}然后開個20線程,每個線程對同一個對象進行一萬次自增操作:
package com.rancho945.concurrent;public class Test { PRivate static final int THREAD_COUNT = 20; public static void main(String[] args) { final Counter counter = new Counter(); for (int i = 0; i < THREAD_COUNT; i++) { new Thread(new Runnable() { @Override public void run() { for (int j = 0; j < 50000; j++) { counter.increase(); } } }).start(); } // 等待所有線程執行完畢 while (Thread.activeCount() > 1) { Thread.yield(); } System.out.println(counter.count); }}每次運行的結果都會有很大的不一樣。就是因為每個線程都緩存了副本,造成數據的不一致,原理跟前面分析的i=i+i是一樣的。我們看一下Counter類的字節碼,在increase方法中,一個自增操作從getfield到putfield包含了四條字節碼,并不是原子操作(這里只是假設一每條字節碼執行都是原子操作也是不嚴謹的,每一條字節碼指令都有可能分幾步執行)
public class com.rancho945.concurrent.Counter { public int count; public com.rancho945.concurrent.Counter(); Code: 0: aload_0 1: invokespecial #10 // Method java/lang/Object."<init>":()V 4: aload_0 5: iconst_0 6: putfield #12 // Field count:I 9: return public void increase(); Code: 0: aload_0 1: dup 2: getfield #12 // Field count:I 5: iconst_1 6: iadd 7: putfield #12 // Field count:I 10: return}看一段代碼
package com.rancho945.concurrent;public class WorkingTask implements Runnable{ private boolean stop = false; @Override public void run() { while(!stop){ //some task } } public void stopTask(){ stop = true; }}這個相信是很多人見過的一段代碼,假如線程1正在執行任務,線程2調用了stopTask,線程1能被終止嗎?絕大多數情況下是可以的,但在極少數情況下停止不了,就會出現迷之bug,原因是當stop變為true時,還沒有來得及把值寫回主內存中,就去做其他事或者阻塞了,導致線程1進入死循環。這就是所謂的可見性問題。事實上前面原子性的例子里面也有可見性的問題,就是當一個線程完成值的修改,重新寫入主內存中后,其他線程緩存了舊值,無法感知到新值的改變。
再看一段代碼
package com.rancho945.concurrent;public class Gift { //表示是否完成禮物的制作 private boolean isInit = false; private String gift = ""; public void makeGift(){ System.out.println("正在制作禮物"); gift = "heart"; System.out.println("禮物制作完成"); isInit = true; } public void sendGift(){ while(!isInit){ System.out.println("禮物還沒準備好,再等等"); } System.out.println("女神,我有東西想送給你"+gift); }}這講的是一個凄美的愛情故事,一個禮物店的老板(線程1)制作禮物(makeGift),當禮物制作好就在禮物盒上打個制作完成的標記(isInit=true);某個屌絲碼農(線程2)送禮物給女神(sendGift),當禮物未完成制作時等待,完成后(isInit=true)就送出去。這看起來也沒有什么問題,但事實上有一定幾率送出的禮物是空的,此時就會被女神呵呵了。
讓我們來看看這個程序為什么會出現這樣的問題,看makeGift的代碼:
照我們的理解,程序是從上到下往下執行的,但是編譯器或者虛擬機在執行的時候有可能會對其進行重排序,也就是先執行isInit=true后再去初始化禮物,如果線程1剛執行完isInit= true但還沒有完成禮物初始化時候線程2剛好讀取到isInit的值,就把禮物送出去了,此時女神將會收到一個空禮物盒。結果就呵呵了。
那么有人就會問,如果是這樣到處重排序,世界都亂了,重排序有一個原則,就是在單線程環境下能夠保證上下文語義的正確性,看下面的一段代碼:
i=10;k=i+8;m=10;k的值依賴于i的值,因此在執行k=i+8之前必須保證i=10執行完畢。然而m=10可以在i=10之前執行,或者是在k=i+8之前執行,這都不會對其結果產生影響,因此JVM虛擬機可能會對執行順序進行重排序從而獲取更好的性能。那么我們在重新看前面禮物的例子就可以解釋為什么會出現禮物沒有制作好就會送出去了。(說得好像送禮物給女神就會逆襲一樣)
瞎逼逼了半天,終于開始講volatile了,那么volatile有什么用,它有兩層語義:
保證更改的內容會馬上寫回到主內存中,并且強制該變量在其他線程中的緩存無效,使改變后的變量對其他線程立即可見。 保證在對該變量操作之前的指令執行完畢,也就是禁止重排序。如果我們將前面可見性例子中的stop變量使用volatile修飾,那么就可以保證改變的值對其他線程立即可見,從而避免停止任務失敗的情況。
如果用volatile修飾Gift類中的isInit變量,就不會出現禮物為空的情況,因為volatile可以保證在執行volatile變量操作之前所有的操作都已經執行完畢,也就是isInit=true的執行不會被重排序到前面(注:volatile重排序語意在JDK1.5之后才完全修復,意味著在1.5之前仍然不能保證不進行重排序)。
那么volatile能夠保證原子性嗎? 很多人覺得既然volatile變量的改變對所有的寫操作對其他的線程立即可見,也就是該變量在各個線程中是一致的,那么也就是線程安全的。這個論據是沒有錯,但是結論是錯的。我們把前面原子性的示例代碼稍微做一下改動:
package com.rancho945.concurrent;public class Counter { //注意這里改成了volatile public volatile int count = 0; public void increase() { count++; }}看一下它的字節碼:
public void increase(); Code: 0: aload_0 1: dup 2: getfield #12 // Field count:I 5: iconst_1 6: iadd 7: putfield #12 // Field count:I 10: returnvolatile能夠保證的是getstatic指令加載變量時變量值是最新的,但是在執行iconst_1和iadd指令的時候,變量的值有可能被其他線程改變了,而此時變量的值仍然不是正確的,因此volatile不能保證原子性。
volatile關鍵字的應用一般來說遵循兩個原則:
變量值不依賴當前狀態。 變量不出現在其他表達式中。第一個原則提現就是前面的i++,因為i++操作依賴當前i的值,所以不能用volatile保證其線程安全。 第二個原則的體現為:
volatile int i;//該表達式依賴了i,不能保證其線程安全k = i+10;volatile常見的兩個應用場景:
這個前面已經說過了,不在贅述。
這里的單例有什么問題呢?我們來看一下,假設有這么一個過程:
線程A和線程B都執行到1處。 線程A執行到2,獲取到類鎖,此時線程B阻塞。 線程A執行到3,發現實例為空,執行到4處。關鍵點到了,正常情況下,4處的執行順序為:
為對象申請內存。 實例化對象。 將對象的內存引用賦給instance。由于可能進行了重排序,4處的執行順序為:
為對象申請內存。 將對象的內存引用賦給instance。 實例化對象。如果進行了重排序,那么在將對象內存賦給instance后(此時對象尚未初始化完成),線程A退出了同步塊,線程B進入了3,發現instance不為null,直接將沒有示例化完成的對象返回,導致獲取的實例結果并不正確。 因此,在單例的引用使用volatile變量修飾,可以保證執行4的時候不被重排序,從而保證單例構造的正確性。
class Singleton{ //注意這里使用了volatile修飾 private volatile static Singleton instance = null; private Singleton() { } public static Singleton getInstance() { if(instance==null) { //1 synchronized (Singleton.class) { //2 if(instance==null) //3 instance = new Singleton(); //4 } } return instance; }}懶漢模式的另外一種優雅的實現方式
public class Singleton { private Singleton() {} public static class Holder { static Singleton instance = new Singleton(); } public static Singleton getInstance() { return Holder.instance; } }該方法通過內部類的靜態變量來實現,由內部類的加載和初始化來保證線程安全。
新聞熱點
疑難解答