(4)Java可見性、原子性、有序性的本質——CPU緩存模型

    我們來看一下併發編程中的原子性、可見性、有序性是怎麼來的。

    早期CPU的頻率比內存的頻率要高很多,如果CPU每次都從內存取數據的話,就會造成快車等慢車的狀態,嚴重影響CPU的性能。爲了解決這個問題,CPU中引入了緩存。緩存的頻率很高,幾乎跟CPU一個級別。於是就將一些用到的重要數據複製一份放到緩存中,CPU直接跟緩存交互,就能消除內存與CPU頻率相差較大的問題了。

    在單核CPU的時代沒有這麼多的麻煩事,後來爲了提升性能開始使用多核CPU。CPU中的每一個核心都有緩存,於是對於內存中的同一份數據,在各個CPU緩存中有一份副本,於是問題就來了!

CPU緩存模型

一、 可見性問題

    

    主內存中有一個int data = 0,則CPU0和CPU1的緩存中都存有一份data的副本,值也爲0。當CPU0執行一個data++操作後,副本1的data數據變成了1。副本1的data還沒有寫回主內存,主內存的data值爲0,副本2的data值也爲0。因此,這個時候兩個副本的值不一致了,如果繼續操作就會造成數據的錯誤。

    一個線程修改了共享變量的值,是否能夠立即被其他線程見到最新值,這就可見性問題。

    java通過volatile關鍵字解決了可見性的問題。

    volatile的實現原理是:

   1. 當一個線程修改了共享變量,CPU的嗅探機制會發現副本與主內存數據的不一致,通過彙編lock前綴指令,鎖定共享變量主內存區域,並將新值寫回到主內存;

   2. 寫回內存的操作會使其他CPU中緩存了該數據的地址失效。(MESI協議,即緩存一致性協議)

 

二、原子性問題

    原子性就是計算機中的一個不能再分割的動作,要麼做完、要麼不做,中間不會被打斷。

    我們對一個volatile修改的int值初始化爲0,用10000個線程去對它進行+1操作。 期望中的結果是10000,但是實際上每次運行的結果都小於10000。

public static void main(String[] args) throws InterruptedException{
    public static volatile int count = 0;

    for(int i = 0; i < 10000; ++i){
        new Thread(new Runnable(){
            public void run(){
                ++count; //每個線程都對count進行+1
            }
        })
    }
    Thread.sleep(5000); //等待一下確保所有的線程都運行完了
    System.out.println(count);    //9985,9970
}

    這就是因爲volatile不能保證多線程計算的原子性問題。

    假如CPU0的緩存裏有一個副本count=0,對它進行+1後count=1, 然後將它寫回主內存的過程中,需要有一個assign(將CPU0的計算結果放入CPU0的高速緩存)動作 和一個write(將CPU0的高速緩存的值寫入主內存)動作,而這兩步不是同時完成的。而這中間就有可能插入別的動作。

    如果CPU0計算完+1後,還未將結果count=1寫入主內存;CPU1見縫插針利用自己緩存中的count=0計算count+1,然後將值寫入了主內存count=1,之後CPU0又茫然不知地將算好的結果count=1寫入主內存。 這就造成了兩次count++,結果卻=1的情況。

    java利用synchronized關鍵字來解決原子性問題。 (還有Lock和Atomic類,另講)

    synchronized實現原子性的原理是:

    利用對象頭中的mark word存儲鎖的信息,當一個線程佔用這個對象時會將threadID寫入對象頭,其他線程就不能獲取這個對象,以此來實現排他性。

 

三、 有序性

     我們來看一段代碼。 在兩個線程中分別對原本爲0的值x、y賦值,正常情況下x、y的值應該都爲0,但是在10000次循環中很快就會出現x=1,y=1的情況而退出循環。

public class MainTest {
    static int a =0, b = 0;
    static int x = 0, y = 0;
    
    public static void func(){
        a = 0; b = 0;
        x = 0; y = 0;
    }
    
    public static void main(String[] args){

        for(int i = 0; i < 10000; ++i){
            func();
            
            new Thread(new Runnable(){
                public void run(){
                    x = a;
                    a = 1;
                }
            }).start();


            new Thread(new Runnable(){
                public void run(){
                    y = b;
                    b = 1;
                }
            }).start();
            
            System.out.println("x = " + x + " y = " + y);
            
            if (1 == x && 1 == y) {
                System.out.println("x = " + x + " y = " + y);
                break;
            }
        }
    }
}

    這是因爲存在着指令重排序的問題,編譯器和處理器會對指令進行優化而出現重排序的情況。

    在單線程的情況下沒有影響,但在多線程情況下可能會產生影響。

    java使用volatile關鍵字解決了指令的重排序問題,volatile的底層使用了內存屏障。針對跨處理器的讀寫操作,它被插入到兩個指令之間,作用是禁止編譯器和處理器重排序。

    至此, 可見性、原子性、有序性的問題得到了解決。

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