Java 程式設計要點之併發(Concurrency)詳解

waylau發表於2016-09-21

計算機使用者想當然地認為他們的系統在一個時間可以做多件事。他們認為,他們可以工作在一個字處理器,而其他應用程式在下載檔案,管理列印佇列和音訊流。即使是單一的應用程式通常也是被期望在一個時間來做多件事。例如,音訊流應用程式必須同時讀取數字音訊,解壓,管理播放,並更新顯示。即使字處理器應該隨時準備響應鍵盤和滑鼠事件,不管多麼繁忙,它總是能格式化文字或更新顯示。可以做這樣的事情的軟體稱為併發軟體(concurrent software)。

在 Java 平臺是完全支援併發程式設計。自從 5.0 版本以來,這個平臺還包括高階併發 API, 主要集中在 java.util.concurrent 包。

程式(Processes )和執行緒(Threads)

程式和執行緒是併發程式設計的兩個基本的執行單元。在 Java 中,併發程式設計主要涉及執行緒。

一個計算機系統通常有許多活動的程式和執行緒。在給定的時間內,每個處理器只能有一個執行緒得到真正的執行。對於單核處理器來說,處理時間是通過時間切片來在程式和執行緒之間進行共享的。

現在多核處理器或多程式的電腦系統越來越流行。這大大增強了系統的程式和執行緒的併發執行能力。但即便是沒有多處理器或多程式的系統中,併發仍然是可能的。

程式

程式有一個獨立的執行環境。程式通常有一個完整的、私人的基本執行時資源;特別是,每個程式都有其自己的記憶體空間。

程式往往被視為等同於程式或應用程式。然而,使用者將看到一個單獨的應用程式可能實際上是一組合作的程式。大多數作業系統都支援程式間通訊( Inter Process Communication,簡稱 IPC)資源,如管道和套接字。IPC 不僅用於同個系統的程式之間的通訊,也可以用在不同系統的程式。

大多數 Java 虛擬機器的實現作為一個程式執行。Java 應用程式可以使用 ProcessBuilder 物件建立額外的程式。多程式應用程式超出了本書的講解範圍。

執行緒

執行緒有時被稱為輕量級程式。程式和執行緒都提供一個執行環境,但建立一個新的執行緒比建立一個新的程式需要更少的資源。

執行緒中存在於程式中,每個程式都至少一個執行緒。執行緒共享程式的資源,包括記憶體和開啟的檔案。這使得工作變得高效,但也存在了一個潛在的問題——通訊。

多執行緒執行是 Java 平臺的一個重要特點。每個應用程式都至少有一個執行緒,或者幾個,如果算上“系統”的執行緒(負責記憶體管理和訊號處理)那就更多。但從程式設計師的角度來看,你啟動只有一個執行緒,稱為主執行緒。這個執行緒有能力建立額外的執行緒。

執行緒物件

每個執行緒都與 Thread 類的一個例項相關聯。有兩種使用執行緒物件來建立併發應用程式的基本策略:

  • 為了直接控制執行緒的建立和管理,簡單地初始化執行緒,應用程式每次需要啟動一個非同步任務。
  • 通過傳遞給應用程式任務給一個 Executor,從而從應用程式的其他部分抽象出執行緒管理。

定義和啟動一個執行緒

有兩種方式穿件 Thread 的例項:

  • 提供 Runnable 物件。Runnable 介面定義了一個方法 run ,用來包含執行緒要執行的程式碼。如 HelloRunnable 所示:
    public class HelloRunnable implements Runnable {
        /* (non-Javadoc)
         * @see java.lang.Runnable#run()
         */
        @Override
        public void run() {
            System.out.println("Hello from a thread!");
        }
    
        /**
         * @param args
         */
        public static void main(String[] args) {
            (new Thread(new HelloRunnable())).start();
        }
    }
  • 繼承 Thread。Thread 類本身是實現 Runnable,雖然它的 run 方法啥都沒幹。HelloThread 示例如下:
    public class HelloThread extends Thread {
    
        public void run() {
            System.out.println("Hello from a thread!");
        }
        /**
         * @param args
         */
        public static void main(String[] args) {
            (new HelloThread()).start();
        }
    }

請注意,這兩個例子呼叫 start 來啟動執行緒。

第一種方式,它使用 Runnable 物件,在實際應用中更普遍,因為 Runnable 物件可以繼承 Thread 以外的類。第二種方式,在簡單的應用程式更容易使用,但受限於你的任務類必須是一個 Thread 的後代。本書推薦使用第一種方法,將 Runnable 任務從 Thread 物件分離來執行任務。這不僅更靈活,而且它適用於高階執行緒管理 API。

Thread 類定義了大量的方法用於執行緒管理。

Sleep 來暫停執行

Thread.sleep 可以當前執行緒執行暫停一個時間段,這樣處理器時間就可以給其他執行緒使用。

sleep 有兩種過載形式:一個是指定睡眠時間到毫秒,另外一個是指定的睡眠時間為納秒級。然而,這些睡眠時間不能保證是精確的,因為它們是通過由基礎 OS 提供的,並受其限制。此外,睡眠週期也可以通過中斷終止,我們將在後面的章節中看到。在任何情況下,你不能假設呼叫 sleep 會掛起執行緒用於指定精確的時間段。

SleepMessages 示例使用 sleep 每隔4秒列印一次訊息:

public class SleepMessages {

    /**
     * @param args
     */
    public static void main(String[] args) throws InterruptedException {
        String importantInfo[] = { "Mares eat oats", "Does eat oats", "Little lambs eat ivy",
                "A kid will eat ivy too" };

        for (int i = 0; i < importantInfo.length; i++) {
            // Pause for 4 seconds
            Thread.sleep(4000);
            // Print a message
            System.out.println(importantInfo[i]);
        }
    }
}

請注意 main 宣告丟擲 InterruptedException。當 sleep 是啟用的時候,若有另一個執行緒中斷當前執行緒時,則 sleep 丟擲異常。由於該應用程式還沒有定義的另一個執行緒來引起的中斷,所以考慮捕捉 InterruptedException。

中斷(interrupt)

中斷是表明一個執行緒,它應該停止它正在做和將要做事的時。執行緒通過在 Thread 物件呼叫 interrupt 來實現執行緒的中斷。為了中斷機制能正常工作,被中斷的執行緒必須支援自己的中斷。

支援中斷

如何實現執行緒支援自己的中斷?這要看是什麼它目前正在做。如果執行緒頻繁呼叫丟擲InterruptedException 的方法,它只要在 run 方法捕獲了異常之後返回即可。例如 :

for (int i = 0; i < importantInfo.length; i++) {
    // Pause for 4 seconds
    try {
        Thread.sleep(4000);
    } catch (InterruptedException e) {
        // We've been interrupted: no more messages.
        return;
    }
    // Print a message
    System.out.println(importantInfo[i]);
}

很多方法都會丟擲 InterruptedException,如 sleep,被設計成在收到中斷時立即取消他們當前的操作並返回。

