2w字 + 40張圖帶你參透併發程式設計!

程式設計師cxuan發表於2020-08-18

併發歷史

在計算機最早期的時候,沒有作業系統,執行程式只需要一種方式,那就是從頭到尾依次執行。任何資源都會為這個程式服務,在計算機使用某些資源時,其他資源就會空閒,就會存在 浪費資源 的情況。

這裡說的浪費資源指的是資源空閒,沒有充分使用的情況。

作業系統的出現為我們的程式帶來了 併發性,作業系統使我們的程式能夠同時執行多個程式,一個程式就是一個程式,也就相當於同時執行多個程式。

作業系統是一個併發系統,併發性是作業系統非常重要的特徵,作業系統具有同時處理和排程多個程式的能力,比如多個 I/O 裝置同時在輸入輸出;裝置 I/O 和 CPU 計算同時進行;記憶體中同時有多個系統和使用者程式被啟動交替、穿插地執行。作業系統在協調和分配程式的同時,作業系統也會為不同程式分配不同的資源。

作業系統實現多個程式同時執行解決了單個程式無法做到的問題,主要有下面三點

  • 資源利用率,我們上面說到,單個程式存在資源浪費的情況,舉個例子,當你在為某個資料夾賦予許可權的時候,輸入程式無法接受外部的輸入字元,只有等到許可權賦予完畢後才能接受外部輸入。總的來講,就是在等待程式時無法執行其他工作。如果在等待程式時可以執行另一個程式,那麼將會大大提高資源的利用率。(資源並不會覺得累)因為它不會划水~
  • 公平性,不同的使用者和程式都能夠使用計算機上的資源。一種高效的執行方式是為不同的程式劃分時間片來使用資源,但是有一點需要注意,作業系統可以決定不同程式的優先順序。雖然每個程式都有能夠公平享有資源的權利,但是當有一個程式釋放資源後的同時有一個優先順序更高的程式搶奪資源,就會造成優先順序低的程式無法獲得資源,進而導致程式飢餓。
  • 便利性,單個程式是是不用通訊的,通訊的本質就是資訊交換,及時進行資訊交換能夠避免資訊孤島,做重複性的工作;任何併發能做的事情,單程式也能夠實現,只不過這種方式效率很低,它是一種順序性的。

但是,順序程式設計(也稱為序列程式設計)也不是一無是處的,序列程式設計的優勢在於其直觀性和簡單性,客觀來講,序列程式設計更適合我們人腦的思考方式,但是我們並不會滿足於順序程式設計,we want it more!!! 。資源利用率、公平性和便利性促使著程式出現的同時,也促使著執行緒的出現。

如果你還不是很理解程式和執行緒的區別的話,那麼我就以我多年作業系統的經驗(吹牛逼,實則半年)來為你解釋一下:程式是一個應用程式,而執行緒是應用程式中的一條順序流

程式中會有多個執行緒來完成一些任務,這些任務有可能相同有可能不同。每個執行緒都有自己的執行順序。

每個執行緒都有自己的棧空間,這是執行緒私有的,還有一些其他執行緒內部的和執行緒共享的資源,如下所示。

在計算機中,一般堆疊指的就是棧,而堆指的才是堆

執行緒會共享程式範圍內的資源,例如記憶體和檔案控制程式碼,但是每個執行緒也有自己私有的內容,比如程式計數器、棧以及區域性變數。下面彙總了程式和執行緒共享資源的區別

執行緒是一種輕量級的程式,輕量級體現線上程的建立和銷燬要比程式的開銷小很多。

注意:任何比較都是相對的。

在大多數現代作業系統中,都以執行緒為基本的排程單位,所以我們的視角著重放在對執行緒的探究。

執行緒

什麼是多執行緒

多執行緒意味著你能夠在同一個應用程式中執行多個執行緒,我們知道,指令是在 CPU 中執行的,多執行緒應用程式就像是具有多個 CPU 在同時執行應用程式的程式碼。

其實這是一種假象,執行緒數量並不等於 CPU 數量,單個 CPU 將在多個執行緒之間共享 CPU 的時間片,在給定的時間片內執行每個執行緒之間的切換,每個執行緒也可以由不同的 CPU 執行,如下圖所示

併發和並行的關係

併發意味著應用程式會執行多個的任務,但是如果計算機只有一個 CPU 的話,那麼應用程式無法同時執行多個的任務,但是應用程式又需要執行多個任務,所以計算機在開始執行下一個任務之前,它並沒有完成當前的任務,只是把狀態暫存,進行任務切換,CPU 在多個任務之間進行切換,直到任務完成。如下圖所示

並行是指應用程式將其任務分解為較小的子任務,這些子任務可以並行處理,例如在多個CPU上同時進行。

優勢和劣勢

合理使用執行緒是一門藝術,合理編寫一道準確無誤的多執行緒程式更是一門藝術,如果執行緒使用得當,能夠有效的降低程式的開發和維護成本。

Java 很好的在使用者空間實現了開發工具包,並在核心空間提供系統呼叫來支援多執行緒程式設計,Java 支援了豐富的類庫 java.util.concurrent 和跨平臺的記憶體模型,同時也提高了開發人員的門檻,併發一直以來是一個高階的主題,但是現在,併發也成為了主流開發人員的必備素質。

雖然執行緒帶來的好處很多,但是編寫正確的多執行緒(併發)程式是一件極困難的事情,併發程式的 Bug 往往會詭異地出現又詭異的消失,在當你認為沒有問題的時候它就出現了,難以定位 是併發程式的一個特徵,所以在此基礎上你需要有紮實的併發基本功。那麼,併發為什麼會出現呢?

併發為什麼會出現

計算機世界的快速發展離不開 CPU、記憶體和 I/O 裝置的高速發展,但是這三者一直存在速度差異性問題,我們可以從儲存器的層次結構可以看出

CPU 內部是暫存器的構造,暫存器的訪問速度要高於快取記憶體,快取記憶體的訪問速度要高於記憶體,最慢的是磁碟訪問。

程式是在記憶體中執行的,程式裡大部分語句都要訪問記憶體,有些還需要訪問 I/O 裝置,根據漏桶理論來說,程式整體的效能取決於最慢的操作也就是磁碟訪問速度。

因為 CPU 速度太快了,所以為了發揮 CPU 的速度優勢,平衡這三者的速度差異,計算機體系機構、作業系統、編譯程式都做出了貢獻,主要體現為:

  • CPU 使用快取來中和和記憶體的訪問速度差異
  • 作業系統提供程式和執行緒排程,讓 CPU 在執行指令的同時分時複用執行緒,讓記憶體和磁碟不斷互動,不同的 CPU 時間片 能夠執行不同的任務,從而均衡這三者的差異
  • 編譯程式提供優化指令的執行順序,讓快取能夠合理的使用

我們在享受這些便利的同時,多執行緒也為我們帶來了挑戰,下面我們就來探討一下併發問題為什麼會出現以及多執行緒的源頭是什麼

執行緒帶來的安全性問題

