併發編程-(volatile)可見性&有序性

併發編程-(volatile)可見性&有序性

【可見性】:就是兩個線程對同一個變量進行修改線程a修改後,線程b沒有讀取到修改後的數據,相似於數據庫中的髒讀。java

【有序性】:在java內存模型中,容許編譯器和處理器對指令進行從新排序,在單線程的時候不影響,可是在多線程的時候,就會影響執行結果的正確性。數據庫

【volatile】:java提供的一種解決上面的這兩種問題的方式,底層是cpu提供的#Lock指令。編程

可見性

模擬兩個線程,兩個線程對同一個數據進行改變,看看線程a改變了數據,線程b是否能夠覺察,運行下面的代碼,咱們發現,main線程對flag進行修改,並不能中止咱們執行while循環的線程,這就是線程可見性問題。緩存

    private static boolean flag=false;
    public static void main(String[] args) throws InterruptedException {
        Thread thread=new Thread(()->{
            int i=0;
            while (!flag){
                i++;
            }
        });
        thread.start();
        System.out.println("thread began execute");
        Thread.sleep(100);
        flag=true;
    }
}

如何解決線程可見性問題呢

1.實際上咱們只要增長volatile便可,能夠這樣認爲,加了這個關鍵字後,當其中一個線程修改了一個變量後,其餘的線程對應也能夠看見修改後的內容,其實jvm中有作深度優化(活性失敗),在底層優化後while(!flag)就變成了【whild(true)】,volatile就是禁止這樣優化,從而讓程序正常的運行。安全

 

2.咱們可讓線程睡一下,實際上即便是0睡0毫米,cup也會進行切換,切換後,咱們的數值就會從新加載,起到正常運行的目的多線程

3. 能夠打印 i 的數值,這實際上是一個 io 的操做,底層有synchronize的鎖,變量也會從新加載。架構

 

可見性是什麼致使的

咱們都知道cup是核心的資源,咱們不能讓cpu資源的浪費,爲了充分利用cpu資源的,咱們想出了一系列方案,可是在這一系列方案去優化cpu的使用率的時候,咱們就碰到了可見性的問題。這些優化的方案爲: 併發

  • 給CPU增長高速緩存
  • 給操做系統中增長進程和線程這樣的東西
  • 編譯器層面:上面提到的jvm的深度優化就是一個例子

CPU的高速緩存

由於cpu的處理速度和內存讀寫的速度差距較大,那就形成了cpu讀取內存數據的時候消耗很長時間,那能不能像咱們作分佈式開發的時候的那樣,把數據緩存在Redis中,從而減小性能的消耗,減小時間呢,是的cpu的高速緩存就是相似這樣作的,cpu的高速緩存就是介於CPU處理器和內存之間的臨時數據交換的緩衝區。他的流程就是當cpu讀取數據的時候,當緩存中沒有數據,纔去內存中讀取。實際上在cpu中設計了三層緩存,每個cpu中都有三個緩存,L1和L2是cpu獨立存在的,其中L2緩存和L3是共享的,L1緩存中分爲數據緩存(L1D)和指令緩存(L1I)jvm

咱們知道了緩存行的存在,就明白了爲何上面的代碼main修改了變量flag,可是執行i++的線程卻不能感應到的緣由了,這就是緩存一致性問題,那麼如何解決?這裏引入一個概念緩存行分佈式

 緩存行

cpu的緩存是由多個緩存行組成的,cpu每一次讀取數據的時候都只會加載一段數據,緩存行是cpu和內存交互的最小工做單元,在x86的架構中,每一個緩存行是64個字節,因此cpu在讀取數據的時候,一次就讀取64個字節。由於在高速緩存的時候,涉及到一個僞共享的問題,那什麼是僞共享?

僞共享

如今主流的緩存行都是64Bytes大小的,試想,若是兩個線程同時修改同一個緩存行中的變量,那勢必會形成互相競爭,緩存行一次讀取了【ABCDEFGH,】線程1要修改A的數值,線程2要修改B的內容,當線程1修改了A的數值線程0的緩存行就會失效,當線程2修改B的數值的時候,那線程a的緩存行就會失效,這樣就耗費了性能,這就是僞共享。那麼怎麼解決這個問題,那就要用到【對齊填充】了。

對齊填充

既然他每次都cpu每次都要讀取64Bytes,可是這64個Bytes中可能加載了僞共享狀況的發生,那咱們能不能每次只加載一個數據,其他都給他自動填充成別的數據,這樣時候就避免了僞共享的發生,其實對齊填充就是這樣作的,當咱們只要操縱【ABCDEFGH】中的A的時候,那咱們就第一個數據就只讀取A,其他的位數就這樣A*******

一個例子說明對齊填充的性能

// 對齊填充
public class ValuePadding {
    protected long p1, p2, p3, p4, p5, p6, p7;
    protected volatile long value = 0L;
    protected long p9, p10, p11, p12, p13, p14;
    protected long p15;
}
public class CacheLineExample  implements Runnable {
    public final static long ITERATIONS = 500L * 1000L * 100L;
    private int arrayIndex = 0;
    private static ValuePadding[] longs;

