併發程式設計之JMM&Volatile(一)

北洛發表於2021-01-05

併發

很多程式設計師應該對併發一詞並不陌生,併發如同一把雙刃劍,如果使用得當,可以幫助我們更好的壓榨硬體的效能,反之,也會產生一些難以排查的問題。這裡,先簡單介紹下併發的幾個基本概念。

程式與執行緒

程式:程式是作業系統進行資源分配和排程的基本單位。

執行緒:執行緒是作業系統能夠進行運算排程的最小單位,它被包含在程式之中,是程式中的實際運作單位。

上面是百度百科對程式和執行緒的解釋,可能有點抽象,這裡筆者再根據自己的理解解釋下程式和執行緒的概念和區別:當我們開啟QQ、微信、網易雲音樂,這時我們啟動了三個程式,作業系統會分別對這三個程式分配資源,作業系統會分配什麼資源給這三個程式呢?首先是記憶體資源,這三個程式都有各自的記憶體進行資料的存取,QQ和微信分別有各自的記憶體資源來儲存我們的使用者資料、聊天資料。其次,當我們需要用QQ或者微信聊天時,作業系統只會把鍵盤資源分配給QQ或者微信其中一個程式,當我們輸入文字,只會出現在QQ或者微信其中一個的聊天窗。下面我們再來說說執行緒,我們用網易雲音樂,可以同時下載音樂和播放音樂,兩者互不影響,這是因為在網易雲音樂這個程式裡,同時有兩個執行緒,一個執行緒播放音樂,一個執行緒下載音樂,利用多執行緒,可以使一個程式在一段時間內同時執行兩個任務。

併發與並行

併發:在單核單CPU架構中,只會出現併發,不會出現並行。比如在一個電商系統中,使用者A正在下單,使用者B正在改名,因此分別有執行緒A和執行緒B兩個執行緒在CPU上交替執行,互相競爭CPU資源。假設下單操作需要執行100個指令,改名操作需要執行60個指令,單核單CPU的架構可能先線上程A中執行80個指令,然後將CPU時間片讓給執行緒B,執行緒B在執行50個指令後,CPU重新把時間片讓給執行緒A執行剩餘的20個指令,再執行執行緒B剩餘的10個指令,最後執行緒A和執行緒B都執行完畢。

並行:只要是多核CPU,不管是單CPU還是多CPU,都有可能出現並行。還是以上面的電商系統為例,使用者A和使用者B的執行緒可以同時跑在同CPU或者不同CPU的不同的核心上,這時候就能做到執行緒A和執行緒B同時執行,互不競爭CPU核心資源。

區別:從上面的例子,我們可以知道併發和並行的區別,併發是指在一段時間內,多個任務交替執行,並行是同一時間內,多個任務可以同時執行。

併發程式設計的本質

至此,我們已經瞭解了併發的幾個基本概念。而併發的本質是要解決:可見性、原子性、有序性這三個問題。

可見性

當多個執行緒同時訪問同一個變數,一個執行緒修改了這個變數的值,其他執行緒要能立刻看到修改的結果。

我們來看下面這段程式碼,首先我們宣告瞭一個靜態變數flag,預設為true,執行緒A只要檢查到flag為true時,就迴圈下去,主執行緒啟動執行緒A後休眠2000毫秒,再啟動執行緒B修改flag的值為false。按理來說,在flag被執行緒B修改為false之後,執行緒A應該退出迴圈。然而,如果我們執行下面的程式碼,會發現程式並不會終止。程式之所以不會終止的原因,是因為執行緒A無法跳出迴圈,即便我們用執行緒B把flag改為false,但執行緒B修改的行為,對執行緒A是無感知的,即執行緒A並不知道此時flag已經被其他執行緒修改為false,執行緒A仍舊以為flag為true,所以無法跳出迴圈。

public class VisibilityTest {
    private static boolean flag = true;//靜態變數


    public static void main(String[] args) {
        new Thread(() -> {
            int i = 0;
            while (flag) {//如果靜態變數為flag則迴圈下去
                i++;
            }
            System.out.println("i=" + i);
        }, "Thread-A").start();
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        new Thread(() -> flag = false, "Thread-B").start();

    }

}

    

之所以執行緒A無法感知執行緒B修改flag變數的值,是因為線上程A啟動的時候,會拷貝一份flag的副本,我們將副本命名為flag’,當執行緒A需要flag的值時,會去訪問flag’,並不會去訪問flag最新的值。那麼,執行緒A又為什麼要拷貝一份flag的值呢?為什麼不直接去訪問flag呢?這裡就要談到CPU快取架構和JMM模型(Java執行緒記憶體模型)。

