這一次,讓我們完全掌握Java多執行緒(2/10)

兜裡有辣條發表於2019-03-10

多執行緒不僅是Java後端開發面試中非常熱門的一個問題,也是各種高階工具、框架與分散式的核心基石。但是這個領域相關的知識點涉及到了執行緒排程、執行緒同步,甚至在一些關鍵點上還涉及到了硬體原語、作業系統等更底層的知識。想要背背面試題很容易,但是如果面試官一追問就很容易露餡,更不用說真正想搞明白這個問題並應用在實際的程式碼實踐中了。

不用擔心!在接下來的一系列文章中將會由淺入深地貫穿這個問題的方方面面,雖然不如一些面試大全來得直接和速成。但是真正搞明白多執行緒程式設計不僅能夠一勞永逸地解決面試中的尷尬,而且還能開啟通往底層知識的大門,不止是搞明白一個孤立的知識點,更是一個將以前曾經瞭解過的理論知識融會貫通連點成面的好機會。

雖然閱讀本文不需要事先了解併發相關的概念,但是如果已經掌握了一些大概的概念將會大大降低理解的難度。有興趣的讀者可以參考本系列的第一篇文章來了解一下併發相關的基本概念——當我們在說“併發、多執行緒”,說的是什麼?

這一系列文章將會包含10篇文章,本文是其中的第二篇,相信只要有耐心看完所有內容一定能輕鬆地玩轉多執行緒程式設計,不止是遊刃有餘地通過面試,更是能熟練掌握多執行緒程式設計的實踐技巧與併發實踐這一Java高階工具與框架的共同核心。

前五篇包含以下內容,將會在近期釋出:

  1. 併發基本概念——當我們在說“併發、多執行緒”,說的是什麼?
  2. 多執行緒入門——本文
  3. 執行緒池剖析
  4. 執行緒同步機制解析
  5. 併發常見問題

為什麼要有多執行緒?

多執行緒程式和一般的單執行緒程式相比引入了同步、執行緒排程、記憶體可見性等一大堆複雜的問題,大大提高了開發者開發程式的難度,那麼為什麼現在多執行緒在各個鄰域中還被如此趨之若鶩呢?

一種場景

在我大學的時候宿舍邊上有一家蓋澆飯,也提供炒菜。老闆非常地耿直,非要按點菜的順序一桌一桌地燒,如果前一桌的菜沒上完後一桌一個菜都別想吃到。結果就是每天這家店裡都是怨聲載道,顧客們常常等了半個小時也等不來一個菜填填肚子。你問我為什麼還會有人去吃,受這罪,那肯定是因為好吃啊?。

不過仔細想想,好像一般的店裡好像並沒有這種情況,因為大部分飯店都是混合著上的,就算前一桌沒上完好歹會給幾個菜墊墊肚子。這在程式中也是一樣,不同的程式之間可以交替執行,不至於在我們的電腦上開啟了開發工具就不能接收微信訊息。

這就是多執行緒的一個應用場景:通過任務的交替執行使一臺計算機上可以同時執行多個程式。

另一種場景

還是在小飯館裡,一個服務員在給一桌點完菜之後肯定不會等到這桌菜上完了才去給另外一桌點菜。一般都是點完菜就把訂單給了廚房,之後就繼續給下一桌點菜了。在這裡,我們可以把服務員想象成我們的計算機,把廚房想象成遠端的伺服器。那麼在我們的電腦下載音樂的時候同時繼續播放音樂,這就能更高效地利用我們的電腦了。

這種場景可以描述為:在等待網路請求、磁碟I/O等耗時操作完成時,可以用多執行緒來讓CPU繼續運轉,以達到有效利用CPU資源的目的。

最後一種場景

然後我們來到了廚房,竟然看到了一個大神,能一個人燒2個灶臺。如果這個廚師大神是一個多核處理器,那麼兩個灶臺就是兩個執行緒,如果只給一個灶臺,那就浪費他的才能了,這絕對是一種損失。

這就是多執行緒應用的最後一種場景:將計算量比較大的任務拆分到兩個CPU上執行可以減少執行完成的時間,而多執行緒就是拆分和執行任務的載體,沒有多執行緒就沒辦法把任務放到多個CPU上執行了。

什麼是多執行緒?

多執行緒就是很多執行緒的意思,嗯,是不是很簡單?

執行緒是作業系統中的一個執行單元,同樣的執行單元還有程式,所有的程式碼都要在程式/執行緒中執行。執行緒是從屬於程式的,一個程式可以包含多個執行緒。程式和執行緒之間還有一個區別就是,每個程式有自己獨立的記憶體空間,互相直接不能直接訪問;但是同一個程式中的多個執行緒都共享程式的記憶體空間,所以可以直接訪問同一塊記憶體,其中最典型的就是Java中的堆。

初識多執行緒程式設計

瞭解了這麼多理論概念,終於到了實際上手寫寫程式碼的時候了。

建立執行緒

Java中的執行緒使用Thread類表示,Thread類的構造器可以傳入一個實現了Runnable介面的物件,這個Runnable物件中的void run()方法就代表了執行緒中會執行的任務。例如如果要建立一個對整型變數進行自增的Runnable任務就可以寫為:

// 靜態變數,用於自增
private static int count = 0;

// 建立Runnable物件(匿名內部類物件)
Runnable task = new Runnable() {
    public void run() {
        for (int i = 0; i < 1e6; ++i) {
            count += 1;
    }
}
複製程式碼

有了Runnable物件代表的待執行任務之後,我們就可以建立兩個執行緒來執行它了。

Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
複製程式碼

但是這時候只是建立了執行緒物件,實際上執行緒還沒有被執行,想要執行執行緒還需要呼叫執行緒物件的start()方法。

t1.start();
t2.start();
複製程式碼

這時候執行緒就能開始執行了,完整的程式碼如下所示:

public class SimpleThread {

    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 = count + 1;
                }
            }
        };

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

        t1.start();
        t2.start();
        
        // 等待t1和t2執行完成
//        t1.join();
//        t2.join();

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

最後輸出的結果是8251,你執行的時候應該會與這個值不同,但是一樣會遠遠小於一百萬。這好像離我們期望的結果有點遠,畢竟每個任務都累加了至少一百萬次。

這是因為我們在main方法中建立執行緒並執行之後並沒有等待執行緒完成,使用t1.join()可以使當前執行緒等待t1執行緒執行完成後再繼續執行。讓我們去掉兩個join方法呼叫前面的雙斜槓試一試效果。

執行緒同步

在我的電腦上執行的結果是1753490,你執行的結果會有不同,但是同樣達不到我們所期望的兩百萬。具體的原因可以從下面的執行順序圖中找到答案。

t1 t2
獲取count值為0
獲取count值為0
計算0+1的結果為2
將2儲存到count
計算0+1的結果為2
將2儲存到count

可以看到,t1和t2兩個執行緒之間的併發執行會導致互相自己的結果覆蓋,最後的結果就會在一百萬與兩百萬之間,但是離兩百萬會有比較大的距離。這樣的多執行緒共同讀取並修改同一個共享資料的程式碼區塊就被稱為臨界區,臨界區同一時刻只允許一個執行緒進入,如果同時有多個執行緒進入就會導致資料競爭問題。如果有讀者對這裡提到的臨界區資料競爭概念還不清楚的,可以參考本系列的第一篇介紹併發基本概念的文章——當我們在說“併發、多執行緒”,說的是什麼?

在Java 5之前,我們最常用的執行緒同步方式就是關鍵字synchronized,這個關鍵字既可以標在方法上,也可以作為獨立的塊結構使用。方法宣告形式的synchronized關鍵字可以在方法定義時如此使用:public synchronized static void methodName()。因為我們的累加操作在繼承自Runnable介面的run()方法中,所以沒辦法改變方法的宣告,那麼就可以使用如下的塊結構形式使用synchronized關鍵字:

Runnable task = new Runnable() {
    public void run() {
        for (int i = 0; i < 1000000; ++i) {
            synchronized (SimpleThread.class) {
                count += 1;
            }
        }
    }
};
複製程式碼

synchronized是一種物件鎖,採用的鎖和具體的物件有關,如果是同一個物件就是同一個鎖;如果是不同的物件則是不同的鎖。同一時刻只能有一個執行緒持有鎖,也就意味著其他想要獲取同一個鎖的執行緒會被阻塞,直到持有鎖的執行緒釋放這個鎖為止。這裡可以把物件鎖對應的物件看做是鎖的名稱,實現同步的並不是物件本身,而是與物件對應的物件鎖。

在塊結構的synchronized關鍵字後的括號中的就是物件鎖所對應的物件,在上面的程式碼中,我們使用了SimpleThread類的類物件對應的鎖作為同步工具。而如果synchronized關鍵字被用在方法宣告中,那麼如果是例項方法(非static方法)對應的物件就是this指標所指向的物件,如果是static方法,那麼對應的物件就是所處類的類物件。

這次我們可以看到輸出的結果每次都是穩定的兩百萬了,我們成功完成了我們的第一個完整的多執行緒程式???

後記

但是一般在實際編寫多執行緒程式碼時,我們一般不會直接建立Thread物件,而是使用執行緒池管理任務的執行。相信讀者們也在很多地方看見過“執行緒池”這個詞,如果希望瞭解執行緒池相關的使用與具體實現,可以關注一下將會在近期釋出的下一篇文章。

到目前為止,我們都只是涉及了併發與多執行緒相關的概念和簡單的多執行緒程式實現。接下來我們就會進入更深入與複雜的多執行緒實現當中了,包括但不限於volatile關鍵字、CAS、AQS、記憶體可見性、常用執行緒池、阻塞佇列、死鎖、非死鎖併發問題、事件驅動模型等等知識點的應用和串聯,最後大家都可以逐步實現在各種工具中常用的一系列併發資料結構與程式,例如AtomicInteger、阻塞佇列、事件驅動Web伺服器。相信大家通過這一系列多執行緒程式設計的冒險歷程之後一定可以做到對多執行緒這個話題舉重若輕、有條不紊了。

相關文章