    public CacheLineExample(final int arrayIndex) {
        this.arrayIndex = arrayIndex;
    }

    @Override
    public void run() {
        long i = ITERATIONS + 1;
        while (0 != --i) {
            longs[arrayIndex].value = 0L;
        }
    }

    static void runTest(int NUM_THREADS) throws InterruptedException {
        Thread[] threads = new Thread[NUM_THREADS];

        longs = new ValuePadding[NUM_THREADS];

        for (int i = 0; i < longs.length; i++) {
            longs[i] = new ValuePadding();
        }
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(new CacheLineExample(i));
        }

        for (Thread t : threads) {
            t.start();
        }
        for (Thread t : threads) {
            t.join();
        }
    }

}
public class Test {
    public static void main(final String[] args) throws Exception {
        for (int i = 1; i < 10; i++) {
            System.gc();
            final long start = System.currentTimeMillis();
            CacheLineExample.runTest(i);
            System.out.println(i + " Threads, duration = " + (System.currentTimeMillis() - start));
        }
    }

}

對齊填充

 不對齊填充(把CacheLineExample中的對齊填充的類替換便可

// 不對齊填充
public class ValueNoPadding {
    //8字節
    protected volatile long value = 0L;
}

tips:咱們發現對齊填充明顯比不對齊填充運行的要快得多,在java8中咱們使用@Contended 去避免僞共享的問題,可是前提是你必須設置jvm的參數

緩存一致性問題

上面說了給cpu加上緩存是一種解決方案,可是這就又致使了緩存一致性問題【可見性】,緩存不一致問題,正如上述講的demo,main修改了flag沒有影響到別的線程的數據,其實咱們能夠進行鎖的方式解決,就是當個人某一個線程在修改內存的數據的時候,阻塞別的線程,或者,咱們是否是能夠只對某個緩存行進行加鎖呢,這樣別的線程就不用阻塞,從而提升了性能,那就引出了兩個概念。

  • 總線鎖:其實在線程訪問內存的時候還有要通過一個線程總線,其實咱們能夠給這個總線加鎖,可是這就想當於阻塞其餘線程。
  • 緩存鎖:咱們知道cpu是一個緩存行一個緩存行對數據進行讀取,那麼咱們就能夠對緩存行進行加鎖,從而提升控制的粒度,也不會對其餘的線程阻塞,那麼緩存鎖是如何保障一致性的呢,那麼就要引入一個概念, 緩存一致性協議MESI.

 MESL(表示緩存的4中狀態)

cpu讀:【M、E、S】都可被讀取

cpu寫:【M、E】,當狀態爲S時必須先把其餘cpu緩存中的狀態設爲失效

  •  M  (Modify:修改):當咱們對某個變量進行修改,那狀態就變成了 M,該緩存行有效,數據被修改了,和內存中的數據不一致數據只存在於本緩存行中
  •  E(Exclusive:互斥):也是就這一個數據只存在當前cpu對其餘不可見那麼狀態就是 E,該緩存行有效,數據和內存中的數據一致,數據只存在於本緩存行中
  •  S(Shared:分享):和其餘cpu共享一個數值時,狀態就是 S ,該緩存行有效,數據和內存中的數據一致,數據同時存在於其餘緩存中
  •     I   (Invalid:無效):當共享一個變量時,cpuA把一個數據修改,那麼就要把別的cpu中的狀態變爲  I 該緩存行數據無效

如何使用mesl呢

這裏要引入一個東西叫作snoopy協議,咱們都知道有線程總線這樣一個概念,當其餘cpu發起任務時候,會有一個狀態在緩存總線上,這個snoopy就是來監聽其餘cpu的狀態的對不一樣狀態作出不一樣的反應,你能夠理解緩存、cpu、snoopy是一個一個總線上的一個個的總體。好比說cpuA要修改flag=1那麼他先修改本身的高速緩存,同時同步到主內存的的時候,會發送一個失效的指令到緩存總線中,這個時候其餘的的線程中的flag狀態失效,而後他們就會去內存中從新獲取數據。這就達到了緩存一致性的目的。這裏,咱們就知道當咱們聲明 volatile關鍵字的時候,底層就會有一個彙編指令【#lock】去對cpu加鎖多是總線鎖、也多是緩存鎖取決於你的cpu

【#lock】:能夠解決總線鎖、緩存鎖、以及內存屏障的問題

 

有序性

有時候代碼可能不是按照正常順序執行的,在多線程狀況下,咱們就能看出,例以下面的代碼:正常執行結果多是(1,0)、(0,1)或(1,1) 然而可能有第三種可能那就是(0,0)出現這種狀況就是顛倒執行了順序。像我下面圖中最後的執行場景。然而這種狀況很難發生,你須要運行下面的代碼好久,我是循環了200多萬次才復現的這個場景。那麼究竟是什麼致使這種指令重排序的問題發生的呢?

 

public class seq  {
    private  static int x = 0, y = 0;
    private  static int a = 0, b = 0;


    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for (; ; ) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;
            Thread t1 = new Thread(() -> {
                a = 1;
                x = b;
            });
            Thread t2 = new Thread(() -> {
                b = 1;
                y = a;
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();
            String result = "第" + i + "次(" + x + "," + y + ")";
            if (x == 0 && y == 0) {
                System.out.println(result);
                break;
            } else {
            }
        }
    }

}

 

什麼致使了指令重排序

store buffer】:咱們能夠想象他是相似於一個隊列,上面說了我們的MESL當cpu修改某個數值的時候,其實先把這個消息告訴store buffer,而後他去通知別的cpu,說這個數值已經失效,接着其餘cpu回覆當前cpu收到後,當前cpu才修改他的緩存中的數值和內存中的數值,這個store buffer在必定的程度上提升了性能,由於當cpu把他要修改內存中的一個數值的信息告訴其餘cpu的時候,其餘cpu須要回覆當前cpu後,當前cup才能對數據進行操做,那在等待的時候可能會有一段時間當前cpu閒置,這個store buffer 就能夠去幫助當前cpu去通知消息,那樣當前cpu就能夠作後面的事情了,等到這個store buffer 返回消息後,他就能夠對內存中的數據進行修改了

咱們來看這段代碼,最終結果爲什是b=1,用這個來分析致使指令重排序的緣由

 

用一個圖來闡述問題

 其實上面問題就是cpuA他給了【store buffer】了這個數值而後是其餘線程返回了這個數據,那試想是否能夠直接從store buffer中獲取這個數據呢?是的,這個過程叫【store forwarding】,cpu能夠直接從本身的store buffer中讀取數據,可是這又會致使問題,這裏不進行展開,推薦去一個博客內容

 tips:store buffer 和【store forwarding】已經很大程度的讓cpu資源利用最大化,可是有個問題,那就是【store buffer的大小是有限制的若是什麼都放入這個裏面cpu可能會阻塞的!,那又引入一個東西【Invalidate Queue】

 Invalidate Queue(失效隊列)

 實際上就是上面的圖中的第二步,cpuA等到別的cpu返回消息後纔對本身的信息進行修改,那麼是否能夠在cpuB中放置一個隊列同樣的東西,去接受cpuA的消息,而後立馬返回收到的信息呢?是這樣的。Invalidate Queue】就是一個隊列,接受其餘cpu的失效內容,而後立馬返回一個收到的信息【 invalidate ack】而後本身慢慢處理,這樣就能夠增長增長一些效率,讓store buffer更快的執行。可是咱們想這裏可能又致使了另一個問題,就是實際上,他只是放在本身的隊列中,而沒有真的對這個消息進行處理,或者說沒有來的急對這個消息進行處理,可是線程別收到他的假消息,因而繼續操做這個數據,因此可能又引出了指令重排序的問題。記住咱們這些都是在說有序性的緣由,這一切均可以使用【volatile】解決。那實在的怎麼解決呢?這個時候要再介紹一個名詞【內存屏障】

 

 內存屏障

  • 寫屏障:全部的寫操做必須在寫屏障前面完成
  • 讀屏障:全部的讀操做必須在讀屏障前完成
  • 和全屏障:讀寫操做必須在全屏障以前完成

內存屏障你就想象他是一個標記,他會鎖定內存的系統,去確保執行順序。他底層仍是經過#Lock指令,#Lock指令是一個全屏障。這裏記住#Lock指令在不一樣的系統中實現內存屏障的指令不一樣,只不過在X86中是#Lcok,其餘的架構中,咱們不去深究。

 總結

可見性:咱們想充分利用cpu的資源,因此提出使用cpu高速緩存去緩存一些數據,去減小cpu的執行時間加強執行效率,因此每一個cpu都有本身的【緩存行】,這裏牽扯到一個【僞共享】的問題,消耗了cpu的性能,而後使用了【對齊填充】解決了這個問題。咱們想到可使用加鎖的方式對數據安全進行保障,而後又引入了【總線鎖、和緩存行鎖】去解決這個問題,這些鎖是基於【mesl】協議去作的,咱們使用【snoopy】去輔助各個線程去監聽別的線程的【mesl】協議的狀態,咱們知道若是一個變量被volatile所修飾的話,在每次數據變化以後,值都會被強制刷入主存。而其餘處理器的緩存因爲遵照了mesl協議,也會把這個變量的值從主存加載到本身的緩存中,這就解決了可見性問題。

有序性:【store buffer】輔助cpu去通知各個線程本身的狀態,可是其實直接從【store buffer】中獲取當前本身已經修改的內存比通知別的線程更能提升性能,因此咱們就引入了【store forwarding】,然而store forwarding 的內存大小有限,因此咱們引入了【 Invalidate Queue】去充當一個失效隊列,cpu在接收到內容後,就直接返回本身的結果,可是有可能他急於返回內存,並無處理,那就形成了可能在後面的流程須要用到這個值,然而沒有操做的結果,因此就致使了【指令重排序】,那隻能交給開發本身解決,這個時候用volatile關鍵字就能夠調用cpu提供的【內存屏障】,從而解決有序性問題。

相關文章
相關標籤/搜索