執行緒安全性是非常複雜的,在沒有采用同步機制的情況下,多個執行緒中的執行操作往往是不可預測的,這也是多執行緒帶來的挑戰之一,下面我們給出一段程式碼,來看看安全性問題體現在哪

public class TSynchronized implements Runnable{

    static int i = 0;

    public void increase(){
        i++;
    }


    @Override
    public void run() {
        for(int i = 0;i < 1000;i++) {
            increase();
        }
    }

    public static void main(String[] args) throws InterruptedException {

        TSynchronized tSynchronized = new TSynchronized();
        Thread aThread = new Thread(tSynchronized);
        Thread bThread = new Thread(tSynchronized);
        aThread.start();
        bThread.start();
        System.out.println("i = " + i);
    }
}

這段程式輸出後會發現,i 的值每次都不一樣,這不符合我們的預測,那麼為什麼會出現這種情況呢?我們先來分析一下程式的執行過程。

TSynchronized 實現了 Runnable 介面,並定義了一個靜態變數 i,然後在 increase 方法中每次都增加 i 的值,在其實現的 run 方法中進行迴圈呼叫,共執行 1000 次。

可見性問題

在單核 CPU 時代,所有的執行緒共用一個 CPU,CPU 快取和記憶體的一致性問題容易解決,CPU 和 記憶體之間

如果用圖來表示的話我想會是下面這樣

在多核時代,因為有多核的存在,每個核都能夠獨立的執行一個執行緒,每顆 CPU 都有自己的快取,這時 CPU 快取與記憶體的資料一致性就沒那麼容易解決了,當多個執行緒在不同的 CPU 上執行時,這些執行緒操作的是不同的 CPU 快取

因為 i 是靜態變數,沒有經過任何執行緒安全措施的保護,多個執行緒會併發修改 i 的值,所以我們認為 i 不是執行緒安全的,導致這種結果的出現是由於 aThread 和 bThread 中讀取的 i 值彼此不可見,所以這是由於 可見性 導致的執行緒安全問題。

原子性問題

看起來很普通的一段程式卻因為兩個執行緒 aThreadbThread 交替執行產生了不同的結果。但是根源不是因為建立了兩個執行緒導致的,多執行緒只是產生執行緒安全性的必要條件,最終的根源出現在 i++ 這個操作上。

這個操作怎麼了?這不就是一個給 i 遞增的操作嗎?也就是 i++ => i = i + 1,這怎麼就會產生問題了?

因為 i++ 不是一個 原子性 操作,仔細想一下,i++ 其實有三個步驟,讀取 i 的值,執行 i + 1 操作,然後把 i + 1 得出的值重新賦給 i(將結果寫入記憶體)。

當兩個執行緒開始執行後,每個執行緒都會把 i 的值讀入到 CPU 快取中,然後執行 + 1 操作,再把 + 1 之後的值寫入記憶體。因為執行緒間都有各自的虛擬機器棧和程式計數器,他們彼此之間沒有資料交換,所以當 aThread 執行 + 1 操作後,會把資料寫入到記憶體,同時 bThread 執行 + 1 操作後,也會把資料寫入到記憶體,因為 CPU 時間片的執行週期是不確定的,所以會出現當 aThread 還沒有把資料寫入記憶體時,bThread 就會讀取記憶體中的資料,然後執行 + 1操作,再寫回記憶體,從而覆蓋 i 的值,導致 aThread 所做的努力白費。

為什麼上面的執行緒切換會出現問題呢?

我們先來考慮一下正常情況下(即不會出現執行緒安全性問題的情況下)兩條執行緒的執行順序

可以看到,當 aThread 在執行完整個 i++ 的操作後,作業系統對執行緒進行切換,由 aThread -> bThread,這是最理想的操作,一旦作業系統在任意 讀取/增加/寫入 階段產生執行緒切換,都會產生執行緒安全問題。例如如下圖所示

最開始的時候,記憶體中 i = 0,aThread 讀取記憶體中的值並把它讀取到自己的暫存器中,執行 +1 操作,此時發生執行緒切換,bThread 開始執行,讀取記憶體中的值並把它讀取到自己的暫存器中,此時發生執行緒切換,執行緒切換至 aThread 開始執行,aThread 把自己暫存器的值寫回到記憶體中,此時又發生執行緒切換,由 aThread -> bThread,執行緒 bThread 把自己暫存器的值 +1 然後寫回記憶體,寫完後記憶體中的值不是 2 ,而是 1, 記憶體中的 i 值被覆蓋了。

我們上面提到 原子性 這個概念,那麼什麼是原子性呢?

併發程式設計的原子性操作是完全獨立於任何其他程式執行的操作,原子操作多用於現代作業系統和並行處理系統中。

原子操作通常在核心中使用,因為核心是作業系統的主要元件。但是,大多數計算機硬體,編譯器和庫也提供原子性操作。

在載入和儲存中,計算機硬體對儲存器字進行讀取和寫入。為了對值進行匹配、增加或者減小操作,一般通過原子操作進行。在原子操作期間,處理器可以在同一資料傳輸期間完成讀取和寫入。 這樣,其他輸入/輸出機制或處理器無法執行儲存器讀取或寫入任務,直到原子操作完成為止。

簡單來講,就是原子操作要麼全部執行,要麼全部不執行。資料庫事務的原子性也是基於這個概念演進的。

有序性問題

在併發程式設計中還有帶來讓人非常頭疼的 有序性 問題,有序性顧名思義就是順序性,在計算機中指的就是指令的先後執行順序。一個非常顯而易見的例子就是 JVM 中的類載入

這是一個 JVM 載入類的過程圖,也稱為類的生命週期,類從載入到 JVM 到解除安裝一共會經歷五個階段 載入、連線、初始化、使用、解除安裝。這五個過程的執行順序是一定的,但是在連線階段,也會分為三個過程,即 驗證、準備、解析 階段,這三個階段的執行順序不是確定的,通常交叉進行,在一個階段的執行過程中會啟用另一個階段。

有序性問題一般是編譯器帶來的,編譯器有的時候確實是 好心辦壞事,它為了優化系統效能,往往更換指令的執行順序。

活躍性問題

多執行緒還會帶來活躍性問題,如何定義活躍性問題呢?活躍性問題關注的是 某件事情是否會發生

如果一組執行緒中的每個執行緒都在等待一個事件的發生,而這個事件只能由該組中正在等待的執行緒觸發,這種情況會導致死鎖

簡單一點來表述一下,就是每個執行緒都在等待其他執行緒釋放資源,而其他資源也在等待每個執行緒釋放資源,這樣沒有執行緒搶先釋放自己的資源,這種情況會產生死鎖,所有執行緒都會無限的等待下去。

死鎖的必要條件

