天天看點

java架構之路(多線程)JMM和volatile關鍵字(二)

複習:

  先來簡單的複習一遍以前寫過的東西,上次我們說了記憶體一緻性協定M(修改)E(獨占)S(共享)I(失效)四種狀态,還有我們并發程式設計的三大特性原子性、一緻性和可見性。再就是簡單的提到了我們的volatile關鍵字,他可以保證我們的可見性,也就是說被volatile關鍵字修飾的變量如果産生了變化,可以馬上刷到主存當中去。我們接下來看一下我們這次部落格的内容吧。

線程:

  何為線程呢?這也是我們面試當中經常問到的。按照官方的說法是:現代作業系統在運作一個程式時,會為其建立一個程序。例如,啟動一個Java程式,操作 系統就會建立一個Java程序。現代作業系統排程CPU的最小單元是線程。比如我們啟動QQ,就是我們啟動了一個程序,我們發起了QQ語音,這個動作就是一個線程。

  在這裡多提一句的就是線程分為核心級線程和使用者級線程,我們在java虛拟機内的線程一般都為使用者級線程,也就是由我們的jvm虛拟機來調用我們的CPU來申請時間片來完成我們的線程操作的。而我們的核心級線程是由我們的系統來排程CPU來完成的,為了保證安全性,一般的線程都是由虛拟機來控制的。

  使用者線程:指不需要核心支援而在使用者程式中實作的線程,其不依賴于作業系統核心,應用程序利用線程庫提供建立、同步、排程和管理線程的函數來控制使用者線程。另外,使用者線程是由應用程序利用線程庫建立和管理,不依賴于作業系統核心。不需要使用者态/核心态切換,速度快。作業系統核心不知道多線程的存在,是以一個線程阻塞将使得整個程序(包括它的所有線程)阻塞。由于這裡的處理器時間片配置設定是以程序為基本機關,是以每個線程執行的時間相對減少。

  核心線程: 線程的所有管理操作都是由作業系統核心完成的。核心儲存線程的狀态和上下文資訊,當一個線程執行了引起阻塞的系統調用時,核心可以排程該程序的其他線程執行。在多處理器系統上,核心可以分派屬于同一程序的多個線程在多個處理器上運作,提高程序執行的并行度。由于需要核心完成線程的建立、排程和管理,是以和使用者級線程相比這些操作要慢得多,但是仍然比程序的建立和管理操作要快。大多數市場上的作業系統,如Windows,Linux等都支援核心級線程。

  使用者級線程就是我們常說的ULT,核心級線程就是我們說的KLT。線程從使用者态切換到核心态時會消耗很大的性能和時間,後面說sychronized鎖的膨脹更新會說到這個過程。

上下文切換:

  上面我們說過,線程是由我們的虛拟機去CPU來申請時間片來完成我們的操作的,但是不一定馬上執行完成,這時就産生了上下文切換。大緻就是這樣的:

java架構之路(多線程)JMM和volatile關鍵字(二)

  線程A沒有運作完成,但是時間片已經結束了,我們需要挂起我們的線程A,CPU該去執行線程B了,運作完線程B,才能繼續運作我們的線程A,這時就涉及到一個上下文的切換,我們把這個暫時挂起到再次運作的過程,可以了解為上下文切換(最簡單的了解方式)。

可見性:

  用volatile關鍵字修飾過的變量,可以保證可見性,也就是volatile變量被修改了,會立即刷到主記憶體内,讓其他線程感覺到變量已經修改,我們來看一個事例

public class VolatileVisibilitySample {
    private volatile boolean initFlag = false;

    public void refresh(){
        this.initFlag = true;
        String threadname = Thread.currentThread().getName();
        System.out.println("線程:"+threadname+":修改共享變量initFlag");
    }

    public void load(){
        String threadname = Thread.currentThread().getName();
        int i = 0;
        while (!initFlag){

        }
        System.out.println("線程:"+threadname+"目前線程嗅探到initFlag的狀态的改變"+i);
    }

    public static void main(String[] args){
        VolatileVisibilitySample sample = new VolatileVisibilitySample();
        Thread threadA = new Thread(()->{
            sample.refresh();
        },"threadA");

        Thread threadB = new Thread(()->{
            sample.load();
        },"threadB");

        threadB.start();
        try {
             Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        threadA.start();
    }

}           

複制

我們想建立一個全局的由volatile修飾的boolean變量,refresh方法是修改我們的全局變量,load方法是無限循環去檢查我們全局volatile修飾過的變量,我們開啟兩個線程,開始運作,我們會看到如下結果。

java架構之路(多線程)JMM和volatile關鍵字(二)

也就是說,我們的變量被修改以後,我們的另外一個線程會感覺到我們的變量已經發生了改變,也就是我們的可行性,立即刷回主記憶體。

有序性:

  說到有序性,不得不提到幾個知識點,指令重排,as-if-serial語義和happens-before 原則。

  指令重排:java語言規範規定JVM線程内部維持順序化語義。即隻要程式的最終結果與它順序化情況的結果相等,那麼指令的執行順序可以與代碼順序不一緻,此過程叫指令的重排序。指令重排序的意義是什麼?JVM能根據處理器特性(CPU多級緩存系統、多核處理器等)适當的對機器指令進行重排序,使機器指令能更符合CPU的執行特性,最大限度的發揮機器性能。

