1. 程式人生 > 程式設計 >高併發程式設計學習(1)——併發基礎

高併發程式設計學習(1)——併發基礎

為更良好的閱讀體驗,請訪問原文:傳送門

一、前言

當我們使用計算機時,可以同時做許多事情,例如一邊打遊戲一邊聽音樂。這是因為作業系統支援併發任務,從而使得這些工作得以同時進行。

  • 那麼提出一個問題:如果我們要實現一個程式能一邊聽音樂一邊玩遊戲怎麼實現呢?
public class Tester {

    public static void main(String[] args) {
        System.out.println("開始....");
        playGame();
        playMusic();
        System.out.println("結束....");
    }

    private static void playGame() {
        for (int i = 0; i < 50; i++) {
            System.out.println("玩遊戲" + i);
        }
    }

    private static void playMusic() {
        for (int i = 0; i < 50; i++) {
            System.out.println("播放音樂" + i);
        }
    }
}複製程式碼

我們使用了迴圈來模擬過程,因為播放音樂和打遊戲都是連續的,但是結果卻不盡人意,因為函式體總是要執行完之後才能返回。那麼到底怎麼解決這個問題?

並行與併發

並行性和併發性是既相似又有區別的兩個概念。

並行性是指兩個或多個事件在同一時刻發生。而併發性是指兩個或多個事件在同一時間間隔內發生。

在多道程式環境下,併發性是指在一段時間內巨集觀上有多個程式在同時執行,但在單處理機環境下(一個處理器),每一時刻卻僅能有一道程式執行,故微觀上這些程式只能是分時地交替執行。例如,在 1 秒鐘時間內,0 - 15 ms 程式 A 執行;15 - 30 ms 程式 B 執行;30 - 45 ms 程式 C 執行;45 - 60 ms 程式 D 執行,因此可以說,在 1 秒鐘時間間隔內,巨集觀上有四道程式在同時執行,但微觀上,程式 A、B、C、D 是分時地交替執行的。

如果在計算機系統中有多個處理機,這些可以併發執行的程式就可以被分配到多個處理機上,實現併發執行,即利用每個處理機愛處理一個可併發執行的程式。這樣,多個程式便可以同時執行。以此就能提高系統中的資源利用率,增加系統的吞吐量。

程式和執行緒

程式是指一個記憶體中執行的應用程式。一個應用程式可以同時啟動多個程式,那麼上面的問題就有了解決的思路:我們啟動兩個程式,一個用來打遊戲,一個用來播放音樂。這當然是一種解決方案,但是想象一下,如果一個應用程式需要執行的任務非常多,例如 LOL 遊戲吧,光是需要播放的音樂就有非常多,人物本身的語音,技能的音效,遊戲的背景音樂,塔攻擊的聲音等等等,還不用說遊戲本身,就光播放音樂就需要建立許多許多的程式,而程式本身是一種非常消耗資源的東西,這樣的設計顯然是不合理的。更何況大多數的作業系統都不需要一個程式訪問其他程式的記憶體空間,也就是說,程式之間的通訊很不方便,此時我們就得引入“執行緒”這門技術,來解決這個問題。

執行緒是指程式中的一個執行任務(控制單元),一個程式可以同時併發執行多個執行緒。我們可以開啟工作管理員,觀察到幾乎所有的程式都擁有著許多的「執行緒」(在 WINDOWS 中執行緒是預設隱藏的,需要在「檢視」裡面點選「選擇列」,有一個執行緒數的勾選項,找到並勾選就可以了)。

程式和執行緒的區別

程式:有獨立的記憶體空間,程式中的資料存放空間(堆空間和棧空間)是獨立的,至少有一個執行緒。

執行緒:堆空間是共享的,棧空間是獨立的,執行緒消耗的資源也比程式小,相互之間可以影響的,又稱為輕型程式或程式元。