造成死鎖的原因有四個,破壞其中一個即可破壞死鎖

  • 互斥條件:指程式對所分配到的資源進行排它性使用,即在一段時間內某資源只由一個程式佔用。如果此時還有其它程式請求資源,則請求者只能等待,直至佔有資源的程式釋放。
  • 請求和保持條件:指程式已經保持至少一個資源,但又提出了新的資源請求,而該資源已被其它程式佔有,此時請求程式阻塞,但又對自己已獲得的其它資源保持佔有。
  • 不剝奪條件:指程式已獲得的資源,在未使用完之前,不能被剝奪,只能在使用完時由自己釋放。
  • 迴圈等待:指在發生死鎖時,必然存在一個程式對應的環形鏈。

換句話說,死鎖執行緒集合中的每個執行緒都在等待另一個死鎖執行緒佔有的資源。但是由於所有執行緒都不能執行,它們之中任何一個資源都無法釋放資源,所以沒有一個執行緒可以被喚醒。

如果說死鎖很痴情的話,那麼活鎖用一則成語來表示就是 弄巧成拙

某些情況下,當執行緒意識到它不能獲取所需要的下一個鎖時,就會嘗試禮貌的釋放已經獲得的鎖,然後等待非常短的時間再次嘗試獲取。可以想像一下這個場景:當兩個人在狹路相逢的時候,都想給對方讓路,相同的步調會導致雙方都無法前進。

現在假想有一對並行的執行緒用到了兩個資源。它們分別嘗試獲取另一個鎖失敗後,兩個執行緒都會釋放自己持有的鎖,再次進行嘗試,這個過程會一直進行重複。很明顯,這個過程中沒有執行緒阻塞,但是執行緒仍然不會向下執行,這種狀況我們稱之為 活鎖(livelock)

如果我們期望的事情一直不會發生,就會產生活躍性問題,比如單執行緒中的無限迴圈

while(true){...}

for(;;){}

在多執行緒中,比如 aThread 和 bThread 都需要某種資源,aThread 一直佔用資源不釋放,bThread 一直得不到執行,就會造成活躍性問題,bThread 執行緒會產生飢餓,我們後面會說。

效能問題

與活躍性問題密切相關的是 效能 問題,如果說活躍性問題關注的是最終的結果,那麼效能問題關注的就是造成結果的過程,效能問題有很多方面:比如服務時間過長,吞吐率過低,資源消耗過高,在多執行緒中這樣的問題同樣存在。

在多執行緒中,有一個非常重要的效能因素那就是我們上面提到的 執行緒切換,也稱為 上下文切換(Context Switch),這種操作開銷很大。

在計算機世界中,老外都喜歡用 context 上下文這個詞,這個詞涵蓋的內容很多,包括上下文切換的資源,暫存器的狀態、程式計數器等。context switch 一般指的就是這些上下文切換的資源、暫存器狀態、程式計數器的變化等。

在上下文切換中,會儲存和恢復上下文,丟失區域性性,把大量的時間消耗線上程切換上而不是執行緒執行上。

為什麼執行緒切換會開銷如此之大呢?執行緒間的切換會涉及到以下幾個步驟

將 CPU 從一個執行緒切換到另一執行緒涉及掛起當前執行緒,儲存其狀態,例如暫存器,然後恢復到要切換的執行緒的狀態,載入新的程式計數器,此時執行緒切換實際上就已經完成了;此時,CPU 不在執行執行緒切換程式碼,進而執行新的和執行緒關聯的程式碼。

引起執行緒切換的幾種方式

執行緒間的切換一般是作業系統層面需要考慮的問題,那麼引起執行緒上下文切換有哪幾種方式呢?或者說執行緒切換有哪幾種誘因呢?主要有下面幾種引起上下文切換的方式

  • 當前正在執行的任務完成,系統的 CPU 正常排程下一個需要執行的執行緒
  • 當前正在執行的任務遇到 I/O 等阻塞操作,執行緒排程器掛起此任務,繼續排程下一個任務。
  • 多個任務併發搶佔鎖資源,當前任務沒有獲得鎖資源,被執行緒排程器掛起,繼續排程下一個任務。
  • 使用者的程式碼掛起當前任務,比如執行緒執行 sleep 方法,讓出CPU。
  • 使用硬體中斷的方式引起上下文切換

執行緒安全性

在 Java 中,要實現執行緒安全性,必須要正確的使用執行緒和鎖,但是這些只是滿足執行緒安全的一種方式,要編寫正確無誤的執行緒安全的程式碼,其核心就是對狀態訪問操作進行管理。最重要的就是最 共享(Shared)的 和 可變(Mutable)的狀態。只有共享和可變的變數才會出現問題,私有變數不會出現問題,參考程式計數器

物件的狀態可以理解為儲存在例項變數或者靜態變數中的資料,共享意味著某個變數可以被多個執行緒同時訪問、可變意味著變數在生命週期內會發生變化。一個變數是否是執行緒安全的,取決於它是否被多個執行緒訪問。要使變數能夠被安全訪問,必須通過同步機制來對變數進行修飾。

如果不採用同步機制的話,那麼就要避免多執行緒對共享變數的訪問,主要有下面兩種方式

  • 不要在多執行緒之間共享變數
  • 將共享變數置為不可變的

我們說了這麼多次執行緒安全性,那麼什麼是執行緒安全性呢?

什麼是執行緒安全性

多個執行緒可以同時安全呼叫的程式碼稱為執行緒安全的,如果一段程式碼是安全的,那麼這段程式碼就不存在 競態條件。僅僅當多個執行緒共享資源時,才會出現競態條件。

根據上面的探討,我們可以得出一個簡單的結論:當多個執行緒訪問某個類時,這個類始終都能表現出正確的行為,那麼就稱這個類是執行緒安全的

單執行緒就是一個執行緒數量為 1 的多執行緒,單執行緒一定是執行緒安全的。讀取某個變數的值不會產生安全性問題,因為不管讀取多少次,這個變數的值都不會被修改。

原子性

我們上面提到了原子性的概念,你可以把原子性操作想象成為一個不可分割 的整體,它的結果只有兩種,要麼全部執行,要麼全部回滾。你可以把原子性認為是 婚姻關係 的一種,男人和女人只會產生兩種結果,好好的說散就散,一般男人的一生都可以把他看成是原子性的一種,當然我們不排除時間管理(執行緒切換)的個例,我們知道執行緒切換必然會伴隨著安全性問題,男人要出去浪也會造成兩種結果,這兩種結果分別對應安全性的兩個結果:執行緒安全(好好的)和執行緒不安全(說散就散)。

競態條件

有了上面的執行緒切換的功底,那麼競態條件也就好定義了,它指的就是兩個或多個執行緒同時對一共享資料進行修改,從而影響程式執行的正確性時,這種就被稱為競態條件(race condition) ,執行緒切換是導致競態條件出現的誘導因素,我們通過一個示例來說明,來看一段程式碼

public class RaceCondition {
  
  private Signleton single = null;
  public Signleton newSingleton(){
    if(single == null){
      single = new Signleton();
    }
    return single;
  }
  
}

