Java併發編程-原子操做

1. 指令重排序

關於指令重排序的概念,比較複雜,很差理解。咱們從一個例子分析:html

public class SimpleHappenBefore {

    /** 這是一個驗證結果的變量 */
    private static int a=0;

    /** 這是一個標誌位 */
    private static boolean flag=false;

    public static void main(String[] args) throws InterruptedException {

        //因爲多線程狀況下未必會試出重排序的結論,因此多試一些次
        for(int i = 0; i < 1000; i++){

            ThreadA threadA=new ThreadA();
            ThreadB threadB=new ThreadB();

            threadA.start();
            threadB.start();

            //這裏等待線程結束後,重置共享變量,以使驗證結果的工做變得簡單些.
            threadA.join();
            threadB.join();

            a = 0;
            flag = false;
        }
    }

    static class ThreadA extends Thread{
        public void run(){
            a = 1;
            flag = true;
        }
    }

    static class ThreadB extends Thread{
        public void run(){
            if(flag){
                a = a * 1;
            }

            if(a == 0){
                System.out.println("ha,a==0");
            }
        }
    }
}

一個簡單的展現Happen-Before的例子.
這裏有兩個共享變量:a和flag,初始值分別爲0和false.在ThreadA中先給a=1,而後flag=true.java

若是按照有序的話,那麼在ThreadB中若是if(flag)成功的話,則應該a=1,而a=a*1以後a仍然爲1,下方的if(a==0)應該永遠不會爲真,永遠不會打印.算法

但實際狀況是:在試驗100次的狀況下會出現0次或幾回的打印結果,而試驗1000次結果更明顯,有十幾回打印.編程

1.1. 什麼是指令重排序

在虛擬機層面,爲了儘量減小內存操做速度遠慢於CPU運行速度所帶來的CPU空置的影響,虛擬機會按照本身的一些規則(這規則後面再敘述)將程序編寫順序打亂——即寫在後面的代碼在時間順序上可能會先執行,而寫在前面的代碼會後執行——以儘量充分地利用CPU。緩存

拿上面的例子來講:假如不是a=1的操做,而是a=new byte[1024*1024](分配1M空間),那麼它會運行地很慢,此時CPU是等待其執行結束呢,仍是先執行下面那句flag=true呢?顯然,先執行flag=true能夠提早使用CPU,加快總體效率,固然這樣的前提是不會產生錯誤(什麼樣的錯誤後面再說)。雖然這裏有兩種狀況:後面的代碼先於前面的代碼開始執行;前面的代碼先開始執行,但當效率較慢的時候,後面的代碼開始執行並先於前面的代碼執行結束。無論誰先開始,總以後面的代碼在一些狀況下存在先結束的可能。安全

無論怎麼重排序,單線程程序的執行結果不能被改變。編譯器、運行時和處理器都必須遵照「as-if-serial」語義。拿個簡單例子來講,多線程

public void execute(){
    int a = 0;
    int b = 1;
    int c = a+b;
}

這裏a=0,b=1兩句能夠隨便排序,不影響程序邏輯結果,但c=a+b這句必須在前兩句的後面執行。併發

2. happen-before

在JMM中,若是一個操做執行的結果須要對另外一個操做可見,那麼這兩個操做之間必須存在happens-before關係。app

happens-before原則很是重要,它是判斷數據是否存在競爭、線程是否安全的主要依據,依靠這個原則,咱們解決在併發環境下兩操做之間是否可能存在衝突的全部問題。
happens-before原則定義以下:框架

  1. 若是一個操做happens-before另外一個操做,那麼第一個操做的執行結果將對第二個操做可見,並且第一個操做的執行順序排在第二個操做以前。
  1. 兩個操做之間存在happens-before關係,並不意味着必定要按照happens-before原則制定的順序來執行。若是重排序以後的執行結果與按照happens-before關係來執行的結果一致,那麼這種重排序並不非法。

