多線程中那些看不見的陷阱

多線程編程就像一個沼澤,中間遍及各類各樣的陷阱。大多數開發者絕大部分時間都是在作上層應用的開發,並不須要過多地涉入底層細節。可是在多線程編程或者說是併發編程中,有很是多的陷阱被埋在底層細節當中。若是不知道這些底層知識,可能在編寫過程當中徹底意識不到程序已經出現了漏洞,甚至在漏洞爆發以後也很難排查出具體緣由進而解決漏洞。雖然前面提到的漏洞聽起來很嚇人,可是相信經過咱們逐步的抽絲剝繭,在最後必定能掌握大量的實用工具來幫助咱們解決這些問題,實現可靠的併發程序。java

閱讀本文須要瞭解併發的基本概念和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,這時結果就出現了問題。這種在多個線程中對共享數據進行競爭性訪問的狀況就被稱爲數據競爭,能夠理解爲對共享數據的併發訪問會致使問題的狀況就是數據競爭app

那麼咱們如何解決這樣的數據競爭問題呢?ide

synchronized關鍵字

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

for (int i = 0; i < 1000000; ++i) {
    synchronized (this) {
        count += 1;
    }
}

運行以後能夠看到,此次的輸出是兩百萬整了。在這裏,synchronized發揮的做用就是讓兩個線程互斥地執行count += 1;語句。所謂互斥也就是同一時間只能有一個線程執行,若是另外一個線程同時也要執行的話則必須等到前一個線程完成操做退出synchronized語句塊以後才能進入。oop

這種同一時間只能被一個線程訪問的代碼塊就被稱爲臨界區,而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這樣的持久性存儲。其實在內存之上還有多個層次,較完整的計算機存儲體系從下到上依次有外存、內存、「L三、L二、L1三層高速緩存」、寄存器這幾層。在這個存儲體系中從下到上是一個速度從慢到快的結構,越上層速度越快,因此當CPU操做內存數據時會盡可能把數據讀取到內存之上的高速緩存中再進行讀寫。

因此若是程序想要修改一個變量的值,那麼系統會先把新值寫到L1緩存中,以後在合適的時間纔會將緩存中的數據寫回內存當中。雖然這樣的設置使系統的整體效率獲得了提高,可是也帶來了一個問題,那就是L一、L2兩級高速緩存是核內緩存,也就是說多核處理器的每個核心都有本身獨立的L一、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原來的值。

clipboard.png

總結

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

在這個過程當中,咱們深刻到了計算機系統的底層,瞭解了計算機存儲體系結構和volatile對高速緩存與內存的影響。多線程編程是一個很是好的切入口,讓咱們能夠將之前曾經學過的計算機理論知識與編程實踐結合起來,這種結合對很是多的高級知識領域都是相當重要的。

由於錯誤的程序是沒有價值的,因此對一個程序來講最重要的固然是正確性。可是在實現了正確性的前提下,咱們也必需要想辦法提高程序的性能。由於多線程的目標就是經過多個線程的協做來提高程序的性能,若是達不到這個目標的話咱們辛辛苦苦寫的多線程代碼就沒有意義了。在下一篇文章中咱們將會具體測試多線程程序的性能,經過發現多線程中那些會讓多線程程序運行得比單線程程序更慢的性能陷阱,最終咱們將找到解決這些陷阱的性能優化方法。下一篇文章將在下週發佈,有興趣的讀者能夠關注一下。

相關文章
相關標籤/搜索