在上面的程式碼中,涉及到一個競態條件,那就是判斷 single 的時候,如果 single 判斷為空,此時發生了執行緒切換,另外一個執行緒執行,判斷 single 的時候,也是空,執行 new 操作,然後執行緒切換回之前的執行緒,再執行 new 操作,那麼記憶體中就會有兩個 Singleton 物件。

加鎖機制

在 Java 中,有很多種方式來對共享和可變的資源進行加鎖和保護。Java 提供一種內建的機制對資源進行保護:synchronized 關鍵字,它有三種保護機制

  • 對方法進行加鎖,確保多個執行緒中只有一個執行緒執行方法;
  • 對某個物件例項(在我們上面的探討中,變數可以使用物件來替換)進行加鎖,確保多個執行緒中只有一個執行緒對物件例項進行訪問;
  • 對類物件進行加鎖,確保多個執行緒只有一個執行緒能夠訪問類中的資源。

synchronized 關鍵字對資源進行保護的程式碼塊俗稱 同步程式碼塊(Synchronized Block),例如

synchronized(lock){
  // 執行緒安全的程式碼
}

每個 Java 物件都可以用做一個實現同步的鎖,這些鎖被稱為 內建鎖(Instrinsic Lock)或者 監視器鎖(Monitor Lock)。執行緒在進入同步程式碼之前會自動獲得鎖,並且在退出同步程式碼時自動釋放鎖,而無論是通過正常執行路徑退出還是通過異常路徑退出,獲得內建鎖的唯一途徑就是進入這個由鎖保護的同步程式碼塊或方法。

synchronized 的另一種隱含的語義就是 互斥,互斥意味著獨佔,最多隻有一個執行緒持有鎖,當執行緒 A 嘗試獲得一個由執行緒 B 持有的鎖時,執行緒 A 必須等待或者阻塞,直到執行緒 B 釋放這個鎖,如果執行緒 B 不釋放鎖的話,那麼執行緒 A 將會一直等待下去。

執行緒 A 獲得執行緒 B 持有的鎖時,執行緒 A 必須等待或者阻塞,但是獲取鎖的執行緒 B 可以重入,重入的意思可以用一段程式碼表示

public class Retreent {
  
  public synchronized void doSomething(){
    doSomethingElse();
    System.out.println("doSomething......");
  }
  
