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

  貌似兩個多月沒寫博客,不知道年前這段時間都去忙了什麼。

  好久以前寫過一次和volatile相關的博客,感覺沒寫的那麼深入吧,這次我們繼續說我們的volatile關鍵字。

複習:

  先來簡單的複習一遍以前寫過的東西,上次我們說了內存一致性協議M(修改)E(獨佔)S(共享)I(失效)四種狀態,還有我們併發編程的三大特性原子性、一致性和可見性。再就是簡單的提到了我們的volatile關鍵字,他可以保證我們的可見性,也就是說被volatile關鍵字修飾的變量如果產生了變化,可以馬上刷到主存當中去。我們接下來看一下我們這次博客的內容吧。

線程:

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

  在這裏多提一句的就是線程分爲內核級線程和用戶級線程,我們在java虛擬機內的線程一般都爲用戶級線程,也就是由我們的jvm虛擬機來調用我們的CPU來申請時間片來完成我們的線程操作的。而我們的內核級線程是由我們的系統來調度CPU來完成的,爲了保證安全性,一般的線程都是由虛擬機來控制的。

  用戶線程:指不需要內核支持而在用戶程序中實現的線程,其不依賴於操作系統核心,應用進程利用線程庫提供創建、同步、調度和管理線程的函數來控制用戶線程。另外,用戶線程是由應用進程利用線程庫創建和管理,不依賴於操作系統核心。不需要用戶態/核心態切換,速度快。操作系統內核不知道多線程的存在,因此一個線程阻塞將使得整個進程(包括它的所有線程)阻塞。由於這裏的處理器時間片分配是以進程爲基本單位,所以每個線程執行的時間相對減少。
  內核線程: 線程的所有管理操作都是由操作系統內核完成的。內核保存線程的狀態和上下文信息,當一個線程執行了引起阻塞的系統調用時,內核可以調度該進程的其他線程執行。在多處理器系統上,內核可以分派屬於同一進程的多個線程在多個處理器上運行,提高進程執行的並行度。由於需要內核完成線程的創建、調度和管理,所以和用戶級線程相比這些操作要慢得多,但是仍然比進程的創建和管理操作要快。大多數市場上的操作系統,如Windows,Linux等都支持內核級線程。
  用戶級線程就是我們常說的ULT,內核級線程就是我們說的KLT。線程從用戶態切換到內核態時會消耗很大的性能和時間,後面說sychronized鎖的膨脹升級會說到這個過程。

上下文切換:

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

  線程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修飾過的變量,我們開啓兩個線程,開始運行,我們會看到如下結果。

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

有序性:

  說到有序性,不得不提到幾個知識點,指令重排,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 跳出循環。我們來測試一下。

運行到第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關鍵字可以保證我們的有序性

至少我這裏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修飾,不然產生指令重排,會造成空對象的行爲。後面我會科普這個玩意。

最進弄了一個公衆號,小菜技術,歡迎大家的加入

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章