因為一個程式中的多個執行緒是併發執行的,那麼從微觀角度上考慮也是有先後順序的,那麼哪個執行緒執行完全取決於 CPU 排程器(JVM 來排程),程式設計師是控制不了的。我們可以把多執行緒併發性看作是多個執行緒在瞬間搶 CPU 資源,誰搶到資源誰就執行,這也造就了多執行緒的隨機性。下面我們將看到更生動的例子。

Java 程式的程式(Java 的一個程式執行在系統中)裡至少包含主執行緒和垃圾回收執行緒(後臺執行緒),你可以簡單的這樣認為,但實際上有四個執行緒(瞭解就好):

  • [1] main——main 執行緒,使用者程式入口
  • [2] Reference Handler——清除 Reference 的執行緒
  • [3] Finalizer——呼叫物件 finalize 方法的執行緒
  • [4] Signal Dispatcher——分發處理髮送給 JVM 訊號的執行緒

多執行緒和單執行緒的區別和聯絡?

  1. 單核 CPU 中,將 CPU 分為很小的時間片,在每一時刻只能有一個執行緒在執行,是一種微觀上輪流佔用 CPU 的機制。
  2. 多執行緒會存線上程上下文切換,會導致程式執行速度變慢,即採用一個擁有兩個執行緒的程式執行所需要的時間比一個執行緒的程式執行兩次所需要的時間要多一些。

結論:即採用多執行緒不會提高程式的執行速度,反而會降低速度,但是對於使用者來說,可以減少使用者的響應時間。

多執行緒的優勢

儘管面臨很多挑戰,多執行緒有一些優點仍然使得它一直被使用,而這些優點我們應該瞭解。

優勢一:資源利用率更好

想象一下,一個應用程式需要從本地檔案系統中讀取和處理檔案的情景。比方說,從磁碟讀取一個檔案需要 5 秒,處理一個檔案需要 2 秒。處理兩個檔案則需要:

1| 5秒讀取檔案A
2| 2秒處理檔案A
3| 5秒讀取檔案B
4| 2秒處理檔案B
5| ---------------------
6| 總共需要14秒複製程式碼

從磁碟中讀取檔案的時候,大部分的 CPU 時間用於等待磁碟去讀取資料。在這段時間裡,CPU 非常的空閒。它可以做一些別的事情。通過改變操作的順序,就能夠更好的使用 CPU 資源。看下面的順序:

1| 5秒讀取檔案A
2| 5秒讀取檔案B + 2秒處理檔案A
3| 2秒處理檔案B
4| ---------------------
5| 總共需要12秒複製程式碼

CPU 等待第一個檔案被讀取完。然後開始讀取第二個檔案。當第二檔案在被讀取的時候,CPU 會去處理第一個檔案。記住,在等待磁碟讀取檔案的時候,CPU 大部分時間是空閒的。

總的說來,CPU 能夠在等待 IO 的時候做一些其他的事情。這個不一定就是磁碟 IO。它也可以是網路的 IO,或者使用者輸入。通常情況下,網路和磁碟的 IO 比 CPU 和記憶體的 IO 慢的多。

優勢二:程式設計在某些情況下更簡單

在單執行緒應用程式中,如果你想編寫程式手動處理上面所提到的讀取和處理的順序,你必須記錄每個檔案讀取和處理的狀態。相反,你可以啟動兩個執行緒,每個執行緒處理一個檔案的讀取和操作。執行緒會在等待磁碟讀取檔案的過程中被阻塞。在等待的時候,其他的執行緒能夠使用 CPU 去處理已經讀取完的檔案。其結果就是,磁碟總是在繁忙地讀取不同的檔案到記憶體中。這會帶來磁碟和 CPU 利用率的提升。而且每個執行緒只需要記錄一個檔案,因此這種方式也很容易程式設計實現。

優勢三:程式響應更快

有時我們會編寫一些較為複雜的程式碼(這裡的複雜不是說複雜的演演算法,而是複雜的業務邏輯),例如,一筆訂單的建立,它包括插入訂單資料、生成訂單趕快找、傳送郵件通知賣家和記錄貨品銷售數量等。使用者從單擊“訂購”按鈕開始,就要等待這些操作全部完成才能看到訂購成功的結果。但是這麼多業務操作,如何能夠讓其更快地完成呢?