若執行緒長時間沒有呼叫方法丟擲 InterruptedException 的話,那麼它必須定期呼叫 Thread.interrupted ,在接收到中斷後返回 true。

for (int i = 0; i < inputs.length; i++) {
    heavyCrunch(inputs[i]);
    if (Thread.interrupted()) {
        // We've been interrupted: no more crunching.
        return;
    }
}

在這個簡單的例子中,程式碼簡單地測試該中斷,如果已接收到中斷執行緒就退出。在更復雜的應用程式,它可能會更有意義丟擲一個 InterruptedException:

if (Thread.interrupted()) {
    throw new InterruptedException();
}

中斷狀態標誌

中斷機制是使用被稱為中斷狀態的內部標誌實現的。呼叫 Thread.interrupt 可以設定該標誌。當一個執行緒通過呼叫靜態方法 Thread.interrupted 檢查中斷,中斷狀態被清除。非靜態 isInterrupted 方法,它是用於執行緒來查詢另一個執行緒的中斷狀態,不會改變中斷狀態標誌。

按照慣例,任何方法因丟擲一個 InterruptedException 退出都會清除中斷狀態。當然,它可能因為另一個執行緒呼叫 interrupt 而讓那個中斷狀態立即被重新設定。

join 方法

join 方法允許一個執行緒等待另一個完成。假設 t 是一個 Thread 物件,

t.join();

它會導致當前執行緒暫停執行直到 t 執行緒終止。join 允許程式設計師指定一個等待週期。與 sleep 一樣,等待時間是依賴於作業系統的時間,不能假設 join 等待時間是精確的。

像 sleep 一樣,join 響應中斷並通過 InterruptedException 退出。

SimpleThreads 示例

SimpleThreads 示例,有兩個執行緒,第一個執行緒是每個 Java 應用程式都有主執行緒。主執行緒建立的 Runnable 物件 MessageLoop,並等待它完成。如果 MessageLoop 需要很長時間才能完成,主執行緒就中斷它。

該 MessageLoop 執行緒列印出一系列訊息。如果中斷之前就已經列印了所有訊息,則 MessageLoop 執行緒列印一條訊息並退出。

public class SimpleThreads {
      // Display a message, preceded by
    // the name of the current thread
    static void threadMessage(String message) {
        String threadName =
            Thread.currentThread().getName();
        System.out.format("%s: %s%n",
                          threadName,
                          message);
    }

    private static class MessageLoop
        implements Runnable {
        public void run() {
            String importantInfo[] = {
                "Mares eat oats",
                "Does eat oats",
                "Little lambs eat ivy",
                "A kid will eat ivy too"
            };
            try {
                for (int i = 0;
                     i < importantInfo.length;
                     i++) {
                    // Pause for 4 seconds
                    Thread.sleep(4000);
                    // Print a message
                    threadMessage(importantInfo[i]);
                }
            } catch (InterruptedException e) {
                threadMessage("I wasn't done!");
            }
        }
    }

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

        // Delay, in milliseconds before
        // we interrupt MessageLoop
        // thread (default one hour).
        long patience = 1000 * 60 * 60;

        // If command line argument
        // present, gives patience
        // in seconds.
        if (args.length > 0) {
            try {
                patience = Long.parseLong(args[0]) * 1000;
            } catch (NumberFormatException e) {
                System.err.println("Argument must be an integer.");
                System.exit(1);
            }
        }

        threadMessage("Starting MessageLoop thread");
        long startTime = System.currentTimeMillis();
        Thread t = new Thread(new MessageLoop());
        t.start();

        threadMessage("Waiting for MessageLoop thread to finish");
        // loop until MessageLoop
        // thread exits
        while (t.isAlive()) {
            threadMessage("Still waiting...");
            // Wait maximum of 1 second
            // for MessageLoop thread
            // to finish.
            t.join(1000);
            if (((System.currentTimeMillis() - startTime) > patience)
                  && t.isAlive()) {
                threadMessage("Tired of waiting!");
                t.interrupt();
                // Shouldn't be long now
                // -- wait indefinitely
                t.join();
            }
        }
        threadMessage("Finally!");
    }
}

同步(Synchronization)

執行緒間的通訊主要是通過共享訪問欄位以及其欄位所引用的物件來實現的。這種形式的通訊是非常有效的,但可能導致2種可能的錯誤:執行緒干擾(thread interference)和記憶體一致性錯誤(memory consistency errors)。同步就是要需要避免這些錯誤的工具。

但是,同步可以引入執行緒競爭(thread contention),當兩個或多個執行緒試圖同時訪問相同的資源時,並導致了 Java 執行時執行一個或多個執行緒更慢,或甚至暫停他們的執行。飢餓(Starvation)和活鎖 (livelock) 是執行緒競爭的表現形式。

執行緒干擾

描述當多個執行緒訪問共享資料時是錯誤如何出現。

考慮下面的一個簡單的類 Counter:

public class Counter {
    private int c = 0;

    public void increment() {
        c++;
    }

    public void decrement() {
        c--;
    }

    public int value() {
        return c;
    }
}

其中的 increment 方法用來對 c 加1;decrement 方法用來對 c 減 1。然而,有多個執行緒中都存在對某個 Counter 物件的引用,那麼執行緒間的干擾就可能導致出現我們不想要的結果。

執行緒間的干擾出現在多個執行緒對同一個資料進行多個操作的時候,也就是出現了“交錯”。這就意味著操作是由多個步驟構成的,而此時,在這多個步驟的執行上出現了疊加。

Counter類物件的操作貌似不可能出現這種“交錯(interleave)”,因為其中的兩個關於c 的操作都很簡單,只有一條語句。然而,即使是一條語句也是會被虛擬機器翻譯成多個步驟的。在這裡,我們不深究虛擬機器具體上上面的操作翻譯成了什麼樣的步驟。只需要知道即使簡單的 c++ 這樣的表示式也是會被翻譯成三個步驟的:

  1. 獲取 c 的當前值。
  2. 對其當前值加 1。
  3. 將增加後的值儲存到 c 中。

表示式 c– 也是會被按照同樣的方式進行翻譯,只不過第二步變成了減1,而不是加1。

假定執行緒 A 中呼叫 increment 方法,執行緒 B 中呼叫 decrement 方法,而呼叫時間基本上相同。如果 c 的初始值為 0,那麼這兩個操作的“交錯”順序可能如下:

  1. 執行緒A:獲取 c 的值。
  2. 執行緒B:獲取 c 的值。
  3. 執行緒A:對獲取到的值加1;其結果是1。
  4. 執行緒B:對獲取到的值減1;其結果是-1。
  5. 執行緒A:將結果儲存到 c 中;此時c的值是1。
  6. 執行緒B:將結果儲存到 c 中;此時c的值是-1。

這樣執行緒 A 計算的值就丟失了,也就是被執行緒 B 的值覆蓋了。上面的這種“交錯”只是其中的一種可能性。在不同的系統環境中,有可能是 B 執行緒的結果丟失了,或者是根本就不會出現錯誤。由於這種“交錯”是不可預測的,執行緒間相互干擾造成的 bug 是很難定位和修改的。