下圖是一個雙核雙CPU的架構,Core是CPU核心,L1、L2、L3是CPU的快取記憶體,當CPU需要對數值進行運算時,會先把記憶體的資料載入到快取記憶體再進行運算。假設執行緒A跑在Core1,執行緒B跑在Core2,不管是讀取flag還是修改flag,執行緒A和B都需要從主存將flag載入到快取記憶體(L1、L2、L3)。因此,快取記憶體有兩份flag的拷貝:flag(A)和flag(B),分別用於執行緒A和執行緒B,要注意一點的是,即便flag(A)和flag(B)都是主存flag的拷貝,但執行緒A對flag(A)讀取或者修改對執行緒B是不可見的,同理執行緒B對flag(B)的讀取修改對執行緒A也是不可見的。在我們上面的程式碼中,執行緒B在修改快取的flag(B)之後,會把flag(B)最新的值同步回主存的flag,但執行緒A並不知道主存的flag已更新,它仍舊用快取中flag(A)的值,所以無法跳出迴圈。

CPU快取結構

而Java的執行緒記憶體模型則參考了CPU的結構,在Java中,每個執行緒都有自己單獨的本地記憶體用來儲存資料,主存的共享變數也會被拷貝到本地記憶體成為副本,執行緒如果要使用共享變數,不會從主存讀取或者修改,而是讀取修改本地記憶體的副本。這也是程式碼VisibilityTest中,執行緒B在修改flag變數後,執行緒A無法跳出迴圈的原因。

 

Java執行緒記憶體模型

那麼,如果我們業務中存在多執行緒訪問修改同一變數,而且要求其他執行緒能看到變數最新修改的值該怎麼辦呢?Java提供了volatile關鍵字,來保證變數的可見性:

private static volatile boolean flag = true;

  

如果我們給flag加上volatile,執行緒B在修改flag的值之後,執行緒A就能及時獲取到flag最新的值,就會跳出迴圈。那麼,除了volatile關鍵字,還有其他的辦法來保證可見性嗎?有三種方式:synchronized、休眠和快取失效。

public class VisibilityTest2 {
    private static boolean flag = true;//靜態變數


    public static void main(String[] args) {
        new Thread(() -> {
            int i = 0;
            while (flag) {//如果靜態變數為flag則迴圈下去
                i++;
                //System.out.println("i=" + i);//<1>呼叫println()方法時會進入synchronized同步程式碼塊,synchronized可以保證共享變數的可見性
//                try {
//                    Thread.sleep(100);//<2>休眠也可以保證貢獻變數的可見性
//                } catch (InterruptedException e) {
//                    e.printStackTrace();
//                }
                //shortWait(100000);//<3>模擬休眠100000納秒,快取失效
            }
            System.out.println("i=" + i);
        }, "Thread-A").start();
        try {
            Thread.sleep(2000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        new Thread(() -> flag = false, "Thread-B").start();

    }

    public static void shortWait(long interval) {
        long start = System.nanoTime();
        long end;
        do {
            end = System.nanoTime();
        } while (start + interval >= end);
    }
}

  

VisibilityTest2中<1>、<2>、<3>處的程式碼都會可以讓執行緒A跳出迴圈,但三者的原理是不一樣的:

  • <1>呼叫標準輸出流的println()方法,這個方法裡有synchronized關鍵字,這個關鍵字可以保證本地記憶體對共享變數的可見性。
  • <2>Thread.sleep()和Thread.yield()會讓出CPU時間片,當休眠結束或者重新得到CPU時間片時,執行緒會去載入主存最新的共享變數。
  • <3>我們呼叫shortWait(long interval)等待100000納秒,由於本地記憶體的副本太久沒有使用,執行緒判斷副本過期,重新去主存載入,這裡需要注意一點是,如果我們把等待時間設為10或者100納秒,那麼結束等待時執行緒又會去使用flag副本,由於等待時間不是很長,不會將副本設定為已過期,也就不會跳出迴圈。

至此,我們瞭解了執行緒可見性,以及保證可見性的方法。當然,在上面幾種保證可見性的方法中,最優雅的還是使用volatile關鍵字,其他保證可見性的方式都不是那麼優雅,或者說是不可控的。

原子性

即一個操作或者多個操作,要麼全部執行並且執行的過程不被任何因素打斷,要麼就都不執行。原子性就像資料庫裡面的事務一樣,要嘛全部執行成功,如果在執行過程中出現失敗,則整體操作回滾。

我們來看下面的例子,在AtomicityTest中宣告兩個int型別的靜態變數a和b,然後我們啟動10個執行緒,每個執行緒對a和b迴圈1000次加1的操作,如果我們多次執行下面這段程式碼,會發現大部分情況下a和b最後的值都不是10000,甚至a和b的值也不相等,那麼是為什麼呢?

public class AtomicityTest {
    private static volatile int a, b;

    public static void main(String[] args) {
        Thread[] threads = new Thread[10];
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    a++;
                    b++;
                }
            });
        }
        for (Thread thread : threads) {
            thread.start();
        }
        for (Thread thread : threads) {
            try {
                thread.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }
        System.out.println("a=" + a + " b=" + b);
    }
}

    

