java多執行緒3:原子性,可見性,有序性

16bit發表於2020-04-23

概念

  在瞭解執行緒安全問題之前,必須先知道為什麼需要併發,併發給我們帶來什麼問題。

       為什麼需要併發,多執行緒?

  1. 時代的召喚,為了更充分的利用多核CPU的計算能力,多個執行緒程式可通過提高處理器的資源利用率來提升程式效能。
  2. 方便業務拆分,非同步處理業務,提高應用效能。

   多執行緒併發產生的問題?

  1. 大量的執行緒讓CPU頻繁上下文切換帶來的系統開銷。
  2. 臨界資源執行緒安全問題(共享,可變)。
  3. 容易造成死鎖。

注意:當多個執行緒執行一個方法時,該方法內部的區域性變數並不是臨界資源,因為這些區域性變數是在每個執行緒的私有棧中,因此不具有共享性質,不會導致執行緒安全問題。

可見性

 多執行緒訪問同一個變數時,如果有一個執行緒修改了這個變數的值,其他執行緒能夠立即看得到修改的值。這是因為為了保證多個CPU之間的快取記憶體是一致的,作業系統會有一個快取一致性協議,volatile就是通過OS的快取一致性協議策略來保證了共享變數在多個執行緒之間的可見性。

public class ThreadDemo2 {

    private static boolean flag = false;

    public void thread_1(){
        flag = true;
        System.out.println("執行緒1已對flag做出改變");
    }

    public void thread_2(){
        while (!flag){
        }
        System.out.println("執行緒2->flag已被修改,成功打斷迴圈");
    }

    public static void main(String[] args) {
        ThreadDemo2 threadDemo2 = new ThreadDemo2();
        Thread thread2 = new Thread(()->{
            threadDemo2.thread_2();
        });
        Thread thread1= new Thread(()->{
            threadDemo2.thread_1();
        });
        thread2.start();
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        thread1.start();
    }
}

執行結果

執行緒1已對flag做出改變

程式碼無論執行多少次,執行緒2的輸出語句都不會被列印。為flag新增volatile修飾後執行,執行緒2執行的語句被列印

執行結果

執行緒1已對flag做出改變
執行緒2->flag已被修改,成功打斷迴圈

侷限:volatile只是保證共享變數的可見性,無法保證其原子性。多個執行緒併發時,執行共享變數i的i++操作<==> i = i + 1,這是分兩步執行,並不是一個原子性操作。根據快取一致性協議,多個執行緒讀取i並對i進行改變時,其中一個執行緒搶先獨佔i進行修改,會通知其他CPU我已經對i進行修改,把你們快取記憶體的值設為無效並重新讀取,在併發情況下是可能出現資料丟失的情況的。

public class ThreadDemo3 {
    private volatile static int count = 0;
    public static void main(String[] args) {
        for (int i = 0; i < 10; ++i){
            Thread thread = new Thread(()->{
                for (int j = 0; j < 1000; ++j){
                    count++;
                }
            });
            thread.start();
        }
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("count執行的結果為->" + count);
    }
}

執行結果

count執行的結果為->9561

注意:這個結果是不固定的,有時10000,有時少於10000。

原子性

就像戀人一樣同生共死,表現在多執行緒程式碼中程式一旦開始執行,就不會被其他執行緒干擾要嘛一起成功,要嘛一起失敗,一個操作不可被中斷。在上文的例子中,為什麼執行結果不一定等於10000,就是因為在count++是多個操作,1.讀取count值,2.對count進行加1操作,3.計算的結果再賦值給count。這幾個操作無法構成原子操作的,在一個執行緒讀取完count值時,另一個執行緒也讀取他並給它賦值,根據快取一致性協議通知其他執行緒把本次讀取的值置為無效,所以本次迴圈操作是無效的,我們看到的值不一定等於10000,如何進行更正---->synchronized關鍵字

public class ThreadDemo3 {
    private volatile static int count = 0;
    private static Object object = new Object();
    public static void main(String[] args) {
        for (int i = 0; i < 10; ++i){
            Thread thread = new Thread(()->{
                for (int j = 0; j < 1000; ++j){
                    synchronized (object){
                        count++;
                    }
                }
            });
            thread.start();
        }
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("count執行的結果為->" + count);
    }
}

執行結果