記憶體一致性錯誤

介紹了通過共享記憶體出現的不一致的錯誤。

記憶體一致性錯誤(Memory consistency errors)發生在不同執行緒對同一資料產生不同的“看法”。導致記憶體一致性錯誤的原因很複雜,超出了本書的描述範圍。慶幸的是,程式設計師並不需要知道出現這些原因的細節。我們需要的是一種可以避免這種錯誤的方法。

避免出現記憶體一致性錯誤的關鍵在於理解 happens-before 關係。這種關係是一種簡單的方法,能夠確保一條語句對記憶體的寫操作對於其它特定的語句都是可見的。為了理解這點,我們可以考慮如下的示例。假定定義了一個簡單的 int 型別的欄位並對其進行了初始化:

int counter = 0;

該欄位由兩個執行緒共享:A 和 B。假定執行緒 A 對 counter 進行了自增操作:

counter++;

然後,執行緒 B 列印 counter 的值:

System.out.println(counter);

如果以上兩條語句是在同一個執行緒中執行的,那麼輸出的結果自然是1。但是如果這兩條語句是在兩個不同的執行緒中,那麼輸出的結構有可能是0。這是因為沒有保證執行緒 A 對 counter 的修改對執行緒 B 來說是可見的。除非程式設計師在這兩條語句間建立了一定的 happens-before 關係。

我們可以採取多種方式建立這種 happens-before 關係。使用同步就是其中之一,這點我們將會在下面的小節中看到。

到目前為止,我們已經看到了兩種建立這種 happens-before 的方式:

  • 當一條語句中呼叫了 Thread.start 方法,那麼每一條和該語句已經建立了 happens-before 的語句都和新執行緒中的每一條語句有著這種 happens-before。引入並建立這個新執行緒的程式碼產生的結果對該新執行緒來說都是可見的。
  • 當一個執行緒終止了並導致另外的執行緒中呼叫 Thread.join 的語句返回,那麼此時這個終止了的執行緒中執行了的所有語句都與隨後的 join 語句隨後的所有語句建立了這種 happens-before 。也就是說終止了的執行緒中的程式碼效果對呼叫 join 方法的執行緒來說是可見。

關於哪些操作可以建立這種 happens-before,更多的資訊請參閱“java.util.concurrent 包的概要說明”。

同步方法

描述了一個簡單的做法,可以有效防止執行緒干擾和記憶體一致性錯誤。

Java 程式語言中提供了兩種基本的同步用語:同步方法(synchronized methods)和同步語句(synchronized statements)。同步語句相對而言更為複雜一些,我們將在下一小節中進行描述。本節重點討論同步方法。

我們只需要在宣告方法的時候增加關鍵字 synchronized 即可:

public class SynchronizedCounter {
    private int c = 0;

    public synchronized void increment() {
        c++;
    }

    public synchronized void decrement() {
        c--;
    }

    public synchronized int value() {
        return c;
    }
}

如果 count 是 SynchronizedCounter 類的例項,設定其方法為同步方法將有兩個效果:

  • 首先,不可能出現對同一物件的同步方法的兩個呼叫的“交錯”。當一個執行緒在執行一個物件的同步方式的時候,其他所有的呼叫該物件的同步方法的執行緒都會被掛起,直到第一個執行緒對該物件操作完畢。
  • 其次,當一個同步方法退出時,會自動與該物件的同步方法的後續呼叫建立 happens-before 關係。這就確保了對該物件的修改對其他執行緒是可見的。

注意:建構函式不能是 synchronized ——在建構函式前使用 synchronized 關鍵字將導致語義錯誤。同步建構函式是沒有意義的。這是因為只有建立該物件的執行緒才能呼叫其建構函式。

警告:在建立多個執行緒共享的物件時,要特別小心對該物件的引用不能過早地“洩露”。例如,假定我們想要維護一個儲存類的所有例項的列表 instances。我們可能會在建構函式中這樣寫到:

instances.add(this);

但是,其他執行緒可會在該物件的構造完成之前就訪問該物件。

同步方法是一種簡單的可以避免執行緒相互干擾和記憶體一致性錯誤的策略:如果一個物件對多個執行緒都是可見的,那麼所有對該物件的變數的讀寫都應該是通過同步方法完成的(一個例外就是 final 欄位,他在物件建立完成後是不能被修改的,因此,在物件建立完畢後,可以通過非同步的方法對其進行安全的讀取)。這種策略是有效的,但是可能導致“活躍度(liveness)”問題。這點我們會在本課程的後面進行描述。

內部鎖和同步

描述了一個更通用的同步方法,並介紹了同步是如何基於內部鎖的。

同步是構建在被稱為“內部鎖(intrinsic lock)”或者是“監視鎖(monitor lock)”的內部實體上的。(在 API 中通常被稱為是“監視器(monitor)”。)內部鎖在兩個方面都扮演著重要的角色:保證對物件狀態訪問的排他性和建立也物件可見性相關的重要的“ happens-before。

每一個物件都有一個與之相關聯動的內部鎖。按照傳統的做法,當一個執行緒需要對一個物件的欄位進行排他性訪問並保持訪問的一致性時,他必須在訪問前先獲取該物件的內部鎖,然後才能訪問之,最後釋放該內部鎖。線上程獲取物件的內部鎖到釋放物件的內部鎖的這段時間,我們說該執行緒擁有該物件的內部鎖。只要有一個執行緒已經擁有了一個內部鎖,其他執行緒就不能再擁有該鎖了。其他執行緒將會在試圖獲取該鎖的時候被阻塞了。

當一個執行緒釋放了一個內部鎖,那麼就會建立起該動作和後續獲取該鎖之間的 happens-before 關係。

同步方法中的鎖

當一個執行緒呼叫一個同步方法的時候,他就自動地獲得了該方法所屬物件的內部鎖,並在方法返回的時候釋放該鎖。即使是由於出現了沒有被捕獲的異常而導致方法返回,該鎖也會被釋放。

我們可能會感到疑惑:當呼叫一個靜態的同步方法的時候會怎樣了,靜態方法是和類相關的,而不是和物件相關的。在這種情況下,執行緒獲取的是該類的類物件的內部鎖。這樣對於靜態欄位的方法是通過一個和類的例項的鎖相區分的另外的鎖來進行的。

同步語句

另外一種建立同步程式碼的方式就是使用同步語句。和同步方法不同,使用同步語句是必須指明是要使用哪個物件的內部鎖:

public void addName(String name) {
    synchronized(this) {
        lastName = name;
        nameCount++;
    }
    nameList.add(name);
}

在上面的示例中,方法 addName 需要對 lastName 和 nameCount 的修改進行同步,還要避免同步呼叫其他物件的方法(在同步程式碼段中呼叫其他物件的方法可能導致“活躍度(Liveness)”中描述的問題)。如果沒有使用同步語句,那麼將不得不使用一個單獨的,未同步的方法來完成對 nameList.add 的呼叫。