執行結果:

a=9835 b=9999

    

我們來思考下,為什麼a和b都不等於10000呢?靜態變數a和b我們都用關鍵字volatile標記,所以一定能保證如果a和b的值被一個執行緒修改,其他執行緒能馬上感知到。之所以出現a和b的結果都不是10000,是因為a++這個操作,並不是原子性,在一個執行緒執行a++這個操作時,可能被其他執行緒干擾。

我們可以來拆解下a++這個操作分哪幾個步驟:

1.讀取a的值
2.對a加1
3.將+1的結果賦值給a

  

我們假設執行緒1在執行a++操作的時,讀取到a的數值為100,執行緒1執行完a++的第二個步驟,得出+1的結果是101,還未執行第三個步驟進行復制,此時執行緒2搶佔了CPU時間片,執行緒1休眠,執行緒2讀取到a的數值也是100,並且執行緒2完整的執行兩次a++的所有步驟,此時a的數值為102,之後執行緒2休眠,執行緒1搶佔到CPU時間片,便將之前+1的結果101賦值給a。這就是筆者所說,a++這個操作並非原子性,且被其他執行緒干擾,同理我們也就知道為何b的結果不是10000,而且a和b的結果還不相等。

要解決原子性問題也有很多種方式,針對AtomicityTest的程式碼,最簡單的方式就是用synchronized加上一把同步鎖:

    public static void main(String[] args) {
        Thread[] threads = new Thread[10];
        Object lock = new Object();
        for (int i = 0; i < threads.length; i++) {
            threads[i] = new Thread(() -> {
                synchronized (lock) {
                    for (int j = 0; j < 1000; j++) {
                        a++;
                        b++;
                    }
                }
            });
        }
        ……
    }

  

執行上面的程式碼,a和b的結果都是10000。利用synchronized (lock)可以保證同一個時刻,最多隻有一個執行緒訪問同步程式碼塊,其他執行緒如果要訪問時只能陷入阻塞。這樣也就能保證a++和b++的原子性。

Java每個物件的底層維護著一個鎖記錄,當一個物件時某個同步程式碼塊的鎖時,如果有執行緒進入同步程式碼塊,物件的鎖記錄+1,執行緒離開同步程式碼塊,則鎖記錄-1。如果鎖記錄>1,則代表當前執行緒重入鎖,比如下面的程式碼,即方法A和方法B都有lock物件的同步程式碼塊,當執行緒進入methodA的lock同步程式碼塊,鎖記錄+1,呼叫methodB時執行到lock的同步程式碼塊時,鎖記錄再次+1為2,當執行完methodB的同步程式碼塊,lock的鎖記錄-1為1,最後執行完methodA的lock同步程式碼塊,鎖記錄-1變為0,其他執行緒則可以競爭lock的鎖許可權,執行methodA或者methodB的同步程式碼塊。

    public void methodA() {
        synchronized (lock) {
            //...
            methodB();
        }
    }

    public void methodB() {
        synchronized (lock) {
            //...
        }
    }

  

我們來看看下面四個操作哪幾個是原子性哪幾個不是:

i = 0;       //1
j = i ;      //2
i++;         //3
i = j + 1;   //4

  

  1. i=0:是原子性,在Java中對基本資料型別變數的賦值操作是原子性操作。
  2. j=i:不是原子性,首先要讀取i的值,再將i的值賦值給變數j。
  3. i++:不是原子性,操作步驟見上。
  4. i=j+1:不是原子性,原因同i++一樣。

有序性

為了提高執行程式的效能,編譯器和處理器可能會對我們編寫的程式做一些優化,執行程式的順序不一定是按照我們程式碼編寫的順序,即指令重排序。編譯器和處理器只要保證程式在單執行緒情況下,指令重排序的執行結果和按照我們程式碼順序所執行出來的結果一樣即可。