在上面的場景中,可以使用多執行緒技術,即將資料一致性不強的操作派發給其他執行緒處理(也可以使用訊息佇列),如生成訂單快照、傳送郵件等。這樣做的好處是響應使用者請求的執行緒能夠儘可能快地處理完成,縮短了響應時間,提升了使用者體驗。

其他優勢

多執行緒還有一些優勢也顯而易見:

  • 程式之前不能共享記憶體,而執行緒之間共享記憶體(堆記憶體)則很簡單。
  • 系統建立程式時需要為該程式重新分配系統資源,建立執行緒則代價小很多,因此實現多工併發時,多執行緒效率更高.
  • Java 語言本身內建多執行緒功能的支援,而不是單純地作為底層系統的排程方式,從而簡化了多執行緒程式設計.

上下文切換

即使是單核處理器也支援多執行緒執行程式碼,CPU 通過給每個執行緒分配 CPU 時間片來實現這個機制。時間片是 CPU 分配給各個執行緒的時間,因為時間片非常短,所以 CPU 通過不停地切換執行緒執行,讓我們感覺多個執行緒是同時執行的,時間片一般是幾十毫秒(ms)。

CPU 通過時間片分配演演算法來迴圈執行任務,當前任務執行一個時間片後會切換到下一個任務。但是,在切換前會儲存上一個任務的狀態,以便下次切換回這個任務的時候,可以再載入這個任務的狀態。所以任務從儲存到再載入的過程就是一次上下文切換。

這就像我們同時讀兩本書,當我們在讀一本英文的技術書時,發現某個單詞不認識,於是開啟中英文字典,但是在放下英文技術書之前,大腦必須先記住這本書獨到了多少頁的多少行,等查完單詞之後,能夠繼續讀這本書。這樣的切換是會影響讀書效率的,同樣上下文切換也會影響多執行緒的執行速度。

二、建立執行緒的兩種方式

繼承 Thread 類

public class Tester {

    // 播放音樂的執行緒類
    static class PlayMusicThread extends Thread {

        // 播放時間,用迴圈來模擬播放的過程
        private int playTime = 50;

        public void run() {
            for (int i = 0; i < playTime; i++) {
                System.out.println("播放音樂" + i);
            }
        }
    }

    // 方式1:繼承 Thread 類
    public static void main(String[] args) {
        // 主執行緒:執行遊戲
        for (int i = 0; i < 50; i++) {
            System.out.println("打遊戲" + i);
            if (i == 10) {
                // 建立播放音樂執行緒
                PlayMusicThread musicThread = new PlayMusicThread();
                musicThread.start();
            }
        }
    }
}複製程式碼

執行結果發現打遊戲和播放音樂交替出現,說明已經成功了。

實現 Runnable 介面

public class Tester {

    // 播放音樂的執行緒類
    static class PlayMusicThread implements Runnable {

        // 播放時間,用迴圈來模擬播放的過程
        private int playTime = 50;

        public void run() {
            for (int i = 0; i < playTime; i++) {
                System.out.println("播放音樂" + i);
            }
        }
    }

    // 方式2:實現 Runnable 方法
    public static void main(String[] args) {
        // 主執行緒:執行遊戲
        for (int i = 0; i < 50; i++) {
            System.out.println("打遊戲" + i);
            if (i == 10) {
                // 建立播放音樂執行緒
                Thread musicThread = new Thread(new PlayMusicThread());
                musicThread.start();
            }
        }
    }
}複製程式碼

也能完成效果。

以上就是傳統的兩種建立執行緒的方式,事實上還有第三種,我們後邊再講。

多執行緒一定快嗎?

先來一段程式碼,通過並行和序列來分別執行累加操作,分析:下面的程式碼併發執行一定比序列執行快嗎?

import org.springframework.util.StopWatch;

// 比較並行和序列執行累加操作的速度
public class Tester {