  public synchronized void doSomethingElse(){
    System.out.println("doSomethingElse......");
}

獲取 doSomething() 方法鎖的執行緒可以執行 doSomethingElse() 方法,執行完畢後可以重新執行 doSomething() 方法中的內容。鎖重入也支援子類和父類之間的重入,具體的我們後面會進行介紹。

volatile 是一種輕量級的 synchronized,也就是一種輕量級的加鎖方式,volatile 通過保證共享變數的可見性來從側面對物件進行加鎖。可見性的意思就是當一個執行緒修改一個共享變數時,另外一個執行緒能夠 看見 這個修改的值。volatile 的執行成本要比 synchronized 低很多,因為 volatile 不會引起執行緒的上下文切換。

我們還可以使用原子類 來保證執行緒安全,原子類其實就是 rt.jar 下面以 atomic 開頭的類

除此之外,我們還可以使用 java.util.concurrent 工具包下的執行緒安全的集合類來確保執行緒安全,具體的實現類和其原理我們後面會說。

可以使用不同的併發模型來實現併發系統,併發模型說的是系統中的執行緒如何協作完成併發任務。不同的併發模型以不同的方式拆分任務,執行緒可以以不同的方式進行通訊和協作。

競態條件和關鍵區域

競態條件是在關鍵程式碼區域發生的一種特殊條件。關鍵區域是由多個執行緒同時執行的程式碼部分,關鍵區域中的程式碼執行順序會對造成不一樣的結果。如果多個執行緒執行一段關鍵程式碼,而這段關鍵程式碼會因為執行順序不同而造成不同的結果時,那麼這段程式碼就會包含競爭條件。

併發模型和分散式系統很相似

併發模型其實和分散式系統模型非常相似,在併發模型中是執行緒彼此進行通訊,而在分散式系統模型中是 程式 彼此進行通訊。然而本質上,程式和執行緒也非常相似。這也就是為什麼併發模型和分散式模型非常相似的原因。

分散式系統通常要比並發系統面臨更多的挑戰和問題比如程式通訊、網路可能出現異常,或者遠端機器掛掉等等。但是一個併發模型同樣面臨著比如 CPU 故障、網路卡出現問題、硬碟出現問題等。

因為併發模型和分散式模型很相似,因此他們可以相互借鑑,例如用於執行緒分配的模型就類似於分散式系統環境中的負載均衡模型。

其實說白了,分散式模型的思想就是借鑑併發模型的基礎上推演發展來的。

認識兩個狀態

併發模型的一個重要的方面是,執行緒是否應該共享狀態,是具有共享狀態還是獨立狀態。共享狀態也就意味著在不同執行緒之間共享某些狀態

狀態其實就是資料,比如一個或者多個物件。當執行緒要共享資料時,就會造成 競態條件 或者 死鎖 等問題。當然,這些問題只是可能會出現,具體實現方式取決於你是否安全的使用和訪問共享物件。

獨立的狀態表明狀態不會在多個執行緒之間共享,如果執行緒之間需要通訊的話,他們可以訪問不可變的物件來實現,這是最有效的避免併發問題的一種方式,如下圖所示

使用獨立狀態讓我們的設計更加簡單,因為只有一個執行緒能夠訪問物件,即使交換物件,也是不可變的物件。

併發模型

並行 Worker

第一個併發模型是並行 worker 模型,客戶端會把任務交給 代理人(Delegator),然後由代理人把工作分配給不同的 工人(worker)。如下圖所示

並行 worker 的核心思想是,它主要有兩個程式即代理人和工人,Delegator 負責接收來自客戶端的任務並把任務下發,交給具體的 Worker 進行處理,Worker 處理完成後把結果返回給 Delegator,在 Delegator 接收到 Worker 處理的結果後對其進行彙總,然後交給客戶端。

並行 Worker 模型是 Java 併發模型中非常常見的一種模型。許多 java.util.concurrent 包下的併發工具都使用了這種模型。

並行 Worker 的優點

並行 Worker 模型的一個非常明顯的特點就是很容易理解,為了提高系統的並行度你可以增加多個 Worker 完成任務。

並行 Worker 模型的另外一個好處就是,它會將一個任務拆分成多個小任務,併發執行,Delegator 在接受到 Worker 的處理結果後就會返回給 Client,整個 Worker -> Delegator -> Client 的過程是非同步的。

並行 Worker 的缺點

同樣的,並行 Worker 模式同樣會有一些隱藏的缺點

共享狀態會變得很複雜

實際的並行 Worker 要比我們圖中畫出的更復雜,主要是並行 Worker 通常會訪問記憶體或共享資料庫中的某些共享資料。

這些共享狀態可能會使用一些工作佇列來儲存業務資料、資料快取、資料庫的連線池等。線上程通訊中,執行緒需要確保共享狀態是否能夠讓其他執行緒共享,而不是僅僅停留在 CPU 快取中讓自己可用,當然這些都是程式設計師在設計時就需要考慮的問題。執行緒需要避免 競態條件死鎖 和許多其他共享狀態造成的併發問題。

多執行緒在訪問共享資料時,會丟失併發性,因為作業系統要保證只有一個執行緒能夠訪問資料,這會導致共享資料的爭用和搶佔。未搶佔到資源的執行緒會 阻塞

現代的非阻塞併發演算法可以減少爭用提高效能,但是非阻塞演算法比較難以實現。

可持久化的資料結構(Persistent data structures) 是另外一個選擇。可持久化的資料結構在修改後始終會保留先前版本。因此,如果多個執行緒同時修改一個可持久化的資料結構,並且一個執行緒對其進行了修改,則修改的執行緒會獲得對新資料結構的引用。

雖然可持久化的資料結構是一個新的解決方法,但是這種方法實行起來卻有一些問題,比如,一個持久列表會將新元素新增到列表的開頭,並返回所新增的新元素的引用,但是其他執行緒仍然只持有列表中先前的第一個元素的引用,他們看不到新新增的元素。

持久化的資料結構比如 連結串列(LinkedList) 在硬體效能上表現不佳。列表中的每個元素都是一個物件,這些物件散佈在計算機記憶體中。現代 CPU 的順序訪問往往要快的多,因此使用陣列等順序訪問的資料結構則能夠獲得更高的效能。CPU 快取記憶體可以將一個大的矩陣塊載入到快取記憶體中,並讓 CPU 在載入後直接訪問 CPU 快取記憶體中的資料。對於連結串列,將元素分散在整個 RAM 上,這實際上是不可能的。

無狀態的 worker

共享狀態可以由其他執行緒所修改,因此,worker 必須在每次操作共享狀態時重新讀取,以確保在副本上能夠正確工作。不線上程內部保持狀態的 worker 成為無狀態的 worker。

作業順序是不確定的

並行工作模型的另一個缺點是作業的順序不確定,無法保證首先執行或最後執行哪些作業。任務 A 在任務 B 之前分配給 worker,但是任務 B 可能在任務 A 之前執行。

流水線

第二種併發模型就是我們經常在生產車間遇到的 流水線併發模型,下面是流水線設計模型的流程圖

這種組織架構就像是工廠中裝配線中的 worker,每個 worker 只完成全部工作的一部分,完成一部分後,worker 會將工作轉發給下一個 worker。

每道程式都在自己的執行緒中執行,彼此之間不會共享狀態,這種模型也被稱為無共享併發模型。

使用流水線併發模型通常被設計為非阻塞I/O,也就是說,當沒有給 worker 分配任務時,worker 會做其他工作。非阻塞I/O 意味著當 worker 開始 I/O 操作,例如從網路中讀取檔案,worker 不會等待 I/O 呼叫完成。因為 I/O 操作很慢,所以等待 I/O 非常耗費時間。在等待 I/O 的同時,CPU 可以做其他事情,I/O 操作完成後的結果將傳遞給下一個 worker。下面是非阻塞 I/O 的流程圖

在實際情況中,任務通常不會按著一條裝配線流動,由於大多數程式需要做很多事情,因此需要根據完成的不同工作在不同的 worker 之間流動,如下圖所示

任務還可能需要多個 worker 共同參與完成

響應式 - 事件驅動系統

使用流水線模型的系統有時也被稱為 響應式 或者 事件驅動系統,這種模型會根據外部的事件作出響應,事件可能是某個 HTTP 請求或者某個檔案完成載入到記憶體中。

Actor 模型

在 Actor 模型中,每一個 Actor 其實就是一個 Worker, 每一個 Actor 都能夠處理任務。

簡單來說,Actor 模型是一個併發模型,它定義了一系列系統元件應該如何動作和互動的通用規則,最著名的使用這套規則的程式語言是 Erlang。一個參與者Actor對接收到的訊息做出響應,然後可以建立出更多的 Actor 或傳送更多的訊息,同時準備接收下一條訊息。

Channels 模型

在 Channel 模型中,worker 通常不會直接通訊,與此相對的,他們通常將事件傳送到不同的 通道(Channel)上,然後其他 worker 可以在這些通道上獲取訊息,下面是 Channel 的模型圖

有的時候 worker 不需要明確知道接下來的 worker 是誰,他們只需要將作者寫入通道中,監聽 Channel 的 worker 可以訂閱或者取消訂閱,這種方式降低了 worker 和 worker 之間的耦合性。

流水線設計的優點

與並行設計模型相比,流水線模型具有一些優勢,具體優勢如下

不會存在共享狀態

因為流水線設計能夠保證 worker 在處理完成後再傳遞給下一個 worker,所以 worker 與 worker 之間不需要共享任何狀態,也就無需考慮併發問題。你甚至可以在實現上把每個 worker 看成是單執行緒的一種。

有狀態 worker

因為 worker 知道沒有其他執行緒修改自身的資料,所以流水線設計中的 worker 是有狀態的,有狀態的意思是他們可以將需要操作的資料保留在記憶體中,有狀態通常比無狀態更快。

更好的硬體整合

因為你可以把流水線看成是單執行緒的,而單執行緒的工作優勢在於它能夠和硬體的工作方式相同。因為有狀態的 worker 通常在 CPU 中快取資料,這樣可以更快地訪問快取的資料。

使任務更加有效的進行

可以對流水線併發模型中的任務進行排序,一般用來日誌的寫入和恢復。

流水線設計的缺點

流水線併發模型的缺點是任務會涉及多個 worker,因此可能會分散在專案程式碼的多個類中。因此很難確定每個 worker 都在執行哪個任務。流水線的程式碼編寫也比較困難,設計許多巢狀回撥處理程式的程式碼通常被稱為 回撥地獄。回撥地獄很難追蹤 debug。

函式性並行

函式性並行模型是最近才提出的一種併發模型,它的基本思路是使用函式呼叫來實現。訊息的傳遞就相當於是函式的呼叫。傳遞給函式的引數都會被拷貝,因此在函式之外的任何實體都無法操縱函式內的資料。這使得函式執行類似於原子操作。每個函式呼叫都可以獨立於任何其他函式呼叫執行。

當每個函式呼叫獨立執行時,每個函式都可以在單獨的 CPU 上執行。這也就是說,函式式並行並行相當於是各個 CPU 單獨執行各自的任務。

JDK 1.7 中的 ForkAndJoinPool 類就實現了函式性並行的功能。Java 8 提出了 stream 的概念,使用並行流也能夠實現大量集合的迭代。

函式性並行的難點是要知道函式的呼叫流程以及哪些 CPU 執行了哪些函式,跨 CPU 函式呼叫會帶來額外的開銷。

我們之前說過,執行緒就是程式中的一條順序流,在 Java 中,每一條 Java 執行緒就像是 JVM 的一條順序流,就像是虛擬 CPU 一樣來執行程式碼。Java 中的 main() 方法是一條特殊的執行緒,JVM 建立的 main 執行緒是一條主執行執行緒,在 Java 中,方法都是由 main 方法發起的。在 main 方法中,你照樣可以建立其他的執行緒(執行順序流),這些執行緒可以和 main 方法共同執行應用程式碼。

Java 執行緒也是一種物件,它和其他物件一樣。Java 中的 Thread 表示執行緒,Thread 是 java.lang.Thread 類或其子類的例項。那麼下面我們就來一起探討一下在 Java 中如何建立和啟動執行緒。

建立並啟動執行緒

在 Java 中,建立執行緒的方式主要有三種