在改善併發性時,巧妙地使用同步語句能起到很大的幫助作用。例如,我們假定類 MsLunch 有兩個例項欄位,c1 和 c2,這兩個變數絕不會一起使用。所有對這兩個變數的更新都需要進行同步。但是沒有理由阻止對 c1 的更新和對 c2 的更新出現交錯——這樣做會建立不必要的阻塞,進而降低併發性。此時,我們沒有使用同步方法或者使用和this 相關的鎖,而是建立了兩個單獨的物件來提供鎖。

public class MsLunch {
    private long c1 = 0;
    private long c2 = 0;
    private Object lock1 = new Object();
    private Object lock2 = new Object();

    public void inc1() {
        synchronized(lock1) {
            c1++;
        }
    }

    public void inc2() {
        synchronized(lock2) {
            c2++;
        }
    }
}

採用這種方式時需要特別的小心。我們必須絕對確保相關欄位的訪問交錯是完全安全的。

重入同步(Reentrant Synchronization)

回憶前面提到的:執行緒不能獲取已經被別的執行緒獲取的鎖。但是執行緒可以獲取自身已經擁有的鎖。允許一個執行緒能重複獲得同一個鎖就稱為重入同步(reentrant synchronization)。它是這樣的一種情況:在同步程式碼中直接或者間接地呼叫了還有同步程式碼的方法,兩個同步程式碼段中使用的是同一個鎖。如果沒有重入同步,在編寫同步程式碼時需要額外的小心,以避免執行緒將自己阻塞。

原子訪問

介紹了不會被其他執行緒干擾的做法的總體思路。

在程式設計中,原子性動作就是指一次性有效完成的動作。原子性動作是不能在中間停止的:要麼一次性完全執行完畢,要麼就不執行。在動作沒有執行完畢之前,是不會產生可見結果的。

通過前面的示例,我們已經發現了諸如 c++ 這樣的自增表示式並不屬於原子操作。即使是非常簡單的表示式也包含了複雜的動作,這些動作可以被解釋成許多別的動作。然而,的確存在一些原子操作的:

  • 對幾乎所有的原生資料型別變數(除了 long he double)的讀寫以及引用變數的讀寫都是原子的。
  • 對所有宣告為 Volatile 的變數的讀寫都是原子的,包括 long 和 double 型別。

原子性動作是不會出現交錯的,因此,使用這些原子性動作時不用考慮執行緒間的干擾。然而,這並不意味著可以移除對原子操作的同步。因為記憶體一致性錯誤還是有可能出現的。使用 volatile 變數可以減少記憶體一致性錯誤的風險,因為任何對 volatile 變 量的寫操作都和後續對該變數的讀操作建立了 happens-before 關係。這就意味著對 volatile 型別變數的修改對於別的執行緒來說是可見的。更重要的是,這意味著當一個執行緒讀取一個 volatile 型別的變數時,他看到的不僅僅是對該變數的最後一次修改,還看到了導致這種修改的程式碼帶來的其他影響。

使用簡單的原子變數訪問比通過同步程式碼來訪問變數更高效,但是需要程式設計師的更多細心考慮,以避免記憶體一致性錯誤。這種額外的付出是否值得完全取決於應用程式的大小和複雜度。

活躍度(Liveness)

一個並行應用程式的及時執行能力被稱為它的活躍度(liveness)。本節將介紹最常見的一種活躍度的問題——死鎖,以及另外兩個活躍度的問題——飢餓和活鎖。

死鎖(Deadlock)

死鎖是指兩個或兩個以上的執行緒永遠被阻塞,一直等待對方的資源。

下面是一個例子。

Alphonse 和 Gaston 是朋友,都很有禮貌。禮貌的一個嚴格的規則是,當你給一個朋友鞠躬時,你必須保持鞠躬,直到你的朋友鞠躬回給你。不幸的是,這條規則有個缺陷,那就是如果兩個朋友同一時間向對方鞠躬,那就永遠不會完了。這個示例應用程式中,死鎖模型是這樣的:

public class Deadlock {
    static class Friend {
        private final String name;

        public Friend(String name) {
            this.name = name;
        }

        public String getName() {
            return this.name;
        }

        public synchronized void bow(Friend bower) {
            System.out.format("%s: %s" + "  has bowed to me!%n", this.name, bower.getName());
            bower.bowBack(this);
        }

        public synchronized void bowBack(Friend bower) {
            System.out.format("%s: %s" + " has bowed back to me!%n", this.name, bower.getName());
        }
    }

    public static void main(String[] args) {
        final Friend alphonse = new Friend("Alphonse");
        final Friend gaston = new Friend("Gaston");
        new Thread(new Runnable() {
            public void run() {
                alphonse.bow(gaston);
            }
        }).start();
        new Thread(new Runnable() {
            public void run() {
                gaston.bow(alphonse);
            }
        }).start();
    }
}

當他們嘗試呼叫 bowBack 兩個執行緒將被阻塞。無論是哪個執行緒永遠不會結束,因為每個執行緒都在等待對方鞠躬。這就是死鎖了。

飢餓和活鎖(Starvation and Livelock)

飢餓和活鎖雖比死鎖問題稍微不常見點,但這些是在併發軟體種每一個設計師仍然可能會遇到的問題。

飢餓(Starvation)

飢餓描述了這樣一個情況,一個執行緒不能獲得定期訪問共享資源,於是無法繼續執行。這種情況一般出現在共享資源被某些“貪婪”執行緒佔用,而導致資源長時間不被其他執行緒可用。例如,假設一個物件提供一個同步的方法,往往需要很長時間返回。如果一個執行緒頻繁呼叫該方法,其他執行緒若也需要頻繁的同步訪問同一個物件通常會被阻塞。

活鎖(Livelock)

一個執行緒常常處於響應另一個執行緒的動作,如果其他執行緒也常常處於該執行緒的動作,那麼就可能出現活鎖。與死鎖、活鎖的執行緒一樣,程式無法進一步執行。然而,執行緒是不會阻塞的,他們只是會忙於應對彼此的恢復工作。現實種的例子是,兩人面對面試圖通過一條走廊: Alphonse 移動到他的左則讓路給 Gaston ,而 Gaston 移動到他的右側想讓 Alphonse 過去,兩個人同時讓路,但其實兩人都擋住了對方沒辦法過去,他們仍然彼此阻塞。

Guarded Blocks

多執行緒之間經常需要協同工作,最常見的方式是使用 Guarded Blocks,它迴圈檢查一個條件(通常初始值為 true),直到條件發生變化才跳出迴圈繼續執行。在使用 Guarded Blocks 時有以下幾個步驟需要注意:

假設 guardedJoy 方法必須要等待另一執行緒為共享變數 joy 設值才能繼續執行。那麼理論上可以用一個簡單的條件迴圈來實現,但在等待過程中 guardedJoy 方法不停的檢查迴圈條件實際上是一種資源浪費。

public void guardedJoy() {
    // Simple loop guard. Wastes
    // processor time. Don't do this!
    while(!joy) {}
    System.out.println("Joy has been achieved!");
}

