­

java多線程3:原子性,可見性,有序性

概念

  在了解線程安全問題之前,必須先知道為什麼需要並發,並發給我們帶來什麼問題。

       為什麼需要並發,多線程?

  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()方法檢測線程是否中斷。