重排序在多線程環境下出現的機率仍是挺高的,在關鍵字上有volatile和synchronized能夠禁用重排序,除此以外還有一些規則,也正是這些規則,使得咱們在平時的編程工做中沒有感覺到重排序的壞處。

  1. 程序次序規則(Program Order Rule):在一個線程內,按照代碼順序,書寫在前面的操做先行發生於書寫在後面的操做。準確地說應該是控制流順序而不是代碼順序,由於要考慮分支、循環等結構。
    一段代碼在單線程中執行的結果是有序的。注意是執行結果,由於虛擬機、處理器會對指令進行重排序(重排序後面會詳細介紹)。雖然重排序了,可是並不會影響程序的執行結果,因此程序最終執行的結果與順序執行的結果是一致的。故而這個規則只對單線程有效,在多線程環境下沒法保證正確性。
  2. 監視器鎖定規則(Monitor Lock Rule):一個unlock操做先行發生於後面對同一個對象鎖的lock操做。這裏強調的是同一個鎖,而「後面」指的是時間上的前後順序,如發生在其餘線程中的lock操做。
    這個規則比較好理解,不管是在單線程環境仍是多線程環境,一個鎖處於被鎖定狀態,那麼必須先執行unlock操做後面才能進行lock操做。
  3. volatile變量規則(Volatile Variable Rule):對一個volatile變量的寫操做發生於後面對這個變量的讀操做,這裏的「後面」也指的是時間上的前後順序。
    這是一條比較重要的規則,它標誌着volatile保證了線程可見性。通俗點講就是若是一個線程先去寫一個volatile變量,而後一個線程去讀這個變量,那麼這個寫操做必定是happens-before讀操做的。
  4. 線程啓動規則(Thread Start Rule):Thread獨享的start()方法先行於此線程的每個動做。
    假定線程A在執行過程當中,經過執行ThreadB.start()來啓動線程B,那麼線程A對共享變量的修改在接下來線程B開始執行後確保對線程B可見。
  5. 線程終止規則(Thread Termination Rule):線程中的每一個操做都先行發生於對此線程的終止檢測,咱們能夠經過Thread.join()方法結束、Thread.isAlive()的返回值檢測到線程已經終止執行。
  6. 線程中斷規則(Thread Interruption Rule):對線程interrupte()方法的調用優先於被中斷線程的代碼檢測到中斷事件的發生,能夠經過Thread.interrupted()方法檢測線程是否已中斷。
  7. 對象終結原則(Finalizer Rule):一個對象的初始化完成(構造函數執行結束)先行發生於它的finalize()方法的開始。
    假定線程A在執行的過程當中,經過制定ThreadB.join()等待線程B終止,那麼線程B在終止以前對共享變量的修改在線程A等待返回後可見。
  8. 傳遞性(Transitivity):若是操做A先行發生於操做B,操做B先行發生於操做C,那就能夠得出操做A先行發生於操做C的結論。
    體現了happens-before原則具備傳遞性

若是不符合以上規則,那麼在多線程環境下就不能保證執行順序等同於代碼順序,也就是「若是在本線程中觀察,全部的操做都是有序的;若是在一個線程中觀察另一個線程,則不符合以上規則的都是無序的」,所以,若是咱們的多線程程序依賴於代碼書寫順序,那麼就要考慮是否符合以上規則,若是不符合就要經過一些機制使其符合,最經常使用的就是synchronized、Lock以及volatile修飾符。

上面八條是原生Java知足Happens-before關係的規則,可是咱們能夠對他們進行推導出其餘知足happens-before的規則:

  1. 將一個元素放入一個線程安全的隊列的操做Happens-Before從隊列中取出這個元素的操做
  2. 將一個元素放入一個線程安全容器的操做Happens-Before從容器中取出這個元素的操做
  3. 在CountDownLatch上的倒數操做Happens-Before CountDownLatch#await()操做
  4. 釋放Semaphore許可的操做Happens-Before得到許可操做
  5. Future表示的任務的全部操做Happens-Before Future#get()操做
  6. 向Executor提交一個Runnable或Callable的操做Happens-Before任務開始執行操做

happen-before原則是JMM中很是重要的原則,它是判斷數據是否存在競爭、線程是否安全的主要依據,保證了多線程環境下的可見性。

tu

3. Volatile

volatile至關於synchronized的弱實現,相似於synchronized的語義,可是沒有鎖機制。在JDK及開源框架隨處可見,可是在JDK6以後synchronized關鍵字性能被大幅優化以後,幾乎沒有使用了場景。

3.1. 語義

第一條語義:JMM不會對volatile指令的操做進行重排序。這個保證了對volatile變量的操做時按照指令的出現順序執行的。

第二條語義是保證線程間變量的可見性,簡單地說就是當線程A對變量X進行了修改後,在線程A後面執行的其餘線程能看到變量X的變更,更詳細地說是要符合如下兩個規則:

  1. 線程對變量進行修改以後,要馬上回寫到主內存。
  2. 線程對變量讀取的時候,要從主內存中讀,而不是緩存。

雖然volatile字段保證了可見性,可是因爲缺乏同步機制,因此volatile的字段的操做不是原子性的,並不能保證線程安全。

3.2. 應用原則

  1. volatile是在synchronized性能低下的時候提出的。現在synchronized的效率已經大幅提高,因此volatile存在的意義不大。
  2. 現在非volatile的共享變量,在訪問不是超級頻繁的狀況下,已經和volatile修飾的變量有一樣的效果了。
  3. volatile不能保證原子性,這點是你們沒太搞清楚的,因此很容易出錯。
  4. volatile能夠禁止重排序

一般應用場景以下:

volatile boolean done = flase;
//...

while(!done){
   // ...
}

4. CAS

4.1. 鎖機制存在的問題

  1. 在多線程競爭下,加鎖、釋放鎖會致使比較多的上下文切換和調度延時,引發性能問題。
  2. 一個線程持有鎖會致使其它全部須要此鎖的線程掛起。
  3. 若是一個優先級高的線程等待一個優先級低的線程釋放鎖會致使優先級倒置,引發性能風險。