  • 通過繼承 Thread 類來建立執行緒
  • 通過實現 Runnable 介面來建立執行緒
  • 通過 CallableFuture 來建立執行緒

下面我們分別探討一下這幾種建立方式

繼承 Thread 類來建立執行緒

第一種方式是繼承 Thread 類來建立執行緒,如下示例

public class TJavaThread extends Thread{

    static int count;

    @Override
    public synchronized void run() {
        for(int i = 0;i < 10000;i++){
            count++;
        }
    }

    public static void main(String[] args) throws InterruptedException {

        TJavaThread tJavaThread = new TJavaThread();
        tJavaThread.start();
        tJavaThread.join();
        System.out.println("count = " + count);
    }
}

執行緒的主要建立步驟如下

  • 定義一個執行緒類使其繼承 Thread 類,並重寫其中的 run 方法,run 方法內部就是執行緒要完成的任務,因此 run 方法也被稱為 執行體
  • 建立了 Thread 的子類,上面程式碼中的子類是 TJavaThread
  • 啟動方法需要注意,並不是直接呼叫 run 方法來啟動執行緒,而是使用 start 方法來啟動執行緒。當然 run 方法可以呼叫,這樣的話就會變成普通方法呼叫,而不是新建立一個執行緒來呼叫了。
public static void main(String[] args) throws InterruptedException {

  TJavaThread tJavaThread = new TJavaThread();
  tJavaThread.run();
  System.out.println("count = " + count);
}

這樣的話,整個 main 方法只有一條執行執行緒也就是 main 執行緒,由兩條執行執行緒變為一條執行執行緒

Thread 構造器只需要一個 Runnable 物件,呼叫 Thread 物件的 start() 方法為該執行緒執行必須的初始化操作,然後呼叫 Runnable 的 run 方法,以便在這個執行緒中啟動任務。我們上面使用了執行緒的 join 方法,它用來等待執行緒的執行結束,如果我們不加 join 方法,它就不會等待 tJavaThread 的執行完畢,輸出的結果可能就不是 10000

可以看到,在 run 方法還沒有結束前,run 就被返回了。也就是說,程式不會等到 run 方法執行完畢就會執行下面的指令。

使用繼承方式建立執行緒的優勢:編寫比較簡單;可以使用 this 關鍵字直接指向當前執行緒,而無需使用 Thread.currentThread() 來獲取當前執行緒。

使用繼承方式建立執行緒的劣勢:在 Java 中,只允許單繼承(拒絕肛精說使用內部類可以實現多繼承)的原則,所以使用繼承的方式,子類就不能再繼承其他類。

使用 Runnable 介面來建立執行緒

相對的,還可以使用 Runnable 介面來建立執行緒,如下示例

public class TJavaThreadUseImplements implements Runnable{

    static int count;

    @Override
    public synchronized void run() {
        for(int i = 0;i < 10000;i++){
            count++;
        }
    }

    public static void main(String[] args) throws InterruptedException {

        new Thread(new TJavaThreadUseImplements()).start();
        System.out.println("count = " + count);
    }

}

執行緒的主要建立步驟如下

  • 首先定義 Runnable 介面,並重寫 Runnable 介面的 run 方法,run 方法的方法體同樣是該執行緒的執行緒執行體。
  • 建立執行緒例項,可以使用上面程式碼這種簡單的方式建立,也可以通過 new 出執行緒的例項來建立,如下所示
TJavaThreadUseImplements tJavaThreadUseImplements = new TJavaThreadUseImplements();
new Thread(tJavaThreadUseImplements).start();
  • 再呼叫執行緒物件的 start 方法來啟動該執行緒。

執行緒在使用實現 Runnable 的同時也能實現其他介面,非常適合多個相同執行緒來處理同一份資源的情況,體現了物件導向的思想。

使用 Runnable 實現的劣勢是程式設計稍微繁瑣,如果要訪問當前執行緒,則必須使用 Thread.currentThread() 方法。

使用 Callable 介面來建立執行緒

Runnable 介面執行的是獨立的任務,Runnable 介面不會產生任何返回值,如果你希望在任務完成後能夠返回一個值的話,那麼你可以實現 Callable 介面而不是 Runnable 介面。Java SE5 引入了 Callable 介面,它的示例如下

public class CallableTask implements Callable {

    static int count;
    public CallableTask(int count){
        this.count = count;
    }

    @Override
    public Object call() {
        return count;
    }

    public static void main(String[] args) throws ExecutionException, InterruptedException {

        FutureTask<Integer> task = new FutureTask((Callable<Integer>) () -> {
            for(int i = 0;i < 1000;i++){
                count++;
            }
            return count;
        });
        Thread thread = new Thread(task);
        thread.start();

        Integer total = task.get();
        System.out.println("total = " + total);
    }
}

我想,使用 Callable 介面的好處你已經知道了吧,既能夠實現多個介面,也能夠得到執行結果的返回值。Callable 和 Runnable 介面還是有一些區別的,主要區別如下

  • Callable 執行的任務有返回值,而 Runnable 執行的任務沒有返回值
  • Callable(重寫)的方法是 call 方法,而 Runnable(重寫)的方法是 run 方法。
  • call 方法可以丟擲異常,而 Runnable 方法不能丟擲異常

使用執行緒池來建立執行緒

首先先來認識一下頂級介面 Executor,Executor 雖然不是傳統執行緒建立的方式之一,但是它卻成為了建立執行緒的替代者,使用執行緒池的好處如下