我們看下面的兩行程式碼,思考一下如果對調這兩行程式碼會不會有什麼問題?這兩行程式碼那一行需要執行的指令更少?

int j = a;//<1>
int i = 1;//<2>

  

首先我們來解決第一個問題,<1>和<2>這兩行程式碼即便我們程式對調也不會有問題,畢竟程式碼<1>用到的變數和程式碼<2>沒有交集,所以這兩行程式碼是可以互換位置的。其次,我們來考慮<1>和<2>哪一行執行的指令更少,通過之前的學習,我們知道<2>是一個原子操作,而<1>需要讀值再賦值,不是原子操作,執行程式碼<2>所需指令比<1>更少,所以編譯器就可以做一個優化,把程式碼<2>和程式碼<1>的位置互換,優先執行指令少且變動順序不會影響結果的程式碼,再執行指令多的程式碼。

下面的程式碼[1]和程式碼[2]是兩個獨立的程式碼塊,但這兩個獨立的程式碼塊最終結果又都是一樣,即:i=2,j=3,那麼哪一個程式碼塊執行效率更高?

//[1]
int i = 1;//<1>
int j = 3;//<2>
int i = i+1;//<3>

//[2]
int i = 1;//<4>
int i = i+1;//<5>
int j = 3;//<6>

  

為了思考程式碼塊[1]和程式碼塊[2]哪一個執行效率更高,我們模擬下CPU的執行邏輯。首先是程式碼塊[1]:CPU在執行完<1>和<2>兩個賦值操作後,即將執行i=i+1,這時候i的值可能已經不在CPU的快取記憶體裡,CPU需要去主存載入i的值進行運算和賦值。再來是程式碼塊[2]:CPU執行完<4>的賦值操作,此時i還在快取記憶體,CPU直接從快取記憶體讀取i的值加1再賦值給i,最後再執行程式碼<6>的賦值操作。

到這裡,我想大家應該都明白哪個程式碼塊效率更高,顯而易見,程式碼塊[2]的效率會更高,因為它不用面臨變數i從快取記憶體中淘汰,後續對i進行+1操作時又需要去主存載入變數i。而程式碼塊[1]在執行完i的賦值操作後,又執行了其他指令,這時候可能出現快取記憶體無法容納變數i而將i淘汰,後續需要對i進行操作需要去主存載入i。

根據上面我們所瞭解的,指令重排序確實會提高程式的效能,但指令重排序只保證單執行緒情況下,重排序的執行結果和未排序的執行結果是一樣的,如果是多執行緒的情況下,指令重排序會給我們帶來意想不到的結果。

在下面的程式碼中,我們宣告4個int型別的靜態變數:a,b,x,y,主方法有一個迴圈,每次迴圈都會將這四個靜態變數賦值為0,之後開啟兩個執行緒,線上程1中獎a賦值為1,b的值賦值給x,執行緒2中將b賦值為1,a的值賦值給y。等到兩個執行緒執行完畢後,如果x和y都為0,則跳出迴圈。

public class ReOrderTest {
    private static int x = 0, y = 0;

    private static int a = 0, b = 0;

    public static void main(String[] args) {
        int i = 0;
        while (true) {
            i++;
            x = 0;
            y = 0;
            a = 0;
            b = 0;
            Thread thread1 = new Thread(() -> {
                a = 1;//<1>
                x = b;//<2>
            });
            Thread thread2 = new Thread(() -> {
                b = 1;//<3>
                y = a;//<4>
            });
            thread1.start();
            thread2.start();
            try {
                thread1.join();
                thread2.join();
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            System.out.println("第" + i + "次:x=" + x + " y=" + y + "");
            if (x == 0 && y == 0) {
                break;
            }
        }
    }
}

  

執行結果:

第1次:x=0 y=1
……
第86586次:x=0 y=1
第86587次:x=0 y=0

    

執行上面的程式,我們會發現程式終究會跳出迴圈,按理來說,我們線上程1給a賦值,線上程2將a的值賦予給y,執行緒2又對b賦值,線上程1將b的值賦值給x,兩個執行緒執行結束後,x和y本來應該都不為0,那為什麼會出現x和y同時為0跳出迴圈的情況?可能有人想到執行緒的可見性,誠然有可能出現:執行緒1和執行緒2同時將這四個靜態變數的值拷貝到本地記憶體,即便執行緒1對a賦值,執行緒2對b賦值,但執行緒1看不到執行緒2對b的修改,將b在本地記憶體的拷貝賦值給x,同理執行緒2將a在本地記憶體的拷貝賦值給y,因此x和y同時為0,跳出迴圈。但這裡還要考慮到一個重排序的情況,執行緒1的<1>、<2>程式碼是可以互換位置的,同理還有執行緒2的<3>、<4>。考慮下執行緒1執行重排序後,執行順序是<2>、<1>,而執行緒2執行順序是<4>、<3>,即程式碼順序變為:

//執行緒1
x = b;//<2>
a = 1;//<1>

//執行緒2
y = a;//<4>
b = 1;//<3>

  

執行緒1執行<2>之後,執行緒2又執行了<4>,之後兩個執行緒即便對a和b賦值,但對x和y來說為時已晚,x和y已經具備跳出迴圈的條件了。那麼,有沒有辦法解決這個問題呢?這裡又要請出我們的關鍵字volatile了,volatile除了保證可見性,還能保證有序性。只要將ReOrderTest 的四個靜態變數標記上volatile,就可以禁止指令重排序。