獨佔鎖是一種悲觀鎖,synchronized就是一種獨佔鎖,會致使其它全部須要鎖的線程掛起,等待持有鎖的線程釋放鎖。而另外一個更加有效的鎖就是樂觀鎖。所謂樂觀鎖就是,每次不加鎖而是假設沒有衝突而去完成某項操做,若是由於衝突失敗就重試,直到成功爲止。

4.2. 原理

CAS 操做包含三個操做數——內存位置(V)、預期原值(A)和新值(B)。若是內存位置的值與預期原值相匹配,那麼處理器會自動將該位置值更新爲新值。不然,處理器不作任何操做。不管哪一種狀況,它都會在 CAS 指令以前返回該位置的值。(在 CAS 的一些特殊狀況下將僅返回 CAS 是否成功,而不提取當前值。)CAS 有效地說明了「我認爲位置 V 應該包含值 A;若是包含該值,則將 B 放到這個位置;不然,不要更改該位置,只告訴我這個位置如今的值便可。」

一般將 CAS 用於同步的方式是從地址 V 讀取值 A,執行多步計算來得到新值 B,而後使用 CAS 將 V 的值從 A 改成 B。若是 V 處的值還沒有同時更改,則 CAS 操做成功。

CAS其底層是經過CPU的1條指令來完成3個步驟,所以其自己是一個原子性操做,不存在其執行某一個步驟的時候而被中斷的可能。

從性能角度考慮:
若是使用鎖來進行併發控制,當某一個線程(T1)搶佔到鎖以後,那麼其餘線程再嘗試去搶佔鎖時就會被掛起,當T1釋放鎖以後,下一個線程(T2)再搶佔到鎖後而且從新恢復到原來的狀態大約須要通過8W個時鐘週期。

假設咱們業務代碼自己並不具有很複雜的操做,執行整個操做可能就花費3-10個時鐘週期左右,那麼當咱們使用無鎖操做時,線程T1和線程T2對共享變量進行併發的CAS操做,假設T1成功了,T2最多再執行一次,它執行屢次的所消耗的時間遠遠小於因爲線程所掛起到恢復所消耗的時間,所以無鎖的CAS操做在性能上要比同步鎖高不少。

示例代碼:

public class SimulatedCAS {
     private int value;
 
     public synchronized int getValue() { return value; }
 
    public synchronized int compareAndSwap(int expectedValue, int newValue) {
         int oldValue = value;
         if (value == expectedValue)
             value = newValue;
         return oldValue;
     }
}

非阻塞算法:一個線程的失敗或者掛起不該該影響其餘線程的失敗或掛起的算法。

基於CAS的併發算法稱爲非阻塞算法,CAS 操做成功仍是失敗,在任何一種狀況中,它都在可預知的時間內完成。若是 CAS 失敗,調用者能夠重試 CAS 操做或採起其餘適合的操做。下面顯示了從新編寫的計數器類來使用 CAS 替代鎖定:

public class CasCounter {
    private SimulatedCAS value;
    public int getValue() {
        return value.getValue();
    }
    public int increment() {
        int oldValue = value.getValue();
        while (value.compareAndSwap(oldValue, oldValue + 1) != oldValue)
            oldValue = value.getValue();
        return oldValue + 1;
    }
}

不管是直接的仍是間接的,幾乎 java.util.concurrent 包中的全部類都使用原子變量,而不使用同步。相似 ConcurrentLinkedQueue 的類也使用原子變量直接實現無等待算法,而相似 ConcurrentHashMap 的類使用 ReentrantLock 在須要時進行鎖定。而後, ReentrantLock 使用原子變量來維護等待鎖定的線程隊列。

4.3. 缺陷

CAS策略有以下須要注意的事項:

  1. 在線程搶佔資源特別頻繁的時候(相對於CPU執行效率而言),會形成長時間的自旋,耗費CPU性能。
  2. 有ABA問題(即在更新前的值是A,但在操做過程當中被其餘線程更新爲B,又更新爲A),這時當前線程認爲是能夠執行的,實際上是發生了不一致現象,若是這種不一致對程序有影響(真正有這種影響的場景不多,除非是在變量操做過程當中以此變量爲標識位作一些其餘的事,好比初始化配置),則須要使用AtomicStampedReference(除了對更新前的原值進行比較,也須要用更新前的stamp標誌位來進行比較)。
  3. 只能對一個變量進行原子性操做。若是須要把多個變量做爲一個總體來作原子性操做,則應該使用AtomicReference來把這些變量放在一個對象裏,針對這個對象作原子性操做。

5. 引用

  1. java的多線程機制引用
  2. 【死磕Java併發】-----Java內存模型之happens-before
  3. 深刻淺出 Java Concurrency (4): 原子操做 part 3 指令重排序與happens-before法則
  4. 流行的原子
相關文章
相關標籤/搜索