多執行緒中那些看不見的陷阱

兜裡有辣條發表於2019-04-09

多執行緒程式設計就像一個沼澤,中間遍佈各種各樣的陷阱。大多數開發者絕大部分時間都是在做上層應用的開發,並不需要過多地涉入底層細節。但是在多執行緒程式設計或者說是併發程式設計中,有非常多的陷阱被埋在底層細節當中。如果不知道這些底層知識,可能在編寫過程中完全意識不到程式已經出現了漏洞,甚至在漏洞爆發之後也很難排查出具體原因進而解決漏洞。雖然前面提到的漏洞聽起來很嚇人,但是相信通過我們逐步的抽絲剝繭,在最後一定能掌握大量的實用工具來幫助我們解決這些問題,實現可靠的併發程式。

閱讀本文需要了解併發的基本概念和Java多執行緒程式設計基礎知識,還不瞭解的讀者可以參考一下下面兩篇文章:

  1. 併發的基本概念——當我們在說“併發、多執行緒”,說的是什麼?
  2. Java多執行緒程式設計基礎——這一次,讓我們完全掌握Java多執行緒

資料競爭問題

為了瞭解多執行緒程式有什麼隱藏的陷阱,我們先來看一段程式碼:

public class AccumulateWrong {

    private static int count = 0;