count執行的結果為->10000

加鎖後,執行緒在爭奪執行權就必須獲取到鎖,當前執行緒就不會被其他執行緒所干擾,保證了count++的原子性,至於synchronized為什麼能保證原子性,篇幅有限,下一篇在介紹。

有序性

jmm記憶體模型允許編譯器和CPU在單執行緒執行結果不變的情況下,會對程式碼進行指令重排(遵守規則的前提下)。但在多執行緒的情況下卻會影響到併發執行的正確性。

public class ThreadDemo4 {
    private static int x = 0,y = 0;
    private static int a = 0,b = 0;
    private static int i = 0;
    public static void main(String[] args) throws InterruptedException {
        for (;;){
            i++;
            x = 0;y = 0;
            a = 0;b = 0;
            Thread thread1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    waitTime(10000);
                    a = 1;
                    x = b;
                }
            });
            Thread thread2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    b = 1;
                    y = a;
                }
            });
            thread1.start();
            thread2.start();
            thread1.join();
            thread2.join();
            System.out.println("第" + i + "次執行結果(" + x + "," + y + ")");
            if (x == 0 && y == 0){
                System.out.println("在第" + i + "次發生指令重排,(" + x + "," + y + ")");
                break;
            }
        }
    }
    public static void waitTime(int time){
        long start = System.nanoTime();
        long end;
        do {
            end = System.nanoTime();
        }while (start + time >= end);
    }

}

執行結果

第1次執行結果(0,1)
第2次執行結果(1,0)
....
第35012次執行結果(0,1)
第35013次執行結果(0,0)
在第35013次發生指令重排,(0,0)

如何解決上訴問題哪?volatile的另一個作用就是禁止指令重排優化,它的底層是記憶體屏障,其實就是一個CPU指令,一個標識,告訴CPU和編譯器,禁止在這個標識前後的指令執行重排序優化。記憶體屏障的作用有兩個,一個就是上文所講的保證變數的記憶體可見性,第二個保證特定操作的執行順序。

補充

 指令重排序:Java語言規範規定JVM執行緒內部維持順序化語義,程式的最終結果與它順序化情況的結果相等,那麼指令的執行順序可以和程式碼順序不一致。JVM根據處理器特性,適當的堆機器指令進行重排序,使機器指令更符號CPU的執行特性,最大限度發揮機器效能。

as-if-serial語義:不管怎麼重排序,單執行緒程式的執行結果不能被改變,編譯器和處理器都必須遵守這個原則。

happens-before原則:輔助保證程式執行的原子性,可見性和有序性的問題,判斷資料是否存在競爭,執行緒是否安全的依據(JDK5)

1. 程式順序原則,即在一個執行緒內必須保證語義序列性,也就是說按照程式碼順序執行。

2. 鎖規則 解鎖(unlock)操作必然發生在後續的同一個鎖的加鎖(lock)之前,也就是說, 如果對於一個鎖解鎖後,再加鎖,那麼加鎖的動作必須在解鎖動作之後(同一個鎖)。

3. volatile規則 volatile變數的寫,先發生於讀,這保證了volatile變數的可見性,簡單 的理解就是,volatile變數在每次被執行緒訪問時,都強迫從主記憶體中讀該變數的值,而當 該變數發生變化時,又會強迫將最新的值重新整理到主記憶體,任何時刻,不同的執行緒總是能 夠看到該變數的最新值。

4. 執行緒啟動規則 執行緒的start()方法先於它的每一個動作,即如果執行緒A在執行執行緒B的 start方法之前修改了共享變數的值,那麼當執行緒B執行start方法時,執行緒A對共享變數 的修改對執行緒B可見

5. 傳遞性 A先於B ,B先於C 那麼A必然先於C

6. 執行緒終止規則 執行緒的所有操作先於執行緒的終結,Thread.join()方法的作用是等待當前 執行的執行緒終止。假設線上程B終止之前,修改了共享變數,執行緒A從執行緒B的join方法 成功返回後,執行緒B對共享變數的修改將對執行緒A可見。

7. 執行緒中斷規則 對執行緒 interrupt()方法的呼叫先行發生於被中斷執行緒的程式碼檢測到中 斷事件的發生,可以通過Thread.interrupted()方法檢測執行緒是否中斷。

 

相關文章