更加高效的保護方法是呼叫 Object.wait 將當前執行緒掛起,直到有另一執行緒發起事件通知(儘管通知的事件不一定是當前執行緒等待的事件)。

public synchronized void guardedJoy() {
    // This guard only loops once for each special event, which may not
    // be the event we're waiting for.
    while(!joy) {
        try {
            wait();
        } catch (InterruptedException e) {}
    }
    System.out.println("Joy and efficiency have been achieved!");
}

注意:一定要在迴圈裡面呼叫 wait 方法,不要想當然的認為執行緒喚醒後迴圈條件一定發生了改變。

和其他可以暫停執行緒執行的方法一樣,wait 方法會丟擲 InterruptedException,在上面的例子中,因為我們關心的是 joy 的值,所以忽略了 InterruptedException。

為什麼 guardedJoy 是 synchronized 的?假設 d 是用來呼叫 wait 的物件,當一個執行緒呼叫 d.wait,它必須要擁有 d的內部鎖(否則會丟擲異常),獲得 d 的內部鎖的最簡單方法是在一個 synchronized 方法裡面呼叫 wait。

當一個執行緒呼叫 wait 方法時,它釋放鎖並掛起。然後另一個執行緒請求並獲得這個鎖並呼叫 Object.notifyAll 通知所有等待該鎖的執行緒。

public synchronized notifyJoy() {
    joy = true;
    notifyAll();
}

當第二個執行緒釋放這個該鎖後,第一個執行緒再次請求該鎖,從 wait 方法返回並繼續執行。

注意:還有另外一個通知方法,notify(),它只會喚醒一個執行緒。但由於它並不允許指定哪一個執行緒被喚醒,所以一般只在大規模併發應用(即系統有大量相似任務的執行緒)中使用。因為對於大規模併發應用,我們其實並不關心哪一個執行緒被喚醒。

現在我們使用 Guarded blocks 建立一個生產者/消費者應用。這類應用需要在兩個執行緒之間共享資料:生產者生產資料,消費者使用資料。兩個執行緒通過共享物件通訊。在這裡,執行緒協同工作的關鍵是:生產者釋出資料之前,消費者不能夠去讀取資料;消費者沒有讀取舊資料前,生產者不能釋出新資料。

在下面的例子中,資料通過 Drop 物件共享的一系列文字訊息:

public class Drop {
      // Message sent from producer
    // to consumer.
    private String message;
    // True if consumer should wait
    // for producer to send message,
    // false if producer should wait for
    // consumer to retrieve message.
    private boolean empty = true;

    public synchronized String take() {
        // Wait until message is
        // available.
        while (empty) {
            try {
                wait();
            } catch (InterruptedException e) {}
        }
        // Toggle status.
        empty = true;
        // Notify producer that
        // status has changed.
        notifyAll();
        return message;
    }

    public synchronized void put(String message) {
        // Wait until message has
        // been retrieved.
        while (!empty) {
            try { 
                wait();
            } catch (InterruptedException e) {}
        }
        // Toggle status.
        empty = false;
        // Store message.
        this.message = message;
        // Notify consumer that status
        // has changed.
        notifyAll();
    }
}

Producer 是生產者執行緒,傳送一組訊息,字串 DONE 表示所有訊息都已經傳送完成。為了模擬現實情況,生產者執行緒還會在訊息傳送時隨機的暫停。

public class Producer implements Runnable {
    private Drop drop;

    public Producer(Drop drop) {
        this.drop = drop;
    }

    public void run() {
        String importantInfo[] = { "Mares eat oats", "Does eat oats", "Little lambs eat ivy",
                "A kid will eat ivy too" };
        Random random = new Random();

        for (int i = 0; i < importantInfo.length; i++) {
            drop.put(importantInfo[i]);
            try {
                Thread.sleep(random.nextInt(5000));
            } catch (InterruptedException e) {
            }
        }
        drop.put("DONE");
    }
}

Consumer 是消費者執行緒,讀取訊息並列印出來,直到讀取到字串 DONE 為止。消費者執行緒在訊息讀取時也會隨機的暫停。

public class Consumer implements Runnable {
    private Drop drop;

    public Consumer(Drop drop) {
        this.drop = drop;
    }

    public void run() {
        Random random = new Random();
        for (String message = drop.take(); !message.equals("DONE"); message = drop.take()) {
            System.out.format("MESSAGE RECEIVED: %s%n", message);
            try {
                Thread.sleep(random.nextInt(5000));
            } catch (InterruptedException e) {
            }
        }
    }
}

ProducerConsumerExample 是主執行緒,它啟動生產者執行緒和消費者執行緒。

public class ProducerConsumerExample {
    public static void main(String[] args) {
        Drop drop = new Drop();
        (new Thread(new Producer(drop))).start();
        (new Thread(new Consumer(drop))).start();
    }
}

不可變物件(Immutable Objects)

如果一個物件它被構造後其,狀態不能改變,則這個物件被認為是不可變的(immutable )。不可變物件的好處是可以建立簡單的、可靠的程式碼。

不可變物件在併發應用種特別有用。因為他們不能改變狀態,它們不能被執行緒干擾所中斷或者被其他執行緒觀察到內部不一致的狀態。

程式設計師往往不願使用不可變物件,因為他們擔心建立一個新的物件要比更新物件的成本要高。實際上這種開銷常常被過分高估,而且使用不可變物件所帶來的一些效率提升也抵消了這種開銷。例如:使用不可變物件降低了垃圾回收所產生的額外開銷,也減少了用來確保使用可變物件不出現併發錯誤的一些額外程式碼。

接下來看一個可變物件的類,然後轉化為一個不可變物件的類。通過這個例子說明轉化的原則以及使用不可變物件的好處。

一個同步類的例子

SynchronizedRGB 是表示顏色的類,每一個物件代表一種顏色,使用三個整形數表示顏色的三基色,字串表示顏色名稱。

public class SynchronizedRGB {
    // Values must be between 0 and 255.
    private int red;
    private int green;
    private int blue;
    private String name;

    private void check(int red,
                       int green,
                       int blue) {
        if (red < 0 || red > 255
            || green < 0 || green > 255
            || blue < 0 || blue > 255) {
            throw new IllegalArgumentException();
        }
    }

    public SynchronizedRGB(int red,
                           int green,
                           int blue,
                           String name) {
        check(red, green, blue);
        this.red = red;
        this.green = green;
        this.blue = blue;
        this.name = name;
    }

    public void set(int red,
                    int green,
                    int blue,
                    String name) {
        check(red, green, blue);
        synchronized (this) {
            this.red = red;
            this.green = green;
            this.blue = blue;
            this.name = name;
        }
    }

    public synchronized int getRGB() {
        return ((red << 16) | (green << 8) | blue);
    }

    public synchronized String getName() {
        return name;
    }

    public synchronized void invert() {
        red = 255 - red;
        green = 255 - green;
        blue = 255 - blue;
        name = "Inverse of " + name;
    }
}

使用 SynchronizedRGB 時需要小心,避免其處於不一致的狀態。例如一個執行緒執行了以下程式碼:

SynchronizedRGB color =
    new SynchronizedRGB(0, 0, 0, "Pitch Black");
...
int myColorInt = color.getRGB();      //Statement 1
String myColorName = color.getName(); //Statement 2

如果有另外一個執行緒在 Statement 1 之後、Statement 2 之前呼叫了 color.set 方法,那麼 myColorInt 的值和 myColorName 的值就會不匹配。為了避免出現這樣的結果,必須要像下面這樣把這兩條語句繫結到一塊執行:

synchronized (color) {
    int myColorInt = color.getRGB();
    String myColorName = color.getName();
}

這種不一致的問題只可能發生在可變物件上。

定義不可變物件的策略

以下的一些建立不可變物件的簡單策略。並非所有不可變類都完全遵守這些規則,不過這不是編寫這些類的程式設計師們粗心大意造成的,很可能的是他們有充分的理由確保這些物件在建立後不會被修改。但這需要非常複雜細緻的分析,並不適用於初學者。

  • 不要提供 setter 方法。(包括修改欄位的方法和修改欄位引用物件的方法)
  • 將類的所有欄位定義為 final、private 的。
  • 不允許子類重寫方法。簡單的辦法是將類宣告為 final,更好的方法是將建構函式宣告為私有的,通過工廠方法建立物件。
  • 如果類的欄位是對可變物件的引用,不允許修改被引用物件。
    • 不提供修改可變物件的方法。
    • 不共享可變物件的引用。當一個引用被當做引數傳遞給建構函式,而這個引用指向的是一個外部的可變物件時,一定不要儲存這個引用。如果必須要儲存,那麼建立可變物件的拷貝,然後儲存拷貝物件的引用。同樣如果需要返回內部的可變物件時,不要返回可變物件本身,而是返回其拷貝。

將這一策略應用到 SynchronizedRGB 有以下幾步:

  • SynchronizedRGB 類有兩個 setter 方法。第一個 set 方法只是簡單的為欄位設值,第二個 invert 方法修改為建立一個新物件,而不是在原有物件上修改。
  • 所有的欄位都已經是私有的,加上 final 即可。
  • 將類宣告為 final 的
  • 只有一個欄位是物件引用,並且被引用的物件也是不可變物件。

經過以上這些修改後,我們得到了 ImmutableRGB:

public class ImmutableRGB {
      // Values must be between 0 and 255.
    final private int red;
    final private int green;
    final private int blue;
    final private String name;

    private void check(int red,
                       int green,
                       int blue) {
        if (red < 0 || red > 255
            || green < 0 || green > 255
            || blue < 0 || blue > 255) {
            throw new IllegalArgumentException();
        }
    }

    public ImmutableRGB(int red,
                        int green,
                        int blue,
                        String name) {
        check(red, green, blue);
        this.red = red;
        this.green = green;
        this.blue = blue;
        this.name = name;
    }

    public int getRGB() {
        return ((red << 16) | (green << 8) | blue);
    }

    public String getName() {
        return name;
    }

    public ImmutableRGB invert() {
        return new ImmutableRGB(255 - red,
                       255 - green,
                       255 - blue,
                       "Inverse of " + name);
    }
}

高階併發物件

目前為止,之前的教程都是重點講述了最初作為 Java 平臺一部分的低階別 API。這些API 對於非常基本的任務來說已經足夠,但是對於更高階的任務就需要更高階的 API。特別是針對充分利用了當今多處理器和多核系統的大規模併發應用程式。 本章,我們將著眼於 Java 5.0 新增的一些高階併發特徵。大多數功能已經在新的java.util.concurrent 包中實現。Java 集合框架中也定義了新的併發資料結構。

鎖物件

提供了可以簡化許多併發應用的鎖的慣用法。

同步程式碼依賴於一種簡單的可重入鎖。這種鎖使用簡單,但也有諸多限制。java.util.concurrent.locks 包提供了更復雜的鎖。這裡會重點關注其最基本的介面 Lock。 Lock 物件作用非常類似同步程式碼使用的內部鎖。如同內部鎖,每次只有一個執行緒可以獲得 Lock 物件。通過關聯 Condition 物件,Lock 物件也支援 wait/notify 機制。

Lock 物件之於隱式鎖最大的優勢在於,它們有能力收回獲得鎖的嘗試。如果當前鎖物件不可用,或者鎖請求超時(如果超時時間已指定),tryLock 方法會收回獲取鎖的請求。如果在鎖獲取前,另一個執行緒傳送了一箇中斷,lockInterruptibly 方法也會收回獲取鎖的請求。

讓我們使用 Lock 物件來解決我們在活躍度中見到的死鎖問題。Alphonse 和 Gaston 已經把自己訓練成能注意到朋友何時要鞠躬。我們通過要求 Friend 物件在雙方鞠躬前必須先獲得鎖來模擬這次改善。下面是改善後模型的原始碼 Safelock :

public class Safelock {
    static class Friend {
        private final String name;
        private final Lock lock = new ReentrantLock();

        public Friend(String name) {
            this.name = name;
        }

        public String getName() {
            return this.name;
        }

        public boolean impendingBow(Friend bower) {
            Boolean myLock = false;
            Boolean yourLock = false;
            try {
                myLock = lock.tryLock();
                yourLock = bower.lock.tryLock();
            } finally {
                if (!(myLock && yourLock)) {
                    if (myLock) {
                        lock.unlock();
                    }
                    if (yourLock) {
                        bower.lock.unlock();
                    }
                }
            }
            return myLock && yourLock;
        }

        public void bow(Friend bower) {
            if (impendingBow(bower)) {
                try {
                    System.out.format("%s: %s has" + " bowed to me!%n", this.name, bower.getName());
                    bower.bowBack(this);
                } finally {
                    lock.unlock();
                    bower.lock.unlock();
                }
            } else {
                System.out.format(
                        "%s: %s started" + " to bow to me, but saw that" + " I was already bowing to" + " him.%n",
                        this.name, bower.getName());
            }
        }

        public void bowBack(Friend bower) {
            System.out.format("%s: %s has" + " bowed back to me!%n", this.name, bower.getName());
        }
    }

    static class BowLoop implements Runnable {
        private Friend bower;
        private Friend bowee;

        public BowLoop(Friend bower, Friend bowee) {
            this.bower = bower;
            this.bowee = bowee;
        }

        public void run() {
            Random random = new Random();
            for (;;) {
                try {
                    Thread.sleep(random.nextInt(10));
                } catch (InterruptedException e) {
                }
                bowee.bow(bower);
            }
        }
    }

    public static void main(String[] args) {
        final Friend alphonse = new Friend("Alphonse");
        final Friend gaston = new Friend("Gaston");
        new Thread(new BowLoop(alphonse, gaston)).start();
        new Thread(new BowLoop(gaston, alphonse)).start();
    }
}

執行器(Executors)

為載入和管理執行緒定義了高階 API。Executors 的實現由 java.util.concurrent 包提供,提供了適合大規模應用的執行緒池管理。