    // 執行次數
    private static final long COUNT = 100000000;
    private static final StopWatch TIMER = new StopWatch();

    public static void main(String[] args) throws InterruptedException {
        concurrency();
        serial();
        // 列印比較測試結果
        System.out.println(TIMER.prettyPrint());
    }

    private static void serial() {
        TIMER.start("序列執行" + COUNT + "條資料");

        int a = 0;
        for (long i = 0; i < COUNT; i++) {
            a += 5;
        }
        // 序列執行
        int b = 0;
        for (long i = 0; i < COUNT; i++) {
            b--;
        }

        TIMER.stop();
    }

    private static void concurrency() throws InterruptedException {
        TIMER.start("並行執行" + COUNT + "條資料");

        // 通過匿名內部類來建立執行緒
        Thread thread = new Thread(() -> {
            int a = 0;
            for (long i = 0; i < COUNT; i++) {
                a += 5;
            }
        });
        thread.start();

        // 並行執行
        int b = 0;
        for (long i = 0; i < COUNT; i++) {
            b--;
        }
        // 等待執行緒結束
        thread.join();
        TIMER.stop();
    }
}複製程式碼

大家可以自己測試一下,每一臺機器 CPU 不同測試結果可能也會不同,之前在 WINDOWS 本兒上測試的時候,多執行緒的優勢從 1 千萬資料的時候才開始體現出來,但是現在換了 MAC,1 億條資料時間也差不多,到 10 億的時候明顯序列就比並行快了... 總之,為什麼併發執行的速度會比序列慢呢?就是因為執行緒有建立和上下文切換的開銷。

繼承 Thread 類還是實現 Runnable 介面?

想象一個這樣的例子:給出一共 50 個蘋果,讓三個同學一起來吃,並且給蘋果編上號碼,讓他們吃的時候順便要說出蘋果的編號:

執行結果可以看到,使用繼承方式實現,每一個執行緒都吃了 50 個蘋果。這樣的結果顯而易見:是因為顯式地建立了三個不同的 Person 物件,而每個物件在堆空間中有獨立的區域來儲存定義好的 50 個蘋果。

而使用實現方式則滿足要求,這是因為三個執行緒共享了同一個 Apple 物件,而物件中的 num 數量是一定的。

所以可以簡單總結出繼承方式和實現方式的區別:

繼承方式:

  1. Java 中類是單繼承的,如果繼承了 Thread 了,該類就不能再有其他的直接父類了;
  2. 從操作上分析,繼承方式更簡單,獲取執行緒名字也簡單..(操作上,更簡單)
  3. 從多執行緒共享同一個資源上分析,繼承方式不能做到...

實現方式:

  1. Java 中類可以實現多個介面,此時該類還可以繼承其他類,並且還可以實現其他介面(設計上,更優雅)..
  2. 從操作上分析,實現方式稍微複雜點,獲取執行緒名字也比較複雜,需要使用 Thread.currentThread() 來獲取當前執行緒的引用..
  3. 從多執行緒共享同一個資源上分析,實現方式可以做到..

在這裡,三個同學完成搶蘋果的例子,使用實現方式才是更合理的方式。

對於這兩種方式哪種好並沒有一個確定的答案,它們都能滿足要求。就我個人意見,我更傾向於實現 Runnable 介面這種方法。因為執行緒池可以有效的管理實現了 Runnable 介面的執行緒,如果執行緒池滿了,新的執行緒就會排隊等候執行,直到執行緒池空閒出來為止。而如果執行緒是通過實現 Thread 子類實現的,這將會複雜一些。

有時我們要同時融合實現 Runnable 介面和 Thread 子類兩種方式。例如,實現了 Thread 子類的例項可以執行多個實現了 Runnable 介面的執行緒。一個典型的應用就是執行緒池。

常見錯誤:呼叫 run() 方法而非 start() 方法

建立並執行一個執行緒所犯的常見錯誤是呼叫執行緒的 run() 方法而非 start() 方法,如下所示:

1| Thread newThread = new Thread(MyRunnable());
2| newThread.run();  //should be start();複製程式碼

起初你並不會感覺到有什麼不妥,因為 run() 方法的確如你所願的被呼叫了。但是,事實上,run() 方法並非是由剛建立的新執行緒所執行的,而是被建立新執行緒的當前執行緒所執行了。也就是被執行上面兩行程式碼的執行緒所執行的。想要讓建立的新執行緒執行 run() 方法,必須呼叫新執行緒的 start() 方法。

三、執行緒的安全問題

吃蘋果遊戲的不安全問題

我們來考慮一下上面吃蘋果的例子,會有什麼問題?

儘管,Java 並不保證執行緒的順序執行,具有隨機性,但吃蘋果比賽的案例執行多次也並沒有發現什麼太大的問題。這並不是因為程式沒有問題,而只是問題出現的不夠明顯,為了讓問題更加明顯,我們使用 Thread.sleep() 方法(經常用來模擬網路延遲)來讓執行緒休息 10 ms,讓其他執行緒去搶資源。(注意:在程式中並不是使用 Thread.sleep(10)之後,程式才出現問題,而是使用之後,問題更明顯.)

為什麼會出現這樣的錯誤呢?

先來分析第一種錯誤:為什麼會吃重複的蘋果呢?就拿 B 和 C 都吃了編號為 47 的蘋果為例吧:

- A 執行緒拿到了編號為 48 的蘋果,列印輸出然後讓 num 減 1,睡眠 10 ms,此時 num 為 47。

- 這時 B 和 C 同時都拿到了編號為 47 的蘋果,列印輸出,在其中一個執行緒作出了減一操作的時候,A 執行緒從睡眠中醒過來,拿到了編號為 46 的蘋果,然後輸出。在這期間並沒有任何操作不允許 B 和 C 執行緒不能拿到同一個編號的蘋果,之前沒有明顯的錯誤僅僅可能只是因為執行速度太快了。

再來分析第二種錯誤:照理來說只應該存在 1-50 編號的蘋果,可是 0 和-1 是怎麼出現的呢?

- 當 num = 1 的時候,A,B,C 三個執行緒同時進入了 try 語句進行睡眠。

- C 執行緒先醒過來,輸出了編號為 1 的蘋果,然後讓 num 減一,當 C 執行緒醒過來的時候發現 num 為 0 了。

- A 執行緒醒過來一看,0 都沒有了,只有 -1 了。

歸根結底是因為沒有任何操作來限制執行緒來獲取相同的資源並對他們進行操作,這就造成了執行緒安全性問題。

如果我們把列印和減一的操作分成兩個步驟,會更加明顯:

ABC 三個執行緒同時列印了 50 的蘋果,然後同時做出減一操作。

像這樣的原子操作,是不允許分步驟進行的,必須保證同步進行,不然可能會引發不可設想的後果。

要解決上述多執行緒併發訪問一個資源的安全性問題,就需要引入執行緒同步的概念。

執行緒同步

多個執行執行緒共享一個資源的情景,是最常見的併發程式設計情景之一。為瞭解決訪問共享資源錯誤或資料不一致的問題,人們引入了臨界區的概念:用以訪問共享資源的程式碼塊,這個程式碼塊在同一時間內只允許一個執行緒執行。

為了幫助程式設計人員實現這個臨界區,Java(以及大多數程式語言)提供了同步機制,當一個執行緒試圖訪問一個臨界區時,它將使用一種同步機制來檢視是不是已經有其他執行緒進入臨界區。如果沒有其他執行緒進入臨界區,他就可以進入臨界區。如果已經有執行緒進入了臨界區,它就被同步機制掛起,直到進入的執行緒離開這個臨界區。如果在等待進入臨界區的執行緒不止一個,JVM 會選擇其中的一個,其餘的將繼續等待。

synchronized 關鍵字