    public static void main(String[] args) throws Exception {
        Runnable task = new Runnable() {
            public void run() {
                for (int i = 0; i < 1000000; ++i) {
                    count += 1;
                }
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("count = " + count);
    }
}
複製程式碼

這段程式碼實現的基本功能就是在兩個執行緒中分別對一個整型累加一百萬次,那麼我們期望的輸出應該總共是兩百萬。但在我的電腦上執行的結果只有1799369,而且每次都不一樣,相信在你的電腦上也會執行得到一個不同的結果,但是肯定會達不到兩百萬。

這段程式碼出現問題的原因就在於,我們在執行count += 1;這行程式碼時,實際在CPU上執行的會是多條指令:

  1. 獲取count變數的當前值
  2. 計算count + 1的值
  3. 將count + 1的結果值存到count變數中

所以就有可能會發生下面的執行順序:

t1 t2
獲取到count的值為100
計算100 + 1 = 101
獲取到count的值為100
把101儲存到count變數中
計算100+ 1 = 101
把101儲存到count變數中

這麼一輪操作結束之後,雖然我們在兩個執行緒中分別對count累加了一次,總共是兩次,但是count的值只變大了1,這時結果就出現了問題。這種在多個執行緒中對共享資料進行競爭性訪問的情況就被稱為資料競爭,可以理解為對共享資料的併發訪問會導致問題的情況就是資料競爭

那麼我們如何解決這樣的資料競爭問題呢?

synchronized關鍵字

相信大多數讀者應該都知道synchronized這個關鍵字,它可以被用在方法定義或者是塊結構上,那麼它到底能發揮怎樣的作用呢?我們把它以塊結構的形式把count += 1;語句包圍起來看看。

for (int i = 0; i < 1000000; ++i) {
    synchronized (this) {
        count += 1;
    }
}
複製程式碼

執行之後可以看到,這次的輸出是兩百萬整了。在這裡,synchronized發揮的作用就是讓兩個執行緒互斥地執行count += 1;語句。所謂互斥也就是同一時間只能有一個執行緒執行,如果另一個執行緒同時也要執行的話則必須等到前一個執行緒完成操作退出synchronized語句塊之後才能進入。

這種同一時間只能被一個執行緒訪問的程式碼塊就被稱為臨界區,而synchronized這樣的保護臨界區同時只能被一個執行緒進入的機制就被稱為互斥鎖。當一個執行緒因為另外一個執行緒已經獲取了鎖而陷入等待時,我們可以稱該執行緒被這個鎖阻塞了。

在Java中,synchronized的背後是物件鎖,每個不同的物件都會對應一個不同的鎖,同一個物件對應同一個鎖。只有獲取同一個鎖才能達到互斥訪問的作用,如果兩個執行緒分別獲取不同的鎖,那麼互相就不會影響了。所以在使用synchronized時,區分背後對應的是哪一個物件鎖就至關重要了。synchronized關鍵字可以被用在方法定義和塊結構兩種情況中,具體對應的鎖如下:

  1. 以塊結構形式使用synchronized關鍵字,則獲取的就是synchronized關鍵字後小括號中的物件所對應的鎖;
  2. synchronized被標記在例項方法上,則獲取的就是this引用指向物件所對應的鎖;
  3. synchronized被標記在類方法(靜態方法)上時,獲取的就是方法所在類的“類物件”所對應的鎖,這裡的類物件就可以理解為是每個類一個用於存放靜態欄位和靜態方法的物件。

因為synchronized一定要有一個對應的物件,所以我們自然不能將基本型別的變數傳入到synchronized後面的括號中。

ReentrantLock

在Java 5中JDK引入了java.util.concurrent包,也許大家都或多或少聽說過這個包,在這個包中提供了大量使用的併發工具類,例如執行緒池、鎖、原子資料類等等,對Java語言的併發程式設計易用性和實際效率產生了跨越性的提高。而ReentrantLock就是這個包中的一員。

ReentrantLock發揮的作用與synchronized相同,都是作為互斥鎖使用的。下面是把之前的累加程式碼改為使用ReentrantLock鎖的版本:

final ReentrantLock lock = new ReentrantLock();

Runnable task = new Runnable() {
    public void run() {
        for (int i = 0; i < 1000000; ++i) {
            lock.lock();
            try {
                count += 1;
            } finally {
                lock.unlock();
            }
        }
    }
};
複製程式碼

執行之後的結果依然是兩百萬,說明ReentrantLock確實能起到保障互斥訪問臨界區的作用。但是既然ReentrantLocksynchronized的作用相同,而且從程式碼來看使用synchronized還更方便,為什麼還要專門定義一個ReentrantLock這樣的類呢?

上面的程式碼中,雖然使用ReentrantLock還要專門寫一個try..finally塊來保證鎖釋放,比較麻煩,但是也能從中看到一個好處就是我們可以決定加鎖的位置和釋放鎖的位置。我們甚至可以在一個方法中加鎖,而在另一個方法中解鎖,雖然這樣做會有風險。相對於傳統的synchronizedReentrantLock還有下面的一些好處:

  1. ReentrantLock可以實現帶有超時時間的鎖等待,我們可以通過tryLock方法進行加鎖,並傳入超時時間引數。如果超過了超時時間還麼有獲得鎖的話,那麼就tryLock方法就會返回false;
  2. ReentrantLock可以使用公平性機制,讓先申請鎖的執行緒先獲得鎖,防止執行緒一直等待鎖但是獲取不到;
  3. ReentrantLock可以實現讀寫鎖等更豐富的型別。

更簡便的方式——AtomicInteger

java.util.concurrent包中,我們可以找到一個很有趣的子包atomic,在這個包中我們看到有很多以Atomic開頭的“包裝型別”,這些類會有什麼用呢?我們先來看一下前面的累加程式使用AtomicInteger該如何實現。

public class AtomicIntegerDemo {

    private static AtomicInteger count = new AtomicInteger(0);

    public static void main(String[] args) throws Exception {
        Runnable task = new Runnable() {
            public void run() {
                for (int i = 0; i < 1000000; ++i) {
                    count.incrementAndGet();
                }
            }
        };

        Thread t1 = new Thread(task);
        Thread t2 = new Thread(task);

        t1.start();
        t2.start();

        t1.join();
        t2.join();

        System.out.println("count = " + count);
    }

}
複製程式碼

執行這個程式,我們也可以得到正確的結果兩百萬。在這個版本的程式碼中我們主要改了兩處地方,一個是把count變數的型別修改為了AtomicInteger型別,然後把Runnable物件中的累加方式修改為了count.incrementAndGet()

AtomicInteger提供了原子性的變數值修改方式,原子性保證了整個累加操作可以被看成是一個操作,不會出現更細粒度的操作之間互相穿插導致錯誤結果的情況。在底層AtomicInteger是基於硬體的CAS原語來實現的,CAS是“Compare and Swap”的縮寫,意思是在修改一個變數時會同時指定新值和舊值,只有在舊值等於變數的當前值時,才會把變數的值修改為新值。這個CAS操作在硬體層面是可以保證原子性的。

我們既可以用Atomic類來實現一些簡單的併發修改功能,也可以使用它來對一些關鍵的控制變數進行控制,起到控制併發過程的目的。執行緒池類ThreadPoolExecutor中用於控制執行緒池狀態和執行緒數的控制變數ctl就是一個AtomicInteger型別的欄位。

記憶體可見性問題

看完了如何解決資料競爭問題,我們再來看一個略顯神奇的例子。

public class MemoryVisibilityDemo {

    private static boolean flag;

    public static void main(String[] args) throws Exception {

        for (int i = 0; i < 10000; ++i) {
            flag = false;
            final int no = i;

            Thread t1 = new Thread(new Runnable() {
                @Override
                public void run() {
                    flag = true;
                    System.out.println(String.format("No.%d loop, t1 is done.", no));
                }
            });

            Thread t2 = new Thread(new Runnable() {
                @Override
                public void run() {
                    while (!flag) ;

                    System.out.println(String.format("No.%d loop, t2 is done.", no));
                }
            });

            t2.start();
            t1.start();

            t1.join();
            t2.join();
        }
    }

}
複製程式碼

這段程式在我的電腦上輸出是這樣的:

No.0 loop, t2 is done.
No.0 loop, t1 is done.
No.1 loop, t1 is done.
No.1 loop, t2 is done.
No.2 loop, t2 is done.
No.2 loop, t1 is done.
No.3 loop, t2 is done.
No.3 loop, t1 is done.
No.4 loop, t1 is done.
複製程式碼

在上面的程式輸出中我們可以看到,程式碼中的迴圈是10000次,但是在程式輸出結果中到第五次就結束了。而且第五次執行中只有t1執行完了,t2的結束語句一直沒輸出。這說明程式被卡在了while (!flag) ;上,但是t1明明已經執行結束了,說明此時flag = true已經執行了,為什麼t2還會被卡住呢?

這是因為記憶體可見性在作祟,在計算機中,我們的儲存會分為很多不同的層次,大家比較常見的就是記憶體和外存,外存就是比如磁碟、SSD這樣的永續性儲存。其實在記憶體之上還有多個層次,較完整的計算機儲存體系從下到上依次有外存、記憶體、“L3、L2、L1三層快取記憶體”、暫存器這幾層。在這個儲存體系中從下到上是一個速度從慢到快的結構,越上層速度越快,所以當CPU操作記憶體資料時會盡量把資料讀取到記憶體之上的快取記憶體中再進行讀寫。

所以如果程式想要修改一個變數的值,那麼系統會先把新值寫到L1快取中,之後在合適的時間才會將快取中的資料寫回記憶體當中。雖然這樣的設定使系統的總體效率得到了提升,但是也帶來了一個問題,那就是L1、L2兩級快取記憶體是核內快取,也就是說多核處理器的每一個核心都有自己獨立的L1、L2快取記憶體。那麼如果我們在一個核中執行的執行緒上修改了變數的值而沒有寫回記憶體的話,其他核心上執行的執行緒就看不到這個變數的最新值了。

結合我們前面的程式例子,因為修改和讀取靜態變數flag的程式碼在兩個不同的執行緒中,所以在多核處理器上執行這段程式時,就有可能在兩個不同的處理器核心上執行這兩段程式碼。最終就會導致執行緒t1雖然已經把flag變數的值修改為true了,但是因為這個值還沒有寫回記憶體,所以執行緒t2看到的flag變數的值仍然是false,這就是之前的程式碼會被卡住的罪魁禍首。

那麼我們如何解決這個問題呢?

volatile變數

最簡單的方式是使用volatile變數,即把flag變數標記為volatile,如下所示:

private static volatile boolean flag;
複製程式碼

這下程式就可以穩定地跑完了,那麼volatile做了什麼解決了記憶體可見性問題呢?根據編號為JSR-133的Java語言規範所定義的Java記憶體模型(JMM)volatile變數保證了對該變數的寫入操作和在其之後的讀取操作之間存在同步關係,這個同步關係保證了對volatile變數的讀取一定可以獲取到該變數的最新值。在底層,對volatile變數的寫入會觸發快取記憶體強制寫回記憶體,該操作會使其他處理器核心中的同一個資料塊無效化,必須從記憶體中重新讀取。Java記憶體模型的具體內容在下一節中會有簡單的介紹。

從上面的記憶體可見性問題我們可以發現,多執行緒程式中會出現的一些問題涉及一些非常底層的知識,而且不瞭解的人是很難事先預防和事後排查的。所以對於希望真正掌握多執行緒程式設計的朋友來說,這必然會是一場非常奇妙與漫長的旅程,希望大家都能堅持到最後。

Java記憶體模型

Java語言規範中的JSR-133定義了一系列決定不同執行緒之間指令的邏輯順序,從而保證了不會出現記憶體可見性和指令重排序所引發的併發問題,這對完全掌握多執行緒程式的正確性至關重要。

在程式中,我們一般會認定程式語句是按程式碼中的順序執行的,比如下面這段程式碼:

a = 0;
a = 1;
b = 2;
c = 3;
複製程式碼

我們當然會認為程式的執行順序是a = 0; -> a = 1; -> b = 2; -> c = 3;,但實際上會有兩種情況可能會破壞語句的執行順序,一是編譯器對指令的重排序可能會導致語句的順序發生改變,二是前面提到的記憶體可見性。

對於編譯器的指令重排序來說,雖然編譯器會保證單個執行緒內語句的執行效果與順序執行相同,但是在上面的程式碼中三個語句之間是沒有依賴關係的,任意順序執行的效果都是相同的,所以編譯器是有可能對其中的語句進行重排序的。在單執行緒程式中這當然沒有問題,任意順序執行上面程式碼中的語句都是一樣的,但是在多執行緒情況下,問題就複雜了。如果另外一個執行緒在變數b的值變為2後會列印變數a的值,那麼按我們的期望這段程式應該列印出的1。但是如果b = 2;語句被重排序到了a = 1;之前和a = 0;之後,那麼我們列印出的值就是0了。

對於記憶體可見性,如果b = 2;對變數b的修改結果先於a = 1;寫回了記憶體中。那麼在另一個執行緒中,當看到變數b的值變為2時還不能看到變數a的新值1,這同樣會導致程式列印出不符合我們期望的值。

從上面的介紹我們可以看出,在這個問題中最重要的是語句的執行順序,在預設情況下,我們可以保證單執行緒內的執行順序所產生的結果一定是符合我們的期望的,但一旦進入多執行緒情況下,我們就不能做出這樣的保證了。**那麼我們如何保證多個執行緒之間語句的執行順序關係呢?**這就要說到我們之前說到的Java記憶體模型了。

Java記憶體模型中定義了不同執行緒中的語句的順序關係,這被稱為Happens-Before關係,以下簡稱HB。這個關係指的是如果“操作A”HB於“操作B”,那麼如果“操作A”確實在“操作B”之前已經發生了,那麼“操作B”一定會像在“操作A”之後發生一樣:看到“操作A”發生後所產生的所有結果,比如變數值的修改。如果“操作A”把變數a的值修改為了2,那麼所有“操作B”都一定能看到變數a的值為2,不論是編譯器對指令的重排序還是不同處理器核心之間的記憶體可見性都不能破壞這個結果。

正是因為這種指令執行先後關係的核心就是看到之前執行指令在記憶體中體現的結果,所以這個規範才被稱為Java記憶體模型

常用的Happens-Before關係規則:

  1. 同一個執行緒中,“先執行的語句” HB於 “之後執行的所有語句”;
  2. “對volatile變數的寫操作” HB於 “對同一個變數的讀操作”;
  3. “對鎖的釋放操作” HB於 “對同一個鎖的加鎖操作”;
  4. “對Thread物件的start操作” HB於 “該執行緒任務中的第一行語句”;
  5. “執行緒任務中的最後一行語句” HB於 “對該執行緒對應的Thread物件的join操作”;
  6. 傳遞性規則:如果“操作B” HB於 “操作A”,“操作C” HB於 “操作B”,那麼“操作C” 也HB於 “操作A”。

通過第一條規則我們就確定了單執行緒內的語句的執行順序,而通過規則2到規則4,我們就可以執行緒間確定具體的語句執行順序了。最後的規則6傳遞性規則是整個規則體系的補充,利用這條規則我們就可以把規則1中的執行緒內順序和規則2到4的執行緒間規則進行結合,得到最終的完整順序體系了。

在下圖中,左邊一列和右邊一列分別是兩條不同的執行緒中執行的語句及其順序。如果變數c是一個volatile變數,那麼根據規則2,我們可以知道操作c = 3 HB於 操作print c,下圖中用紅線標明瞭這個關係。所以根據JMM的定義,print c將可以看到變數c的值已經被修改為3了,列印結果將是3,如果在print c語句下方繼續執行對變數a和b的列印,那麼結果必然分別是1和2。

但是我們不能保證右側的第一條print b語句一定會列印出2的值,即使它在時間上發生於b = 2之後。因為指令重排序或者記憶體可見性問題都有可能會使它只能看到變數b在b = 2之前的原值。也就是說HB關係是沒辦法指定兩條執行緒中在HB關係之前的語句相互之間的順序關係的,在下圖的例子中就是print b並不能保證一定可以列印出值2,也有可能列印出變數b原來的值。

多執行緒中那些看不見的陷阱

總結

在這篇文章中我們主要介紹瞭如何保證多執行緒程式的正確性,使執行過程和結果符合我們的預期。通過對多執行緒程式正確性問題的探索,我們介紹了三種常用的執行緒同步方式,分別是鎖、CAS與volatile變數。其中,鎖有synchronized關鍵字和ReentrantLock兩種實現方式。

在這個過程中,我們深入到了計算機系統的底層,瞭解了計算機儲存體系結構和volatile對快取記憶體與記憶體的影響。多執行緒程式設計是一個非常好的切入口,讓我們可以將以前曾經學過的計算機理論知識與程式設計實踐結合起來,這種結合對非常多的高階知識領域都是至關重要的。

因為錯誤的程式是沒有價值的,所以對一個程式來說最重要的當然是正確性。但是在實現了正確性的前提下,我們也必須要想辦法提升程式的效能。因為多執行緒的目標就是通過多個執行緒的協作來提升程式的效能,如果達不到這個目標的話我們辛辛苦苦寫的多執行緒程式碼就沒有意義了。在下一篇文章中我們將會具體測試多執行緒程式的效能,通過發現多執行緒中那些會讓多執行緒程式執行得比單執行緒程式更慢的效能陷阱,最終我們將找到解決這些陷阱的效能優化方法。下一篇文章將在下週釋出,有興趣的讀者可以關注一下。

相關文章