  指令重排一般發生在class翻譯為位元組碼檔案和位元組碼檔案被CPU執行這兩個階段。

  as-if-serial語義的意思是:不管怎麼重排序(編譯器和處理器為了提高并行度),(單線程)程式的執行結果不能被改變。編譯器、runtime和處理器都必須遵守as-if-serial語義。 為了遵守as-if-serial語義,編譯器和處理器不會對存在資料依賴關系的操作做重排序,因 為這種重排序會改變執行結果。但是,如果操作之間不存在資料依賴關系,這些操作就可能被 編譯器和處理器重排序。

  happens-before 原則内容如下

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

  8. 對象終結規則 對象的構造函數執行,結束先于finalize()方法。

  上一段代碼看看指令重排的問題。

public class VolatileReOrderSample {
    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(new Runnable() {
                public void run() {
                    a = 1;
                    x = b;
                }
            });
            Thread t2 = new Thread(new Runnable() {
                public void run() {
                    b = 1;
                    y = a;
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();

            String result = "第" + i + "次 (" + x + "," + y + ")";
            if (x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }

    }
}           

複制

我們來分析一下上面的代碼

情況1:假設我們的線程1開始執行,線程2還沒開始,這時a = 1 ,x = b = 0,因為b的初始值是0,然後開始執行線程2,b = 1,y = a = 1,得到結論x = 0 ,y = 1.

情況2:假設線程1開始執行,将a指派為1,開始執行線程2,b指派為1,并且y = a = 1,這時繼續運作線程1,x = b = 1,得到結論 x = 1,y = 1.

情況3:線程2優先執行,這時b = 1,y = a = 0,然後運作線程1,a = 1,x = b = 1,得到結論 x = 1,y = 0。

不管怎麼誰先誰後,我們都是隻有這三種答案,不會産生x = 0且y = 0的情況,我們在下面寫出來了x = 0 且 y = 0 跳出循環。我們來測試一下。

java架構之路(多線程)JMM和volatile關鍵字(二)

運作到第72874次結果了0,0的情況産生了,也就是說,我們t1中的a = 1;x = b;和t2中的b = 1;y = a;代碼發生了改變,隻有變為

Thread t1 = new Thread(new Runnable() {
    public void run() {
        
        x = b;
        a = 1;
    }
});
Thread t2 = new Thread(new Runnable() {
    public void run() {
        
        y = a;
        b = 1;
    }
});           

複制

這種情況才可以産生0,0的情況,我們可以把代碼改為

private static volatile int a = 0, b = 0;           

複制

繼續來測試,我們發現無論我們運作多久都不會發生我們的指令重排現象,也就是說我們volatile關鍵字可以保證我們的有序性

java架構之路(多線程)JMM和volatile關鍵字(二)

至少我這裡570萬次還沒有發生0,0的情況。

就是我上次部落格給予的表格

Required barriers 2nd operation
1st operation Normal Load Normal Store Volatile Load Volatile Store
Normal Load LoadStore
Normal Store StoreStore
Volatile Load LoadLoad LoadStore LoadLoad LoadStore
Volatile Store StoreLoad StoreStore

我們來分析一下代碼

線程1的。

public void run() {
    a = 1;
    x = b;
}           

複制

  a = 1;是将a這個變量指派為1,因為a被volatile修飾過了,我們成為volatile寫,就是對應表格的Volatile Store,接下來我們來看第二步,x = b,字面意思是将b的值指派給x,但是這步操作不是一個原子操作,其中包含了兩個步驟,先取得變量b,被volatile修飾過,就成為volatile load,然後将b的值賦給x,x沒有被volatile修飾,成為普通寫。也就是說,這兩行代碼做了三個動作,分别是Volatile Store,volatile load和Store寫讀寫,查表格我們看到volatile修飾的變量Volatile Store,volatile load之間是給予了StoreLoad這樣的屏障,是不允許指令重排的,是以達到了有序性的目的。

擴充:

  我們再來看一個方法,不用volatile修飾也可以防止指令重排,因為上面我們說過,volatile可以保證有序性,就是增加記憶體屏障,防止了指令重排,我們可以采用手動加屏障的方式也可以阻止指令重排。我們來看一下事例。

public class VolatileReOrderSample {
    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(new Runnable() {
                public void run() {
                    a = 1;
                    UnsafeInstance.reflectGetUnsafe().storeFence();
                    x = b;
                }
            });
            Thread t2 = new Thread(new Runnable() {
                public void run() {
                    b = 1;
                    UnsafeInstance.reflectGetUnsafe().storeFence();
                    y = a;
                }
            });
            t1.start();
            t2.start();
            t1.join();
            t2.join();

            String result = "第" + i + "次 (" + x + "," + y + ")";
            if(x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                System.out.println(result);
            }
        }

    }

}           

複制

storeFence就是一個有java底層來提供的記憶體屏障,有興趣的可以自己去看一下unsafe類,一共有三個屏障

UnsafeInstance.reflectGetUnsafe().storeFence();//寫屏障
UnsafeInstance.reflectGetUnsafe().loadFence();//讀屏障
UnsafeInstance.reflectGetUnsafe().fullFence();//讀寫屏障           

複制

通過unsafe的反射來調用,涉及安全問題,jvm是不允許直接調用的。手寫單例模式時在超高并發記得加volatile修飾,不然産生指令重排,會造成空對象的行為。後面我會科普這個玩意。