高併發編程學習(1)——併發基礎

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

1、前言


當咱們使用計算機時,能夠同時作許多事情,例如一邊打遊戲一邊聽音樂。這是由於操做系統支持併發任務,從而使得這些工做得以同時進行。java

  • 那麼提出一個問題:若是咱們要實現一個程序能一邊聽音樂一邊玩遊戲怎麼實現呢?
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);
        }
    }
}

咱們使用了循環來模擬過程,由於播放音樂和打遊戲都是連續的,可是結果卻不盡人意,由於函數體老是要執行完以後才能返回。那麼到底怎麼解決這個問題?git

並行與併發

並行性和併發性是既類似又有區別的兩個概念。程序員

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

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

若是在計算機系統中有多個處理機,這些能夠併發執行的程序就能夠被分配到多個處理機上,實現併發執行,即利用每一個處理機愛處理一個可併發執行的程序。這樣,多個程序即可以同時執行。以此就能提升系統中的資源利用率,增長系統的吞吐量。spring

進程和線程

進程是指一個內存中運行的應用程序。一個應用程序能夠同時啓動多個進程,那麼上面的問題就有了解決的思路:咱們啓動兩個進程,一個用來打遊戲,一個用來播放音樂。這固然是一種解決方案,可是想象一下,若是一個應用程序須要執行的任務很是多,例如 LOL 遊戲吧,光是須要播放的音樂就有很是多,人物自己的語音,技能的音效,遊戲的背景音樂,塔攻擊的聲音等等等,還不用說遊戲自己,就光播放音樂就須要建立許多許多的進程,而進程自己是一種很是消耗資源的東西,這樣的設計顯然是不合理的。更況且大多數的操做系統都不須要一個進程訪問其餘進程的內存空間,也就是說,進程之間的通訊很不方便,此時咱們就得引入「線程」這門技術,來解決這個問題。編程

線程是指進程中的一個執行任務(控制單元),一個進程能夠同時併發運行多個線程。咱們能夠打開任務管理器,觀察到幾乎全部的進程都擁有着許多的「線程」(在 WINDOWS 中線程是默認隱藏的,須要在「查看」裏面點擊「選擇列」,有一個線程數的勾選項,找到並勾選就能夠了)。segmentfault

進程和線程的區別

進程:有獨立的內存空間,進程中的數據存放空間(堆空間和棧空間)是獨立的,至少有一個線程。安全

線程:堆空間是共享的,棧空間是獨立的,線程消耗的資源也比進程小,相互之間能夠影響的,又稱爲輕型進程或進程元。微信

由於一個進程中的多個線程是併發運行的,那麼從微觀角度上考慮也是有前後順序的,那麼哪一個線程執行徹底取決於 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 經過時間片分配算法來循環執行任務,當前任務執行一個時間片後會切換到下一個任務。可是,在切換前會保存上一個任務的狀態,以便下次切換回這個任務的時候,能夠再加載這個任務的狀態。因此任務從保存到再加載的過程就是一次上下文切換。

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

2、建立線程的兩種方式


繼承 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() 方法。

3、線程的安全問題


吃蘋果遊戲的不安全問題

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

儘管,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 的商用進程。

參考資料



按照慣例黏一個尾巴:

歡迎轉載,轉載請註明出處!
獨立域名博客:wmyskxz.com
簡書 ID: @我沒有三顆心臟
github: wmyskxz
歡迎關注公衆微信號:wmyskxz
分享本身的學習 & 學習資料 & 生活
想要交流的朋友也能夠加 qq 羣:3382693

本文由博客一文多發平臺 OpenWrite 發佈!

相關文章
相關標籤/搜索