    private static volatile int x = 0, y = 0;

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

  

volatile之所以可以防止指令重排序,是因為它會在使用倒volatile變數的地方生成一道“柵欄”,“柵欄”的前後指令都不能更換順序,比如上述四個靜態變數標記上volatile關鍵字後,執行緒1執行程式碼的順序如下:

a = 1;
//---柵欄---
x = b;
//---柵欄---

  

變數a的後面會生成一道“柵欄”,編譯器和處理器會檢測到這道“柵欄”,即便我們的指令在單執行緒下有優化空間,volatile也能保證處理器執行指令的順序是按照我們程式碼所編寫的順序。

另外,筆者之前有提過,執行a=1的執行比x=b的指令更少,處理器應該要優先執行a=1再執行x=b,但實際上Java虛擬機器在執行指令的時候情況是不一定的,也有可能優先執行x=b再執行a=1,也就是說JVM虛擬機器執行指令的順序,可能會按照我們編寫程式碼的順序,也可能會將我們的程式碼調整順序後再執行,即便是同一段程式碼迴圈執行兩次,前後兩次的指令順序,有可能是按我們程式碼所編寫的順序,也有可能不是。

下面的程式碼是用於獲取單例物件的程式碼,通過SingleFactory.getInstance()方法我們可以獲取到singleFactory物件,在這個方法中,如果singleFactory不為空,則直接返回,如果為空,則進入if分支,在if分支中還有個同步程式碼塊,同步程式碼塊裡會再判斷一次singleFactory是否為null,避免多執行緒呼叫SingleFactory.getInstance(),由於可見性原因,生成多個SingleFactory物件,所以synchronized已經保證了我們的可見性,第一個進入synchronized程式碼塊中的執行緒,singleFactory一定為null,所以會去初始化物件,而其他同樣需要singleFactory物件的執行緒,會先阻塞在同步程式碼塊之外,等到第一個執行緒初始化好singleFactory後離開同步程式碼塊,其他執行緒進入時singleFactory已經不為null了。但我們注意到一點,為什麼synchronized已經保證了可見性,singleFactory這個靜態變數還要用volatile關鍵字來標記呢?

public class SingleFactory {
    private static volatile SingleFactory singleFactory;

    private SingleFactory() {
    }

    public static SingleFactory getInstance() {
        if (singleFactory == null) {
            synchronized (SingleFactory.class) {
                if (singleFactory == null) {
                    singleFactory = new SingleFactory();
                }
            }
        }
        return singleFactory;
    }
}

    

誠然,volatile和synchronized都能保證可見性,但這裡的volatile不是用來保證可見性的,而是禁止指令重排序的。我們來思考一個問題:JVM會如何執行singleFactory = new SingleFactory()這段程式碼?正常應該會先在堆上分配一塊記憶體,在記憶體上建立一個SingleFactory物件,最後把singleFactory這個引用指向堆上的SingleFactory物件是不是?但如果一個物件的構建及其複雜,JVM可能會把建立物件的指令優化成先開闢一塊記憶體,將singleFactory的引用指向這塊記憶體,然後再建立這個物件。如果執行的順序是先開闢記憶體,再指向記憶體,最後在記憶體上建立物件,那麼其他執行緒在呼叫SingleFactory.getInstance()時,即便物件還沒建立好,但singleFactory引用已經不為null了,這個時候如果將singleFactory引用返回並呼叫其堆上的方法是非常危險的,所以這裡需要用volatile禁止指令重排序,並不是為了volatile的可見性,而是讓volatile禁止指令重排序,按部就班的分配記憶體,建立物件,再將引用指向物件。

相關文章