  • 利用執行緒池能夠複用執行緒、控制最大併發數。
  • 實現任務執行緒佇列快取策略拒絕機制
  • 實現某些與時間相關的功能,如定時執行、週期執行等。
  • 隔離執行緒環境。比如,交易服務和搜尋服務在同一臺伺服器上,分別開啟兩個執行緒池,交易執行緒的資源消耗明顯要大;因此,通過配置獨立的執行緒池,將較慢的交易服務與搜尋服務隔開,避免個服務執行緒互相影響。

你可以使用如下操作來替換執行緒建立

new Thread(new(RunnableTask())).start()

// 替換為
  
Executor executor = new ExecutorSubClass() // 執行緒池實現類;
executor.execute(new RunnableTask1());
executor.execute(new RunnableTask2());

ExecutorService 是 Executor 的預設實現,也是 Executor 的擴充套件介面,ThreadPoolExecutor 類提供了執行緒池的擴充套件實現。Executors 類為這些 Executor 提供了方便的工廠方法。下面是使用 ExecutorService 建立執行緒的幾種方式

CachedThreadPool

從而簡化了併發程式設計。Executor 在客戶端和任務之間提供了一個間接層;與客戶端直接執行任務不同,這個中介物件將執行任務。Executor 允許你管理非同步任務的執行,而無須顯示地管理執行緒的生命週期。

public static void main(String[] args) {
  ExecutorService service = Executors.newCachedThreadPool();
  for(int i = 0;i < 5;i++){
    service.execute(new TestThread());
  }
  service.shutdown();
}

CachedThreadPool 會為每個任務都建立一個執行緒。

注意:ExecutorService 物件是使用靜態的 Executors 建立的,這個方法可以確定 Executor 型別。對 shutDown 的呼叫可以防止新任務提交給 ExecutorService ,這個執行緒在 Executor 中所有任務完成後退出。

FixedThreadPool

FixedThreadPool 使你可以使用有限的執行緒集來啟動多執行緒

public static void main(String[] args) {
  ExecutorService service = Executors.newFixedThreadPool(5);
  for(int i = 0;i < 5;i++){
    service.execute(new TestThread());
  }
  service.shutdown();
}

有了 FixedThreadPool 使你可以一次性的預先執行高昂的執行緒分配,因此也就可以限制執行緒的數量。這可以節省時間,因為你不必為每個任務都固定的付出建立執行緒的開銷。

SingleThreadExecutor

SingleThreadExecutor 就是執行緒數量為 1 的 FixedThreadPool,如果向 SingleThreadPool 一次性提交了多個任務,那麼這些任務將會排隊,每個任務都會在下一個任務開始前結束,所有的任務都將使用相同的執行緒。SingleThreadPool 會序列化所有提交給他的任務,並會維護它自己(隱藏)的懸掛佇列。

public static void main(String[] args) {
  ExecutorService service = Executors.newSingleThreadExecutor();
  for(int i = 0;i < 5;i++){
    service.execute(new TestThread());
  }
  service.shutdown();
}

從輸出的結果就可以看到,任務都是挨著執行的。我為任務分配了五個執行緒,但是這五個執行緒不像是我們之前看到的有換進換出的效果,它每次都會先執行完自己的那個執行緒,然後餘下的執行緒繼續走完這條執行緒的執行路徑。你可以用 SingleThreadExecutor 來確保任意時刻都只有唯一一個任務在執行。

休眠

影響任務行為的一種簡單方式就是使執行緒 休眠,選定給定的休眠時間,呼叫它的 sleep() 方法, 一般使用的TimeUnit 這個時間類替換 Thread.sleep() 方法,示例如下:

public class SuperclassThread extends TestThread{

