一文讀懂JAVA多執行緒

背景淵源

摩爾定律

提到多執行緒好多書上都會提到摩爾定律,它是由英特爾創始人之一Gordon Moore提出來的。其內容為:當價格不變時,積體電路上可容納的元器件的數目,約每隔18-24個月便會增加一倍,性能也將提升一倍。換言之,每一美元所能買到的電腦性能,將每隔18-24個月翻一倍以上。這一定律揭示了資訊技術進步的速度。

可是從2003年開始CPU主頻已經不再翻倍,而是採用多核,而不是更快的主頻。摩爾定律失效。那主頻不再提高,核數增加的情況下要想讓程式更快就要用到並行或並發編程。

並行與並發

如果CPU主頻增加程式不用做任何改動就能變快。但核多的話程式不做改動不一定會變快。

CPU廠商生產更多的核的CPU是可以的,一百多核也是沒有問題的,但是軟體還沒有準備好,不能更好的利用,所以沒有生產太多核的CPU。隨著多核時代的來臨,軟體開發越來越關注並行編程的領域。但要寫一個真正並行的程式並不容易。

並行和並發的目標都是最大化CPU的使用率,並發可以認為是一種程式的邏輯結構的設計模式。可以用並發的設計方式去設計模型,然後運行在一個單核的系統上。可以將這種模型不加修改的運行在多核系統上,實現真正的並行,並行是程式執行的一種屬性真正的同時執行,其重點的是充分利用CPU的多個核心。

多執行緒開發的時候會有一些問題,比如安全性問題,一致性問題等,重排序問題,因為這些問題然後大家在寫程式碼的時候會加鎖等等。這些基礎概念大家都懂,本文不再描述。本文主要分享造成這些問題的原因和JAVA解決這些問題的底層邏輯。

多執行緒

電腦存儲體系

要想明白數據一致性問題,要先縷下電腦存儲結構,從本地磁碟到主存到CPU快取,也就是從硬碟到記憶體,到CPU。一般對應的程式的操作就是從資料庫查數據到記憶體然後到CPU進行計算。這個描述有點粗,下邊畫個圖。

業內畫這個圖一般都是畫的金字塔型狀,為了證明是我自己畫的我畫個長方型的(其實我不會畫金字塔)。

CPU多個核心和記憶體之間為了保證內部數據一致性還有一個快取一致性協議(MESI),MESI其實就是指令狀態中的首字母。M(Modified)修改,E(Exclusive)獨享、互斥,S(Shared)共享,I(Invalid)無效。然後再看下邊這個圖。

太細的狀態流轉就不作描述了,扯這麼多主要是為了說明白為什麼會有數據一致性問題,就是因為有這麼多級的快取,CPU的運行並不是直接操作記憶體而是先把記憶體裡邊的數據讀到快取,而記憶體的讀和寫操作的時候就會造成不一致的問題。解決一致性問題怎麼辦呢,兩個思路。

  1. 鎖住匯流排,操作時鎖住匯流排,這樣效率非常低,所以考慮第二個思路。
  2. 快取一致性,每操作一次通知(一致性協議MESI),(但多執行緒的時候還是會有問題,後文講)

JAVA記憶體模型

上邊稍微扯了一下存儲體系是為了在這裡寫一下JAVA記憶體模型。

Java虛擬機規範中試圖定義一種Java記憶體模型(java Memory Model) 來屏蔽掉各種硬體和作業系統的記憶體訪問差異,以實現讓Java程式在各種平台下都能達到一致的記憶體訪問效果。

記憶體模型是記憶體和執行緒之間的交互、規則。與編譯器有關,有並發有關,與處理器有關。

Java記憶體模型的主要目標是定義程式中各個變數的訪問規則,即在虛擬機中將變數存儲到記憶體和從記憶體中取出變數這樣的底層細節。此處的變數與Java編程中所說的變數有所區別,它包括 了實例欄位、靜態欄位和構成數組對象的元素,但不包括局部變數與方法參數,因為後者是執行緒私有的,不會被共享,自然就不會存在競爭問題。為了獲得較好的執行效能,Java記憶體模型並沒有限制執行引擎使用處理器特定暫存器或快取來和主記憶體進行交互,也沒有限制即時編譯器進行調整程式碼執行順序這類優化措施。

Java記憶體模型規定了所有的變數都存儲在主記憶體中。每條執行緒還有自己的工作記憶體,執行緒的工作記憶體中保存了該執行緒使用到的變數的主記憶體副本拷貝,執行緒對變數的所有操作(讀取,賦值等 )都必需在工作記憶體中進行,而不能直接讀寫主記憶體中的變數。不同的執行緒之間也無法直接訪問對方工作記憶體中的變數,執行緒間變數值的傳遞均需要通過主記憶體來完成。

這裡所說的主記憶體、工作記憶體和Java記憶體區域中的Java堆、棧、方法區等並不是同一個層次的記憶體劃分,這兩者基本上是沒有關係的。 如果兩者一定要勉強對應起來,那從變數、主記憶體、工作記憶體的定義來看,主記憶體對應Java堆中的對象實例數據部分 ,而工作記憶體則對應於虛擬機棧中的部分區域。從更底層次上說,主記憶體就是直接對應於物理硬體的記憶體,而為了獲取更好的運行速度,虛擬機可能會讓工作記憶體優先存儲於暫存器和高速快取中,因為程式運行時主要訪問讀寫的是工作記憶體。

前邊說的都是和記憶體有關的內容,其實多執行緒有關係的還有指令重排序,指令重排序也會造成在多執行緒訪問下結束和想的不一樣的情況。大段的介紹就不寫了要不篇幅太長了(JVM那裡書裡邊有)。主要就是在CPU執行指令的時候會進行執行順序的優化。畫個圖看一下吧。

