這一次,讓我們完全掌握Java多線程(2/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服務器。相信大家通過這一系列多線程編程的冒險歷程之後一定可以做到對多線程這個話題舉重若輕、有條不紊了。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章