    @Override
    public void run() {
        System.out.println(Thread.currentThread() + "starting ..." );

        try {
            for(int i = 0;i < 5;i++){
                if(i == 3){
                    System.out.println(Thread.currentThread() + "sleeping ...");
                    TimeUnit.MILLISECONDS.sleep(1000);
                }
            }
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println(Thread.currentThread() + "wakeup and end ...");
    }

    public static void main(String[] args) {
        ExecutorService executors = Executors.newCachedThreadPool();
        for(int i = 0;i < 5;i++){
            executors.execute(new SuperclassThread());
        }
        executors.shutdown();
    }
}

關於 TimeUnit 中的 sleep() 方法和 Thread.sleep() 方法的比較,請參考下面這篇部落格

(https://www.cnblogs.com/xiadongqing/p/9925567.html)

優先順序

上面提到執行緒排程器對每個執行緒的執行都是不可預知的,隨機執行的,那麼有沒有辦法告訴執行緒排程器哪個任務想要優先被執行呢?你可以通過設定執行緒的優先順序狀態,告訴執行緒排程器哪個執行緒的執行優先順序比較高,請給這個騎手馬上派單,執行緒排程器傾向於讓優先順序較高的執行緒優先執行,然而,這並不意味著優先順序低的執行緒得不到執行,也就是說,優先順序不會導致死鎖的問題。優先順序較低的執行緒只是執行頻率較低。

public class SimplePriorities implements Runnable{

    private int priority;

    public SimplePriorities(int priority) {
        this.priority = priority;
    }

    @Override
    public void run() {
        Thread.currentThread().setPriority(priority);
        for(int i = 0;i < 100;i++){
            System.out.println(this);
            if(i % 10 == 0){
                Thread.yield();
            }
        }
    }

    @Override
    public String toString() {
        return Thread.currentThread() + " " + priority;
    }

    public static void main(String[] args) {
        ExecutorService service = Executors.newCachedThreadPool();
        for(int i = 0;i < 5;i++){
            service.execute(new SimplePriorities(Thread.MAX_PRIORITY));
        }
        service.execute(new SimplePriorities(Thread.MIN_PRIORITY));
    }
}

toString() 方法被覆蓋,以便通過使用 Thread.toString() 方法來列印執行緒的名稱。你可以改寫執行緒的預設輸出,這裡採用了 Thread[pool-1-thread-1,10,main] 這種形式的輸出。

通過輸出,你可以看到,最後一個執行緒的優先順序最低,其餘的執行緒優先順序最高。注意,優先順序是在 run 開頭設定的,在構造器中設定它們不會有任何好處,因為這個時候執行緒還沒有執行任務。

儘管 JDK 有 10 個優先順序,但是一般只有MAX_PRIORITY,NORM_PRIORITY,MIN_PRIORITY 三種級別。

作出讓步

我們上面提過,如果知道一個執行緒已經在 run() 方法中執行的差不多了,那麼它就可以給執行緒排程器一個提示:我已經完成了任務中最重要的部分,可以讓給別的執行緒使用 CPU 了。這個暗示將通過 yield() 方法作出。

有一個很重要的點就是,Thread.yield() 是建議執行切換CPU,而不是強制執行CPU切換。

對於任何重要的控制或者在呼叫應用時,都不能依賴於 yield() 方法,實際上, yield() 方法經常被濫用。

後臺執行緒

後臺(daemon) 執行緒,是指執行時在後臺提供的一種服務執行緒,這種執行緒不是屬於必須的。當所有非後臺執行緒結束時,程式也就停止了,同時會終止所有的後臺執行緒。反過來說,只要有任何非後臺執行緒還在執行,程式就不會終止。

public class SimpleDaemons implements Runnable{

    @Override
    public void run() {
        while (true){
            try {
                TimeUnit.MILLISECONDS.sleep(100);
                System.out.println(Thread.currentThread() + " " + this);
            } catch (InterruptedException e) {
                System.out.println("sleep() interrupted");
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        for(int i = 0;i < 10;i++){
            Thread daemon = new Thread(new SimpleDaemons());
            daemon.setDaemon(true);
            daemon.start();
        }
        System.out.println("All Daemons started");
        TimeUnit.MILLISECONDS.sleep(175);
    }
}

在每次的迴圈中會建立 10 個執行緒,並把每個執行緒設定為後臺執行緒,然後開始執行,for 迴圈會進行十次,然後輸出資訊,隨後主執行緒睡眠一段時間後停止執行。在每次 run 迴圈中,都會列印當前執行緒的資訊,主執行緒執行完畢,程式就執行完畢了。因為 daemon 是後臺執行緒,無法影響主執行緒的執行。

但是當你把 daemon.setDaemon(true) 去掉時,while(true) 會進行無限迴圈,那麼主執行緒一直在執行最重要的任務,所以會一直迴圈下去無法停止。

ThreadFactory

按需要建立執行緒的物件。使用執行緒工廠替換了 Thread 或者 Runnable 介面的硬連線,使程式能夠使用特殊的執行緒子類,優先順序等。一般的建立方式為

class SimpleThreadFactory implements ThreadFactory {
  public Thread newThread(Runnable r) {
    return new Thread(r);
  }
}

Executors.defaultThreadFactory 方法提供了一個更有用的簡單實現,它在返回之前將建立的執行緒上下文設定為已知值

ThreadFactory 是一個介面,它只有一個方法就是建立執行緒的方法

public interface ThreadFactory {

    // 構建一個新的執行緒。實現類可能初始化優先順序,名稱,後臺執行緒狀態和 執行緒組等
    Thread newThread(Runnable r);
}

下面來看一個 ThreadFactory 的例子

public class DaemonThreadFactory implements ThreadFactory {

    @Override
    public Thread newThread(Runnable r) {
        Thread t = new Thread(r);
        t.setDaemon(true);
        return t;
    }
}

public class DaemonFromFactory implements Runnable{

    @Override
    public void run() {
        while (true){
            try {
                TimeUnit.MILLISECONDS.sleep(100);
                System.out.println(Thread.currentThread() + " " + this);
            } catch (InterruptedException e) {
                System.out.println("Interrupted");
            }
        }
    }

    public static void main(String[] args) throws InterruptedException {
        ExecutorService service = Executors.newCachedThreadPool(new DaemonThreadFactory());
        for(int i = 0;i < 10;i++){
            service.execute(new DaemonFromFactory());
        }
        System.out.println("All daemons started");
        TimeUnit.MILLISECONDS.sleep(500);
    }
}

Executors.newCachedThreadPool 可以接受一個執行緒池物件,建立一個根據需要建立新執行緒的執行緒池,但會在它們可用時重用先前構造的執行緒,並在需要時使用提供的 ThreadFactory 建立新執行緒。

public static ExecutorService newCachedThreadPool(ThreadFactory threadFactory) {
  return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
                                60L, TimeUnit.SECONDS,
                                new SynchronousQueue<Runnable>(),
                                threadFactory);
}

加入一個執行緒

一個執行緒可以在其他執行緒上呼叫 join() 方法,其效果是等待一段時間直到第二個執行緒結束才正常執行。如果某個執行緒在另一個執行緒 t 上呼叫 t.join() 方法,此執行緒將被掛起,直到目標執行緒 t 結束才回復(可以用 t.isAlive() 返回為真假判斷)。

也可以在呼叫 join 時帶上一個超時引數,來設定到期時間,時間到期,join方法自動返回。

對 join 的呼叫也可以被中斷,做法是線上程上呼叫 interrupted 方法,這時需要用到 try...catch 子句

public class TestJoinMethod extends Thread{

    @Override
    public void run() {
        for(int i = 0;i < 5;i++){
            try {
                TimeUnit.MILLISECONDS.sleep(1000);
            } catch (InterruptedException e) {
                System.out.println("Interrupted sleep");
            }
            System.out.println(Thread.currentThread() + " " + i);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        TestJoinMethod join1 = new TestJoinMethod();
        TestJoinMethod join2 = new TestJoinMethod();
        TestJoinMethod join3 = new TestJoinMethod();

        join1.start();
//        join1.join();

        join2.start();
        join3.start();
    }
}

join() 方法等待執行緒死亡。 換句話說,它會導致當前執行的執行緒停止執行,直到它加入的執行緒完成其任務。

執行緒異常捕獲

由於執行緒的本質,使你不能捕獲從執行緒中逃逸的異常,一旦異常逃出任務的 run 方法,它就會向外傳播到控制檯,除非你採取特殊的步驟捕獲這種錯誤的異常,在 Java5 之前,你可以通過執行緒組來捕獲,但是在 Java 5 之後,就需要用 Executor 來解決問題,因為執行緒組不是一次好的嘗試。

下面的任務會在 run 方法的執行期間丟擲一個異常,並且這個異常會拋到 run 方法的外面,而且 main 方法無法對它進行捕獲

public class ExceptionThread implements Runnable{

    @Override
    public void run() {
        throw new RuntimeException();
    }

    public static void main(String[] args) {
        try {
            ExecutorService service = Executors.newCachedThreadPool();
            service.execute(new ExceptionThread());
        }catch (Exception e){
            System.out.println("eeeee");
        }
    }
}

為了解決這個問題,我們需要修改 Executor 產生執行緒的方式,Java5 提供了一個新的介面 Thread.UncaughtExceptionHandler ,它允許你在每個 Thread 上都附著一個異常處理器。Thread.UncaughtExceptionHandler.uncaughtException() 會線上程因未捕獲臨近死亡時被呼叫。

public class ExceptionThread2 implements Runnable{

    @Override
    public void run() {
        Thread t = Thread.currentThread();
        System.out.println("run() by " + t);
        System.out.println("eh = " + t.getUncaughtExceptionHandler());
      
      	// 手動丟擲異常
        throw new RuntimeException();
    }
}

// 實現Thread.UncaughtExceptionHandler 介面,建立異常處理器
public class MyUncaughtExceptionHandler implements Thread.UncaughtExceptionHandler{

    @Override
    public void uncaughtException(Thread t, Throwable e) {
        System.out.println("caught " + e);
    }
}

public class HandlerThreadFactory implements ThreadFactory {

    @Override
    public Thread newThread(Runnable r) {
        System.out.println(this + " creating new Thread");
        Thread t = new Thread(r);
        System.out.println("created " + t);
        t.setUncaughtExceptionHandler(new MyUncaughtExceptionHandler());
        System.out.println("ex = " + t.getUncaughtExceptionHandler());
        return t;
    }
}

public class CaptureUncaughtException {

    public static void main(String[] args) {
        ExecutorService service = Executors.newCachedThreadPool(new HandlerThreadFactory());
        service.execute(new ExceptionThread2());
    }
}

在程式中新增了額外的追蹤機制,用來驗證工廠建立的執行緒會傳遞給UncaughtExceptionHandler,你可以看到,未捕獲的異常是通過 uncaughtException 來捕獲的。

你好,我是 cxuan,我自己手寫了四本 PDF,分別是 Java基礎總結、HTTP 核心總結、計算機基礎知識,作業系統核心總結,我已經整理成為 PDF,可以關注公眾號 Java建設者 回覆 PDF 領取優質資料。

相關文章