具體理論後文再寫先來點乾貨,直接上程式碼,一看就明白。

public class HappendBeforeTest {
    int a = 0;
    int b = 0;
    public static void main(String[] args) {
        HappendBeforeTest test = new HappendBeforeTest();
        Thread threada = new Thread() {
            @Override
            public void run() {
                test.a = 1;
                System.out.println("b=" + test.b);
            }
        };
        Thread threadb = new Thread() {
            @Override
            public void run() {
                test.b = 1;
                System.out.println("a=" + test.a);
            }
        };
        threada.start();
        threadb.start();
    }
}

猜猜有可能輸出什麼?多選

A:a=0,b=1
B:a=1,b=0
C:a=0,b=0
D:a=1,b=1

上邊這段程式碼不太好調,然後我稍微改造了一下。

public class HappendBeforeTest {
    static int a = 0;
    static int b = 0;
    static int x = 0;
    static int y = 0;
    public static void shortWait(long interval) {
        long start = System.nanoTime();
        long end;
        do {
            end = System.nanoTime();
        }
        while (start + interval >= end);
    }
    public static void main(String[] args) throws InterruptedException {
        for (; ; ) {
            Thread threada = new Thread() {
                @Override
                public void run() {
                    a = 1;
                    x = b;
                }
            };
            Thread threadb = new Thread() {
                @Override
                public void run() {
                    b = 1;
                    y = a;
                }
            };
            Thread starta = new Thread() {
                @Override
                public void run() {
                    // 由於執行緒threada先啟動
                    //下面這句話讓它等一等執行緒startb
                    shortWait(100);
                    threada.start();
                }
            };
            Thread startb = new Thread() {
                @Override
                public void run() {
                    threadb.start();
                }
            };
            starta.start();
            startb.start();
            starta.join();
            startb.join();
            threada.join();
            threadb.join();
            a = 0;
            b = 0;
            System.out.print("x=" + x);
            System.out.print("y=" + y);
            if (x == 0 && y == 0) {
                break;
            }
            x = 0;
            y = 0;
            System.out.println();
        }
    }
}

這段程式碼,a和b初始值為0,然後兩個執行緒同時啟動分別設置a=1,x=b和b=1,y=a。這個程式碼裡邊的starta和startb執行緒完全是為了讓threada 和threadb 兩個執行緒盡量同時啟動而加的,裡邊只是分別調用了threada 和threadb 兩個執行緒。然後無限循環只要x和y 不同時等於0就初始化所有值繼續循環,直到x和y都是0的時候break。你猜猜會不會break。

結果看截圖

因為我沒有記錄循環次數,不知道循環了幾次,然後觸發了條件break了。從程式碼上看,在輸出A之前必然會把B設置成1,在輸出B之前必然會把A設置為1。那為什麼會出現同時是零的情況呢。這就很有可能是指令被重排序了。

指令重排序簡單了說是就兩行以上不相干的程式碼在執行的時候有可能先執行的不是第一條。也就是執行順序會被優化。

如何判斷你寫的程式碼執行順序會不會被優化,要看程式碼之間有沒有Happens-before關係。Happens-before就是不無需任何干涉就可以保證有有序執行,由於篇幅限制Happens-before就不在這裡多做介紹。

下面簡單介紹一下java裡邊的一個關鍵字volatilevolatile簡單來說就是來解決重排序問題的。對一個volatile變數的寫,一定happen-before後續對它的讀。也就是你在寫程式碼的時候不希望你的程式碼被重排序就使用volatile關鍵字。volatile還解決了記憶體可見性問題,在執行執行的時候一共有8條指令lock(鎖定)、read(讀取)、load(載入)、use(使用)、assign(賦值)、store(存儲)、write(寫入)、unlock(解鎖)(篇幅限制具體指令內容自行查詢,看下圖大概有個了解)。

volatile主要是對其中4條指令做了處理。如下圖

也就是把 load和use關聯執行,把assign和store關聯執行。眾所周知有load必需有read現在load又和use關聯也就是要在快取中要use的時候就必須要load要load就必需要read。通俗講就是要use(使用)一個變數的時候必需load(載入),要載入的時候必需從主記憶體read(讀取)這樣就解決了讀的可見性。下面看寫操作它是把assign和store做了關聯,也就是在assign(賦值)後必需store(存儲)。store(存儲)後write(寫入)。也就是做到了給一個變數賦值的時候一串關聯指令直接把變數值寫到主記憶體。就這樣通過用的時候直接從主記憶體取,在賦值到直接寫回主記憶體做到了記憶體可見性。

無鎖編程

我在網上看到大部分寫多執行緒的時候都會寫到鎖,AQS和執行緒池。由於網文太多本文就不多做介紹。下面簡單寫一寫CAS。

CAS是一個比較魔性的操作,用的好可以讓你的程式碼更優雅更高效。它就是無鎖編程的核心。

CAS書上是這麼介紹的:「CAS即Compare and Swap,是JDK提供的非阻塞原子性操作,它通過硬體保證了比較-更新的原子性」。他是非阻塞的還是原子性,也就是說這玩意效率更高。還是通過硬體保證的說明這玩意更可靠。

從上圖可以看出,在cas指令修改變數值的時候,先要進行值的判斷,如果值和原來的值相等說明還沒有被其它執行緒改過,則執行修改,如果被改過了,則不修改。在java裡邊java.util.concurrent.atomic包下邊的類都使用了CAS操作。最常用的方法就是compareAndSet。其底層是調用的Unsafe類的compareAndSwap方法。

作者:高玉瓏