如果一個物件已用 synchronized 關鍵字宣告,那麼只有一個執行執行緒被允許訪問它。使用 synchronized 的好處顯而易見:保證了多執行緒併發訪問時的同步操作,避免執行緒的安全性問題。但是壞處是:使用 synchronized 的方法/程式碼塊的效能比不用要低一些。所以好的做法是:儘量減小 synchronized 的作用域。

我們還是先來解決吃蘋果的問題,考慮一下 synchronized 關鍵字應該加在哪裡呢?

發現如果還再把 synchronized 關鍵字加在 if 裡面的話,0 和 -1 又會出來了。這其實是因為當 ABC 同是進入到 if 語句中,等待臨界區釋放的時,拿到 1 編號的執行緒已經又把 num 減一操作了,而此時最後一個等待臨界區的程式拿到的就會是 -1 了。

同步鎖 Lock

Lock 機制提供了比 synchronized 程式碼塊和 synchronized 方法更廣泛的鎖定操作,同步程式碼塊/ 同步方法具有的功能 Lock 都有,除此之外更強大,更體現面向物件。在併發包的類族中,Lock 是 JUC 包的頂層介面,它的實現邏輯並未用到 synchronized,而是利用了 volatile 的可見性。

使用 Lock 最典型的程式碼如下:

class X {

    private final ReentrantLock lock = new ReentrantLock();

    public void m() {
        lock.lock();
        try {
            // ..... method body
        } finally {
            lock.unlock();
        }
    }
}複製程式碼

執行緒安全問題

執行緒安全問題只在多執行緒環境下才會出現,單執行緒序列執行不存在此類問題。保證高併發場景下的執行緒安全,可以從以下四個維度考量:

維度一:資料單執行緒可見

單執行緒總是安全的。通過限制資料僅在單執行緒內可見,可以避免資料被其他執行緒篡改。最典型的就是執行緒區域性變數,它儲存在獨立虛擬機器器棧幀的區域性變量表中,與其他執行緒毫無瓜葛。TreadLocal 就是採用這種方式來實現執行緒安全的。

維度二:只讀物件

只讀物件總是安全的。它的特性是允許複製、拒絕寫入。最典型的只讀物件有 String、Integer 等。一個物件想要拒絕任何寫入,必須要滿足以下條件:

  • 使用 final 關鍵字修飾類,避免被繼承;
  • 使用 private final 關鍵字避免屬性被中途修改;
  • 沒有任何更新方法;
  • 返回值不能為可變物件。

維度三:執行緒安全類

某些執行緒安全類的內部有非常明確的執行緒安全機制。比如 StringBuffer 就是一個執行緒安全類,它採用 synchronized 關鍵字來修飾相關方法。

維度四:同步與鎖機制

如果想要對某個物件進行併發更新操作,但又不屬於上述三類,需要開發工程師在程式碼中實現安全的同步機制。雖然這個機制支援的併發場景很有價值,但非常複雜且容易出現問題。

處理執行緒安全的核心理念

要麼只讀,要麼加鎖。

合理利用好 JDK 提供的併發包,往往能化腐朽為神奇。Java 併發包(java.util.concurrent,JUC)中大多數類註釋都寫有:@author Doug Lea。如果說 Java 是一本史書,那麼 Doug Lea 絕對是開疆拓土的偉大人物。Doug Lea 在當大學老師時,專攻併發程式設計和併發資料結構設計,主導設計了 JUC 併發包,提高了 Java 併發程式設計的易用性,大大推進了 Java 的商用程式。

參考資料

  • 《Java 零基礎入門教程》 - study.163.com/course/cour…
  • 《Java 併發程式設計的藝術》
  • 《Java 7 併發程式設計實戰手冊》
  • 《碼出高效 Java 開發手冊》 - 楊冠寶(孤盡) 高海慧(鳴莎)著

---

按照慣例黏一個尾巴:

歡迎轉載,轉載請註明出處!

獨立域名部落格:wmyskxz.com

簡書 ID:@我沒有三顆心臟

github:wmyskxz

歡迎關注公眾微訊號:wmyskxz

分享自己的學習 & 學習資料 & 生活

想要交流的朋友也可以加 qq 群:3382693