在之前所有的例子中,Thread 物件表示的執行緒和 Runnable 物件表示的執行緒所執行的任務之間是緊耦合的。這對於小型應用程式來說沒問題,但對於大規模併發應用來說,合理的做法是將執行緒的建立與管理和程式的其他部分分離開。封裝這些功能的物件就是執行器,接下來的部分將講詳細描述執行器。

執行器介面

在 java.util.concurrent 中包括三個執行器介面:

  • Executor,一個執行新任務的簡單介面。
  • ExecutorService,擴充套件了 Executor 介面。新增了一些用來管理執行器生命週期和任務生命週期的方法。
  • ScheduledExecutorService,擴充套件了 ExecutorService。支援 future 和(或)定期執行任務。

通常來說,指向 executor 物件的變數應被宣告為以上三種介面之一,而不是具體的實現類

Executor 介面

Executor 介面只有一個 execute 方法,用來替代通常建立(啟動)執行緒的方法。例如:r 是一個 Runnable 物件,e 是一個 Executor 物件。可以使用

e.execute(r);

代替

(new Thread(r)).start();

但 execute 方法沒有定義具體的實現方式。對於不同的 Executor 實現,execute 方法可能是建立一個新執行緒並立即啟動,但更有可能是使用已有的工作執行緒執行r,或者將 r放入到佇列中等待可用的工作執行緒。(我們將線上程池一節中描述工作執行緒。)

ExecutorService 介面

ExecutorService 介面在提供了 execute 方法的同時,新加了更加通用的 submit 方法。submit 方法除了和 execute 方法一樣可以接受 Runnable 物件作為引數,還可以接受 Callable 物件作為引數。使用 Callable物件可以能使任務返還執行的結果。通過 submit 方法返回的Future 物件可以讀取 Callable 任務的執行結果,或是管理 Callable 任務和 Runnable 任務的狀態。 ExecutorService 也提供了批量執行 Callable 任務的方法。最後,ExecutorService 還提供了一些關閉執行器的方法。如果需要支援即時關閉,執行器所執行的任務需要正確處理中斷。

ScheduledExecutorService 介面

ScheduledExecutorService 擴充套件 ExecutorService介面並新增了 schedule 方法。呼叫 schedule 方法可以在指定的延時後執行一個Runnable 或者 Callable 任務。ScheduledExecutorService 介面還定義了按照指定時間間隔定期執行任務的 scheduleAtFixedRate 方法和 scheduleWithFixedDelay 方法。

執行緒池

執行緒池是最常見的一種執行器的實現。

在 java.util.concurrent 包中多數的執行器實現都使用了由工作執行緒組成的執行緒池,工作執行緒獨立於所它所執行的 Runnable 任務和 Callable 任務,並且常用來執行多個任務。

使用工作執行緒可以使建立執行緒的開銷最小化。在大規模併發應用中,建立大量的 Thread 物件會佔用佔用大量系統記憶體,分配和回收這些物件會產生很大的開銷。

一種最常見的執行緒池是固定大小的執行緒池。這種執行緒池始終有一定數量的執行緒在執行,如果一個執行緒由於某種原因終止執行了,執行緒池會自動建立一個新的執行緒來代替它。需要執行的任務通過一個內部佇列提交給執行緒,當沒有更多的工作執行緒可以用來執行任務時,佇列儲存額外的任務。

使用固定大小的執行緒池一個很重要的好處是可以實現優雅退化(degrade gracefully)。例如一個 Web 伺服器,每一個 HTTP 請求都是由一個單獨的執行緒來處理的,如果為每一個 HTTP 都建立一個新執行緒,那麼當系統的開銷超出其能力時,會突然地對所有請求都停止響應。如果限制 Web 伺服器可以建立的執行緒數量,那麼它就不必立即處理所有收到的請求,而是在有能力處理請求時才處理。

建立一個使用執行緒池的執行器最簡單的方法是呼叫 java.util.concurrent.Executors 的 newFixedThreadPool 方法。Executors 類還提供了下列一下方法:

  • newCachedThreadPool 方法建立了一個可擴充套件的執行緒池。適合用來啟動很多短任務的應用程式。
  • newSingleThreadExecutor 方法建立了每次執行一個任務的執行器。
  • 還有一些 ScheduledExecutorService 執行器建立的工廠方法。

如果上面的方法都不滿足需要,可以嘗試 java.util.concurrent.ThreadPoolExecutor 或者java.util.concurrent.ScheduledThreadPoolExecutor。

Fork/Join

該框架是 JDK 7 中引入的併發框架。

fork/join 框架是 ExecutorService 介面的一種具體實現,目的是為了幫助你更好地利用多處理器帶來的好處。它是為那些能夠被遞迴地拆解成子任務的工作型別量身設計的。其目的在於能夠使用所有可用的運算能力來提升你的應用的效能。

類似於 ExecutorService 介面的其他實現,fork/join 框架會將任務分發給執行緒池中的工作執行緒。fork/join 框架的獨特之處在與它使用工作竊取(work-stealing)演算法。完成自己的工作而處於空閒的工作執行緒能夠從其他仍然處於忙碌(busy)狀態的工作執行緒處竊取等待執行的任務。

fork/join 框架的核心是 ForkJoinPool 類,它是對 AbstractExecutorService 類的擴充套件。ForkJoinPool 實現了工作竊取演算法,並可以執行ForkJoinTask 任務。

基本使用方法

使用 fork/join 框架的第一步是編寫執行一部分工作的程式碼。你的程式碼結構看起來應該與下面所示的虛擬碼類似:

if (my portion of the work is small enough)
  do the work directly
else
  split my work into two pieces
  invoke the two pieces and wait for the results

翻譯為中文為:

if (當前這個任務工作量足夠小)
    直接完成這個任務
else
    將這個任務或這部分工作分解成兩個部分
    分別觸發(invoke)這兩個子任務的執行,並等待結果

你需要將這段程式碼包裹在一個 ForkJoinTask 的子類中。不過,通常情況下會使用一種更為具體的的型別,或者是 RecursiveTask(會返回一個結果),或者是 RecursiveAction。 當你的 ForkJoinTask 子類準備好了,建立一個代表所有需要完成工作的物件,然後將其作為引數傳遞給一個ForkJoinPool 例項的 invoke() 方法即可。

模糊圖片的例子

想要了解 fork/join 框架的基本工作原理,接下來的這個例子會有所幫助。假設你想要模糊一張圖片。原始的 source 圖片由一個整數的陣列表示,每個整數表示一個畫素點的顏色數值。與 source 圖片相同,模糊之後的 destination 圖片也由一個整數陣列表示。 對圖片的模糊操作是通過對 source 陣列中的每一個畫素點進行處理完成的。處理的過程是這樣的:將每個畫素點的色值取出,與周圍畫素的色值(紅、黃、藍三個組成部分)放在一起取平均值,得到的結果被放入 destination 陣列。因為一張圖片會由一個很大的陣列來表示,這個流程會花費一段較長的時間。如果使用 fork/join 框架來實現這個模糊演算法,你就能夠藉助多處理器系統的並行處理能力。下面是上述演算法結合 fork/join 框架的一種簡單實現:

public class ForkBlur extends RecursiveAction {
    private int[] mSource;
    private int mStart;
    private int mLength;
    private int[] mDestination;

    // Processing window size; should be odd.
    private int mBlurWidth = 15;

    public ForkBlur(int[] src, int start, int length, int[] dst) {
        mSource = src;
        mStart = start;
        mLength = length;
        mDestination = dst;
    }

    protected void computeDirectly() {
        int sidePixels = (mBlurWidth - 1) / 2;
        for (int index = mStart; index < mStart + mLength; index++) {
            // Calculate average.
            float rt = 0, gt = 0, bt = 0;
            for (int mi = -sidePixels; mi <= sidePixels; mi++) {
                int mindex = Math.min(Math.max(mi + index, 0),
                                    mSource.length - 1);
                int pixel = mSource[mindex];
                rt += (float)((pixel & 0x00ff0000) >> 16)
                      / mBlurWidth;
                gt += (float)((pixel & 0x0000ff00) >>  8)
                      / mBlurWidth;
                bt += (float)((pixel & 0x000000ff) >>  0)
                      / mBlurWidth;
            }

            // Reassemble destination pixel.
            int dpixel = (0xff000000     ) |
                   (((int)rt) << 16) |
                   (((int)gt) <<  8) |
                   (((int)bt) <<  0);
            mDestination[index] = dpixel;
        }
    }

  ...

接下來你需要實現父類中的 compute() 方法,它會直接執行模糊處理,或者將當前的工作拆分成兩個更小的任務。陣列的長度可以作為一個簡單的閥值來判斷任務是應該直接完成還是應該被拆分。

protected static int sThreshold = 100000;

protected void compute() {
    if (mLength < sThreshold) {
        computeDirectly();
        return;
    }

    int split = mLength / 2;

    invokeAll(new ForkBlur(mSource, mStart, split, mDestination),
              new ForkBlur(mSource, mStart + split, mLength - split,
                           mDestination));
}

如果前面這個方法是在一個 RecursiveAction 的子類中,那麼設定任務在ForkJoinPool 中執行就再直觀不過了。通常會包含以下一些步驟:

  1. 建立一個表示所有需要完成工作的任務。// source image pixels are in src // destination image pixels are in dst ForkBlur fb = new ForkBlur(src, 0, src.length, dst);
  2. 建立將要用來執行任務的 ForkJoinPool。ForkJoinPool pool = new ForkJoinPool();
  3. 執行任務。pool.invoke(fb);

想要瀏覽完成的原始碼,請檢視 ForkBlur示例,其中還包含一些建立 destination 圖片檔案的額外程式碼。

標準實現

除了能夠使用 fork/join 框架來實現能夠在多處理系統中被並行執行的定製化演算法(如前文中的 ForkBlur.java 例子),在 Java SE 中一些比較常用的功能點也已經使用 fork/join 框架來實現了。在 Java SE 8 中,java.util.Arrays 類的一系列parallelSort() 方法就使用了 fork/join 來實現。這些方法與 sort() 方法很類似,但是通過使用 fork/join框 架,藉助了併發來完成相關工作。在多處理器系統中,對大陣列的並行排序會比序列排序更快。這些方法究竟是如何運用 fork/join 框架並不在本教程的討論範圍內。想要了解更多的資訊,請參見 Java API 文件。 其他採用了 fork/join 框架的方法還包括java.util.streams包中的一些方法,此包是作為 Java SE 8 發行版中 Project Lambda 的一部分。想要了解更多資訊,請參見 Lambda 表示式一節。

併發集合

併發集合簡化了大型資料集合管理,且極大的減少了同步的需求。

java.util.concurrent 包囊括了 Java 集合框架的一些附加類。它們也最容易按照集合類所提供的介面來進行分類:

  • BlockingQueue 定義了一個先進先出的資料結構,當你嘗試往滿佇列中新增元素,或者從空佇列中獲取元素時,將會阻塞或者超時。
  • ConcurrentMap 是 java.util.Map 的子介面,定義了一些有用的原子操作。移除或者替換鍵值對的操作只有當 key 存在時才能進行,而新增操作只有當 key 不存在時。使這些操作原子化,可以避免同步。ConcurrentMap 的標準實現是 ConcurrentHashMap,它是 HashMap 的併發模式。
  • ConcurrentNavigableMap 是 ConcurrentMap 的子介面,支援近似匹配。ConcurrentNavigableMap 的標準實現是 ConcurrentSkipListMap,它是 TreeMap 的併發模式。

所有這些集合,通過在集合裡新增物件和訪問或移除物件的操作之間,定義一個happens-before 的關係,來幫助程式設計師避免記憶體一致性錯誤。

原子變數

java.util.concurrent.atomic 包定義了對單一變數進行原子操作的類。所有的類都提供了 get 和 set 方法,可以使用它們像讀寫 volatile 變數一樣讀寫原子類。就是說,同一變數上的一個 set 操作對於任意後續的 get 操作存在 happens-before 關係。原子的 compareAndSet 方法也有記憶體一致性特點,就像應用到整型原子變數中的簡單原子演算法。

為了看看這個包如何使用,讓我們返回到最初用於演示執行緒干擾的 Counter 類:

class Counter {
    private int c = 0;

    public void increment() {
        c++;
    }

    public void decrement() {
        c--;
    }

    public int value() {
        return c;
    }
}

使用同步是一種使 Counter 類變得執行緒安全的方法,如 SynchronizedCounter:

class SynchronizedCounter {
    private int c = 0;

    public synchronized void increment() {
        c++;
    }

    public synchronized void decrement() {
        c--;
    }

    public synchronized int value() {
        return c;
    }
}

對於這個簡單的類,同步是一種可接受的解決方案。但是對於更復雜的類,我們可能想要避免不必要同步所帶來的活躍度影響。將 int 替換為 AtomicInteger 允許我們在不進行同步的情況下阻止執行緒干擾,如 AtomicCounter:

import java.util.concurrent.atomic.AtomicInteger;

class AtomicCounter {
    private AtomicInteger c = new AtomicInteger(0);

    public void increment() {
        c.incrementAndGet();
    }

    public void decrement() {
        c.decrementAndGet();
    }

    public int value() {
        return c.get();
    }
}

併發隨機數

併發隨機數(JDK7)提供了高效的多執行緒生成偽隨機數的方法。

在 JDK7 中,java.util.concurrent 包含了一個相當便利的類 ThreadLocalRandom,可以在當應用程式期望在多個執行緒或 ForkJoinTasks 中使用隨機數時使用。

對於併發訪問,使用 TheadLocalRandom 代替 Math.random() 可以減少競爭,從而獲得更好的效能。

你只需呼叫 ThreadLocalRandom.current(), 然後呼叫它的其中一個方法去獲取一個隨機數即可。下面是一個例子:

int r = ThreadLocalRandom.current() .nextInt(4, 77);

原始碼

本章例子的原始碼,可以在 https://github.com/waylau/essential-java 中 com.waylau.essentialjava.concurrency 包下找到。

參考

相關文章