CompletableFuture 組合式異步編程

本節內容:

  • 創建異步計算並獲取計算結果.
  • 使用非阻塞操作提升吞吐量.
  • 設計和實現異步API.
  • 如何以異步的方式使用同步的API.
  • 如何對兩個或多個異步操作進行流水線和合並操作.
  • 如何處理異步操作的完成狀態.

現在,很少有網站或者網絡應用會以完全隔離的方式工作。更多的時候,我們看到 的下一代網絡應用都採用“混聚”(mash-up)的方式:它會使用來自多個來源的內容,將這些內 容聚合在一起,方便用戶的生活。

可是,你並不希望因爲等待某 些服務的響應,阻塞應用程序的運行,浪費數十億寶貴的CPU時鐘週期。

那麼其實你真正想做的是避免因爲等待遠程服務的返回,或者對數據庫的查詢等,而阻塞 當前線程的執行,浪費寶貴的CPU資源,因爲這種等待的時間很坑相當長.

通過本文你將會瞭解到java5中的Future接口和jav8中對它更加完善的CompletableFuture接口,

Future接口

要使用Future,通常你只需要把耗時操作封裝在一個Callable對象中,再把它交給 ExecutorService 就行了,下面這段代碼展示了Java 8之前使用Future 的一個例子。

ExecutorService executor = Executors.newCachedThreadPool();
    Future<Double> future = executor.submit(new Callable<Double>() {
        public Double call() {
            return doSomeLongComputation();
            }});
        doSomethingElse();
        try {
        Double result = future.get(1, TimeUnit.SECONDS);
        } catch (ExecutionException ee) {
        // 計算拋出一個異常
        } catch (InterruptedException ie) {
        // 當前線程在等待過程中被中斷
        } catch (TimeoutException te) {
        // 在Future對象完成之前超過已過期
}

通過第一個例子,我們知道 Future 接口提供了方法來檢測異步計算是否已經結束(使用 isDone 方法),等待異步操作結束,以及獲取計算的結果。但是這些特性還不足以讓你編寫簡潔 的併發代碼。比如,我們很難表述 Future 結果之間的依賴性;從文字描述上這很簡單,“當長時 間計算任務完成時,請將該計算的結果通知到另一個長時間運行的計算任務,這兩個計算任務都 完成後,將計算的結果與另一個查詢操作結果合併”。但是,使用 Future 中提供的方法完成這樣 的操作又是另外一回事。這也是我們需要更具描述能力的特性的原因.
在這裏插入圖片描述

接下來,你會了解新的 CompletableFuture 類(它實現了 Future 接口)如何利用Java 8 的新特性以更直觀的方式將上述需求都變爲可能。 Stream 和 CompletableFuture 的設計都遵循 了類似的模式:它們都使用了Lambda表達式以及流水線的思想。從這個角度,你可以說 CompletableFuture 和 Future 的關係就跟 Stream 和 Collection 的關係一樣。

使用 CompletableFuture 構建異步應用

爲了展示 CompletableFuture 的強大特性,我們會創建一個名爲“最佳價格查詢器” (best-price-finder)的應用,它會查詢多個在線商店,依據給定的產品或服務找出最低的價格。

實現異步 API

爲了實現最佳價格查詢器應用,讓我們從每個商店都應該提供的API定義入手。首先,商店 應該聲明依據指定產品名稱返回價格的方法:

package completablefuture;

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;

/**
 * 每個商店都提供的對外訪問的API
 * @author itguang
 * @create 2017-11-22 11:05
 **/
public class Shop {

    /**
     * 商店名稱
     */
    private  String name;


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

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    /**
     * (阻塞式)通過名稱查詢價格
     * @param product
     * @return
     */
    public double getPrice(String product) {
           return calculatePrice(product);
    }

   


    /**
     * 計算價格
     * @param product
     * @return
     */
    private double calculatePrice(String product){
        delay();
        //數字*字符=數字
        return 10*product.charAt(0);

    }


    /**
     * 模擬耗時操作,阻塞1秒
     */
    private void delay(){
        try {
            Thread.sleep(1000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}

很明顯,這個API的使用者(這個例子中爲最佳價格查詢器)調用該方法時,它會被 阻塞。爲等待同步事件完成而等待1秒鐘,這是無法接受的,尤其是考慮到最佳價格查詢器對 網絡中的所有商店都要重複這種操作。

本章接下來的小節中,你會了解如何以異步方式使用同步API解決這個問題。

將同步方法轉化爲異步方法

在上面的Shop服務類中添加異步計算價格的方法:

 /**
     * (非阻塞式)異步獲取價格
     * @param product
     * @return
     */
    public Future<Double> getPriceAsync(String product){
        CompletableFuture<Double> future = new CompletableFuture<>();
        new Thread(()->{
            double price = calculatePrice(product);
            //需要長時間計算的任務結束並返回結果時,設置Future返回值
            future.complete(price);
        }).start();

        //無需等待還沒結束的計算,直接返回Future對象
        return future;
    }

上面的代碼我們可以看出,我們模擬了一個計算價格的耗時操作 calculatePrice(String product),接收一個商品名稱作爲參數, 還有一個異步的API getPriceAsync(String product),改方法創建了一個代表異步計算的 CompletableFuture 對象實例,它在計算完成後會包含計算結果. 當請求的產品價格最終計算得出時,你可以使用 complete 方法,結束CompletableFuture 對象的運行,並設置變量的值.

接下來我們開始調用上面的API服務實現我們的商品比價器:

package future;
 
 import completablefuture.Shop;
 import org.junit.Test;
 
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Future;
 
 /**
  * @author itguang
  * @create 2017-11-22 10:54
  **/
 public class FutureDemo {
 
 
     /**
      * 測試同步API
      */
     @Test
     public void testgetPrice(){
         Shop shop = new Shop("Best Shop");
         long start = System.nanoTime();
         double price = shop.getPrice("mac book pro");
         System.out.printf(shop.getName()+" Price is %.2f%n",price);
         long invocationTime = (System.nanoTime()-start)/1_000_000;
         System.out.println("同步方法調用花費時間:--- "+invocationTime+" --- msecs");
 
 
         //...其他操作
         doSomethingElse();
 
 
         long retrievalTime = (System.nanoTime()-start)/1_000_000;
         System.out.println("同步方法返回價格所需時間: --- "+retrievalTime+" ---msecs");
 
     }
 
 
     /**
      * 測試異步API
      */
     @Test
     public void testAsync(){
         Shop shop = new Shop("Best Shop");
         long start = System.nanoTime();
         Future<Double> futurePrice = shop.getPriceAsync("mac book pro");
         long invocationTime = (System.nanoTime()-start)/1_000_000;
         System.out.println("異步方法調用花費時間: --- "+invocationTime+" --- msecs");
 
 
         //...其他操作
         doSomethingElse();
 
         //從future對象中讀取價格,如果價格未知,則發生阻塞.
         try {
             Double price = futurePrice.get();
             System.out.printf(shop.getName()+" Price is %.2f%n",price);
         } catch (InterruptedException e) {
             e.printStackTrace();
         } catch (ExecutionException e) {
             e.printStackTrace();
         }
 
         long retrievalTime = (System.nanoTime()-start)/1_000_000;
         System.out.println("異步方法返回價格所需時間: --- "+retrievalTime+" ---msecs");
 
     }
 
 
 
 
     /**
      * 其它操作
      */
     public static void doSomethingElse(){
          try {
                     Thread.sleep(1_000);
                 } catch (InterruptedException e) {
                     e.printStackTrace();
                 }
 
     }
 
 
 }

測試結果:

同步方法調用花費時間:--- 1000 --- msecs
同步方法返回價格所需時間: --- 2000 ---msecs

----------------

異步方法調用花費時間: --- 29 --- msecs
異步方法返回價格所需時間: --- 1035 ---msecs

由此可見:同步方法使用阻塞時編程獲取商品價格,異步方法採用非阻塞式編程獲取商品價格,在調用異步方法 getPriceAsync後,立即返回一個Future對象, 通過該對象在將來的某個時刻取得商品的價格,這種情況下,客戶在進行商品價格查詢的同時,還能執行一些其它的操作.而同步方法調用,必須等到同步方法完成後才能進行其它操作

錯誤處理

現在我們考慮這樣一種情況,假設計算價格的方法出現了錯誤,會怎樣呢?

  • 對於同步線程來說會直接拋出一個異常,並終止當前線程的運行.
  • 對於異步線程來說,就非常糟糕了,異常會被限制在計算價格的那個異步線程中,當異步線程拋出 未經捕獲的異常 時, 該線程就會被殺死,而這會導致等待get()方法返回結果的客戶端永久性被阻塞.
    接下來我們測試一下:修改Shop類的 calculatePrice方法如下,
/**
     * 計算價格(模擬一個產生價格的方法)
     * @param product
     * @return
     */
    private double calculatePrice(String product){
        delay();

            int i=  1/0;//故意拋出 java.lang.ArithmeticException: / by zero 異常

        //數字*字符=數字(產生價格的方法)
        return 10*product.charAt(0);

    }

再次測試會發現,客戶端會收到一個異常信息:

 java.lang.ArithmeticException: / by zero
        at completablefuture.Shop.calculatePrice(Shop.java:70)
        at completablefuture.Shop.lambda$getPriceAsync$0(Shop.java:49)
        at java.lang.Thread.run(Thread.java:748)

使用工廠方法 supplyAsync 創建 CompletableFuture 對象.

CompletableFuture 類自 身提供了大量精巧的工廠方法,使用這些方法能更容易地完成整個流程,還不用擔心實現的細節。

你可以用一行語句重寫方法 getPriceAsync

/**
     * 使用靜態工廠supplyAsync(非阻塞式)異步獲取價格
     * @param product
     * @return
     */
    public Future<Double> getPriceAsync(String product){
        CompletableFuture<Double> future = CompletableFuture.supplyAsync(() -> calculatePrice(product));

        //無需等待還沒結束的計算,直接返回Future對象
        return future;
    }

supplyAsync 方法接受一個生產者( Supplier )作爲參數,返回一個 CompletableFuture 對象,該對象完成異步執行後會讀取調用生產者方法的返回值。生產者方法會交由 ForkJoinPool 池中的某個執行線程( Executor )運行,但是你也可以使用 supplyAsync 方法的重載版本,傳 遞第二個參數指定不同的執行線程執行生產者方法。一般而言,向 CompletableFuture 的工廠 方法傳遞可選參數,指定生產者方法的執行線程是可行的,在後面我們會使用這一方法.

讓你的代碼免受阻塞之苦

接下來,爲了介紹如何拜託代碼阻塞帶來的煩惱,我們假設Shop API的代碼實現你是不能更改的.其實這樣也合理,我們肯定不能控制服務提供方修改他們的代碼.

現在假設Shop APi提供的方法都是同步阻塞式的,這也是當你試圖使用 HTTP API 提供的服務最長髮生的事情.

下面你會學到如何以異步的方式查詢多個商店,避免被單一的請求所阻塞,並由此提升你的“最佳價格查詢器”的性能和吞吐量

現在我們重修改Shop類

package completablefuture;

import java.util.List;
import java.util.Random;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;

/**
 * 每個商店都提供的對外訪問的API
 * @author itguang
 * @create 2017-11-22 11:05
 **/
public class Shop {

    /**
     * 商店名稱
     */
    private  String name;
    private Random random = new Random();


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

    public String getName() {
        return name;
    }

    /**
     * (阻塞式)通過名稱查詢價格
     * @param product
     * @return
     */
    public double getPrice(String product) {
           return calculatePrice(product);
    }

    /**
     * 計算價格(模擬一個產生價格的方法)
     * @param product
     * @return
     */
    private double calculatePrice(String product){
        delay();
        //數字*字符=數字(產生價格的方法)
        return random.nextDouble()*product.charAt(0)*product.charAt(1);
    }


    /**
     * 模擬耗時操作,阻塞1秒
     */
    private void delay(){
        try {
            Thread.sleep(1_000);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }

}
客戶端代碼:

/**
 * 讓你的代碼免受阻塞之苦
 *
 * @author itguang
 * @create 2017-11-21 16:50
 **/
public class ClientTest {

    List<Shop> shops;

    @Before
    public void before() {
        shops = Arrays.asList(new Shop("淘寶"),
                new Shop("天貓"),
                new Shop("京東"),
                new Shop("亞馬遜"));

    }


    

    /**
     * 
     * @param product 商品名稱
     * @return 根據名字返回每個商店的商品價格
     */
    public List<String> findPrice(String product) {
       

        return list;
    }




}

我們有一個商店列表 shops,和一個 findPrice 方法.

採用順序查詢所有商店的方式實現的 findPrices 方法

首先,我們最先想到的應該是採用順序查詢所有商店的方式實現的 findPrices 方法.

/**
 * 讓你的代碼免受阻塞之苦
 *
 * @author itguang
 * @create 2017-11-21 16:50
 **/
public class ClientTest {

    List<Shop> shops;

    @Before
    public void before() {
        shops = Arrays.asList(new Shop("淘寶"),
                new Shop("天貓"),
                new Shop("京東"),
                new Shop("亞馬遜"));

    }

   /**
     * 採用順序查詢所有商店的方式實現的 findPrices 方法
     * @param product
     * @return
     */
    public List<String> findPrice(String product) {
        List<String> list = shops.stream()
                .map(shop ->
                        String.format("%s price is %.2f RMB",
                                shop.getName(),
                                shop.getPrice(product)))

                .collect(toList());

        return list;
    }

    /**
     *採用順序查詢所有商店的方式實現的 findPrices 方法,查詢每個商店裏的 iphone666s
     */
    @Test
    public void test() {
        long start = System.nanoTime();
        
        List<String> list = findPrice("iphone666s");
        
        System.out.println(list);
        System.out.println("Done in "+(System.nanoTime()-start)/1_000_000+" ms");
    }

    
}

測試結果:

[淘寶 price is 7013.15 RMB, 天貓 price is 6248.79 RMB, 京東 price is 4902.47 RMB, 亞馬遜 price is 2700.63 RMB]
Done in 4043 ms

正如我們所料,由於對每個商店的查詢都是阻塞式的,並且每個查詢都花費大約1秒,4個商店的查詢是順序的,一個查詢操作會阻塞另一個.

使用並行流對請求進行並行操作

爲了對以上代碼進行改進,我們很容易想到使用並行流進行並行操作.可能也是最快的改善方法是使用並行流來避免順序計算, 如下所示:

/**
     * 使用並行流對請求進行並行操作
     * @param product
     * @return
     */
    public List<String> findPrice2(String product) {
        List<String> list = shops.parallelStream()
                .map(shop ->
                        String.format("%s price is %.2f RMB",
                                shop.getName(),
                                shop.getPrice(product)))

                .collect(toList());

        return list;
    }

測試結果:

  [淘寶 price is 7296.22 RMB, 天貓 price is 3504.13 RMB, 京東 price is 8173.09 RMB, 亞馬遜 price is 4015.44 RMB]
    Done in 1037 ms

效果立竿見影,看起來改爲並行流確實是個簡單有效的的操作:現在對四個不同商店的查詢實行了並行操作,所以完成所有操作的總和只有1秒多一點.

使用 CompletableFuture 發起異步請求

讓我們嘗試使用剛學過的CompletableFuture ,將 findPrices 方法中對不同商店的同步調用替換爲異步調用

 /**
     * 使用 CompletableFuture 發起異步請求
     * @param product
     * @return
     */
    public List<String> findPrice3(String product) {
        List<CompletableFuture<String>> futures = shops.stream()
                .map(shop -> CompletableFuture.supplyAsync(
                        () -> String.format("%s price is %.2f RMB",
                                shop.getName(),
                                shop.getPrice(product)))
                )
                .collect(toList());
        List<String> list = futures.stream()
                .map(CompletableFuture::join)
                .collect(toList());


        return list;
    }

在此,我們對上面的代碼解釋一下 CompletableFuture.join() 操作, 其實我們自需要知道CompletableFuture 類中的 join 方法和 Future 接口中的 get 有相同的含義,並且也聲明在Future 接口中, 它們唯一的不同是 join 不會拋出任何檢測到的異常。使用它你不再需要使用try / catch 語句塊讓你傳遞給第二個 map 方法的Lambda表達式變得過於臃腫

還有一點需要注意:這裏使用了兩個不同的 Stream 流水線,而不是在同一個處理流的流水線上一個接一個地放置兩個 map 操作——這其實是有緣由的.

如果你在單一流水線中處理流,發向不同商家的請求只能以同步,順序的方式才能執行成功. 這樣一來,你的操作就相當於,每個ComletableFuture對象只能在前一個執行查詢的操作後才能執行自己的查詢操作,即通知join方法返回結果.下圖清晰的表明了單一流水線和多流水線的執行過程:

圖的上班部分展示了單一流水線的處理過程,我們看到執行的流程是順序的(以虛線標識),即新的CompletableFuture對象只能在前一個操作完成之後才能創建. 與此相反,圖的下半部分展示瞭如何先將CompletableFuture聚集到一個列表中,即以橢圓部分表示,然後轉成另一個流,讓對象們在其他對象完成操作之前就能啓動.

接下來讓我們看看使用 CompletableFuture 發起異步請求的測試結果:

[淘寶 price is 4190.89 RMB, 天貓 price is 10398.09 RMB, 京東 price is 8776.79 RMB, 亞馬遜 price is 2902.69 RMB]
Done in 2044 ms

震驚!!!,有麼有? 我們驚訝的發現,這次執行的結果比原生順序執行快兩倍,比並行流執行慢兩倍,再一想到我們只用了最少最簡單的改動把順序流改爲並行流,把時間提高了4倍,就更加讓人沮喪.

但是,這就是全部的真相嗎? CompletableFuture 就這麼不堪一擊嗎?還是我們可能漏掉了什麼重要的東西,繼續往下探究之前,讓我們休息幾分鐘,尤其是想想你測試 代碼的機器是否足以以並行方式運行四個線程。

尋找更好的方案

對於上面出現的問題,我們分析一下:並行版本工作的很好,是因爲它能並行的執行四個任務,所以它幾乎能爲每個商家分配一個線程.

但是,如果你想要增加第五個商家到商店列表中,讓你的“最佳價格查詢”應用 對其進行處理,這時會發生什麼情況?

讓我們看看增加一個商店後的測試結果:

順序

 [淘寶 price is 6596.20 RMB, 天貓 price is 1528.81 RMB, 京東 price is 2649.17 RMB, 亞馬遜 price is 7780.31 RMB, 實體店 price is 2027.03 RMB]
  Done in 5034 ms

並行

[淘寶 price is 9537.24 RMB, 天貓 price is 11233.77 RMB, 京東 price is 6580.67 RMB, 亞馬遜 price is 2622.23 RMB, 實體店 price is 1528.11 RMB]
Done in 2038 ms

CompletableFuture

[淘寶 price is 8466.65 RMB, 天貓 price is 6883.22 RMB, 京東 price is 4990.85 RMB, 亞馬遜 price is 2826.72 RMB, 實體店 price is 8278.86 RMB]
Done in 2043 ms

順序執行版本的執行還是需要大約5秒多鐘的時間,

並行流版本的程序這次比之前也多消耗了差不多1秒鐘的時間,因爲可以並行運行(通用線程池中處於可用狀態的)的四個線程現在都處於繁忙狀態,都在對前4個商店進行查 詢。第五個查詢只能等到前面某一個操作完成釋放出空閒線程才能繼續,

CompletableFuture 版本的程序似乎比之前只慢那麼一點兒,但是最後這個版本也不太令人滿意。比如,如果你試圖讓你的代碼處理9個商店,並行流版本耗時3143毫秒, 而 CompletableFuture 版本耗時3009毫秒。它們看起來不相伯仲,究其原因都一樣:

後兩個版本他們內部採用的都是同樣的線程池,默認都使用固定數目的線程,具體縣城數量取決於 Runtime.getRuntime().availableProcessors() 的返回值

然而,CompletableFuture 具有一定的優勢,因爲他可以讓你對 Execotor (執行器)進行配置,尤其是線程池的大小,讓它以更適合應用需求的方式配置.這是並行流API無法提供的.

讓我們看看你怎樣利用這種配置上的靈活性帶來實際應用程序性能上的提升。

使用定製的 Executor 配置 CompletableFuture
就這個主題而言,明智的選擇似乎是創建一個配有線程池的 Execotor,但是你應該如何選擇合適的線程數目呢?

調整線程池的大小

《Java併發編程實戰》(http://mng.bz/979c)一書中,Brian Goetz和合著者們爲線程池大小
的優化提供了不少中肯的建議。這非常重要,如果線程池中線程的數量過多,最終他們會競爭稀缺的處理器和內存資源,浪費大量的時間再上下文切換上.
反之,如果線程數過少,正如我們上面的應用所面臨的問題,處理器的一些核可能就無法充分利用. Brian Goetz建議,線程池大
小與處理器的利用率之比可以使用下面的公式進行估算:

我們的應用99% 的時間都在等待商店的響應,所以估算出 W/C 的比率爲99/1,約爲100,這意味這你期望CPU的利用率爲 100%,如果你的處理器爲4核,拿你要創建一個擁有400個線程的線程池, 實際操作中,如果你創建的線程數比商店的數目更多,反而是一種浪費,因爲這樣作之後,你線程池中的很大一部分線程根本沒有機會被使用.

出於這種考慮,我們建議你將執行器使用的線程數,與你需要查詢的商店數目設 定爲同一個值,這樣每個商店都應該對應一個服務線程。不過,爲了避免發生由於商店的數目過 多導致服務器超負荷而崩潰,你還是需要設置一個上限,比如100個線程。

/**
     * 使用定製的 Executor 配置 CompletableFuture
     *
     * @param product
     * @return
     */
    public List<String> findPrice4(String product) {

        //爲“最優價格查詢器”應用定製的執行器 Execotor
        Executor executor = Executors.newFixedThreadPool(Math.min(shops.size(), 100),
                new ThreadFactory() {
                    @Override
                    public Thread newThread(Runnable r) {
                        Thread thread = new Thread(r);
                        //使用守護線程,使用這種方式不會組織程序的關停
                        thread.setDaemon(true);
                        return thread;
                    }
                }
        );

      //將執行器Execotor 作爲第二個參數傳遞給 supplyAsync 工廠方法
        List<CompletableFuture<String>> futures = shops.stream()
                .map(shop -> CompletableFuture.supplyAsync(
                        () -> String.format("%s price is %.2f RMB",
                                shop.getName(),
                                shop.getPrice(product)), executor)
                )
                .collect(toList());
        List<String> list = futures.stream()
                .map(CompletableFuture::join)
                .collect(toList());


        return list;
    }

注意:你現在創建的是一個由守護線程構建的線程池.java程序是沒有辦法終止或者退出一個正在運行的線程的,所以最後剩下的那個線程會由於一直等待無法發生的事件而引發問題 與此相反,如果將線程標記爲守護線程,意味着程序退出時,它也會被回收.這二者之間沒有性能之間的差異.

改進之後,我們再來測試一下5個商品的列表:

測試結果:

[淘寶 price is 3228.06 RMB, 天貓 price is 5183.54 RMB, 京東 price is 7699.38 RMB, 亞馬遜 price is 4071.39 RMB, 實體店 price is 5394.59 RMB]
Done in 1046 ms

使用 CompletableFuture 方案的程序處理5個商店僅耗時1021秒,處理9個商店 時耗時1022秒。一般而言,這種狀態會一直持續,直到商店的數目達到我們之前計算的閾值400。

這個例子證明了,要創建更適合你的應用特性的執行器.利用CompletableFuture像其提交任務執行是個不錯的注意.處理需要大量並行的操作時,這幾乎是最有效的策略

思考:並行—使用並行流還是CompletableFuture ???
目前爲止,你已經知道了,對於集合進行並行計算有兩種方法:

要麼將其轉化爲並行流,利用map這樣的操作開展工作.

要麼枚舉出集合中的每一個元素,創建新的線程,在CompletableFuture內對其進行操作.

後者提供了更多的靈活性,你可以調整線程池的大小,而這能幫助你確保整體的計算不會因爲線程都在等待 I/O 操作而發生阻塞.

我們對使用這些API的建議如下:

如果你進行的是計算密集型操作,並且沒有I/O,那麼推薦使用 Stream接口,因爲實現簡單,同時效率也可能是最高的, 即:如果所有的線程都是計算密集型操作,那麼就沒有必要創建比處理器核數還多的線程數

反之,如果你並行的計算單元還涉及等待I/O的操作(包括網絡連接等待),那麼使用CompletableFuture靈活性更好. 你可以想前文討論的那樣,根據等待計算,或者 W/C 的比率設置需要使用的線程數. 這種情況不適用並行流的另一個原因是:處理流流水線中如果發生I/O等待,流的延遲特性會讓我們很難判斷,到底什麼時候觸發了等待

現在你已經瞭解瞭如何利用 CompletableFuture 爲你的用戶提供異步API,以及如何將一 個同步又緩慢的服務轉換爲異步的服務。不過到目前爲止,我們每個 Future 中進行的都是單次 的操作。下一節中,你會看到如何將多個異步操作結合在一起,以流水線的方式運行,從描述形 式上,它與你在前面學習的Stream API有幾分類似。

對多個異步任務進行流水線操作.
讓我們假設所有的商店都同意使用一個集中式的折扣服務。該折扣服務提供了五個不同的折 扣代碼,每個折扣代碼對應不同的折扣率。你使用一個枚舉型變量 Discount.Code 來實現這一 想法,具體代碼如下所示。

/**
 * @author itguang
 * @create 2017-11-22 17:26
 **/
public class Discount {

    public enum Code {
        NONE(0), SILVER(5), GOLD(10), PLATINUM(15), DIAMOND(20);

        private final int percentage;

        Code(int percentage) {
            this.percentage = percentage;
        }
    }

   
}

我們還假設所有的商店都同意修改 getPrice 方法的返回格式。 getPrice 現在以 Shop- Name:price:DiscountCode 的格式返回一個 String 類型的值

   /**
     * (阻塞式)通過名稱查詢價格
     * @param product
     * @return 返回  Shop-Name:price:DiscountCode 的格式字符串
     */
    public String getPrice(String product) {

     double price = calculatePrice(product);
     //隨機得到一個折扣碼
        Discount.Code code = Discount.Code.values()[
                random.nextInt(Discount.Code.values().length)];
        return String.format("%s:%.2f:%s",name,price,code);
    }

調用 getPrice 方法可能會返回像下面這樣一個 String 值:

天貓:7360.13:PLATINUM

我們已經將對商店返回字符串的解析操作封裝到了下面的 Quote 類之中:

/**
 * @author itguang
 * @create 2017-11-22 17:26
 **/
public class Quote {

    private final String shopName;
    private final double price;
    private final Discount.Code discountCode;

    public Quote(String shopName, double price, Discount.Code discountCode) {
        this.shopName = shopName;
        this.price = price;
        this.discountCode = discountCode;
    }

    public static Quote parse(String s) {
        String[] split = s.split(":");
        String shopName = split[0];
        double price = Double.parseDouble(split[1]);
        Discount.Code discountCode = Discount.Code.valueOf(split[2]);
        return new Quote(shopName, price, discountCode);
    }

    public String getShopName() {
        return shopName;
    }

    public double getPrice() {
        return price;
    }

    public Discount.Code getDiscountCode() {
        return discountCode;
    }
}

通過傳遞 shop 對象返回的字符串給靜態工廠方法 parse ,你可以得到 Quote 類的一個實例, 它包含了 shop 的名稱、折扣之前的價格,以及折扣代碼。

Discount 服務還提供了一個 applyDiscount 方法,它接收一個 Quote 對象,返回一個字符 串,表示生成該 Quote 的 shop 中的折扣價格

/**
 * @author itguang
 * @create 2017-11-22 17:26
 **/
public class Discount {

    public enum Code {
        NONE(0), SILVER(5), GOLD(10), PLATINUM(15), DIAMOND(20);

        private final int percentage;

        Code(int percentage) {
            this.percentage = percentage;
        }
    }


    /**
     * 根據一個Quote返回一個折扣信息
     * @param quote
     * @return
     */
    public static String applyDiscount(Quote quote) {
        return quote.getShopName() + " price is " + Discount.apply(quote.getPrice(), quote.getDiscountCode());
    }

    /**
     * 根據價格和折扣計算折扣後的價格
     * @param price
     * @param code
     * @return
     */
    private static double apply(double price, Code code) {
        Util.delay(1000);//模擬Discount服務的響應延遲
        return Util.format(price * (100 - code.percentage) / 100);
    }

}

現在準備工作已經完成,我們開始使用 Discount服務

首先我麼以最簡單的方式實現findPrice方法

/**
     * 得到折扣商店信息(已經被解析過)
     */
    public List<String> findPrice1(String product){
        List<String> list = discountShops.stream()
                .map(discountShop -> discountShop.getPrice(product))
                .map(Quote::parse)
                .map(Discount::applyDiscount)
                .collect(toList());

        return list;
    }

測試結果:

[淘寶 price is 2190.59, 天貓 price is 6820.3, 京東 price is 9653.26, 亞馬遜 price is 8620.79, 實體店 price is 3973.24]
Done in 10055 ms

毫無意外,這次執行耗時10秒,因爲順序查詢5個商店耗時大約5秒,現在又加上了 Discount 服務爲5個商店返回的價格申請折扣所消耗的5秒鐘.

你已經知道把流轉換爲並行方式,非常容易提升程序的性能.不過,我們也知道當商店的數目多餘默認線程池中線程的數目時,效果並不好.因爲 Stream 底層依賴的是線程數量固定的通用線程池

相反,我們也知道,對於非計算密集型操作(大部分時間都在等待,CPU並沒有執行運算),我們使用自定義 CompletableFuture 的執行器 Executor,能更充分的利用CPU資源.

讓我們再次使用 CompletableFuture 提供的特性,以異步方式重新實現 findPrices 方法。 詳細代碼如下所示。如果你發現有些內容不太熟悉,不用太擔心,我們很快會進行針對性的介紹。

 /**
     * 使用 CompletableFuture 實現 findPrices 方法
     */
    public List<String> findPrice2(String product) {
        //爲“最優價格查詢器”應用定製的執行器 Execotor
        Executor executor = Executors.newFixedThreadPool(Math.min(discountShops.size(), 100),
                new ThreadFactory() {
                    @Override
                    public Thread newThread(Runnable r) {
                        Thread thread = new Thread(r);
                        //使用守護線程,使用這種方式不會阻止程序的關停
                        thread.setDaemon(true);
                        return thread;
                    }
                }
        );

        List<CompletableFuture<String>> futureList = discountShops.stream()
                .map(discountShop -> CompletableFuture.supplyAsync(
                        //異步方式取得商店中產品價格
                        () -> discountShop.getPrice(product), executor))
                .map(future -> future.thenApply(Quote::parse))
                .map(future -> future.thenCompose(
                        quote -> CompletableFuture.supplyAsync(
                                //使用另一個異步任務訪問折扣服務
                                () -> Discount.applyDiscount(quote), executor
                        )
                ))
                .collect(toList());

        //等待流中所有future執行完畢,並提取各自的返回值.
        List<String> list = futureList.stream()
                //join想但與future中的get方法,只是不會拋出異常
                .map(CompletableFuture::join)
                .collect(toList());

        return list;
    }

這一次,事情看起來變得更加複雜了,所以讓我們一步一步地理解到底發生了什麼。這三次 轉換的流程如圖11-5所示

這三次map操作都是用了CompletableFuture 類提供的特性,在需要的地方把它們變成了異步操作.

  1. 獲取價格 這三個操作的第一個你已經見過很多次,只需要將lambda表達式傳遞給supplyAsync 工廠方法就可以以一部方式對shop進行查詢, 第一個轉換的結果是一個Stream 類型的列表, 一旦轉換結束,每個CompletableFuture對象中都包含對應shop返回的字符串.

  2. 解析報價 現在你需要進行第二步轉換操作,將字符串轉換爲 Quote對象,由於解析操作不涉及遠程服務等耗時操作,他幾乎可以在第一時間進行,所以可以採用同步操作.不會帶來太多延遲. 因此,你可以對上面生成的CompletableFuture對象調用thenApply而不是supplyAsync,將以個把字符串轉換爲Quote對象的方法傳遞給他. 注意:直到上面的 CompletableFuture 執行結束, thenApply 方法纔會開始執行,將 Stream 中的每個 CompletableFuture 對象轉換爲對應的CompletableFuture 對象

  3. 獲取折扣服務

第三個map操作又涉及遠程操作,爲從商店得到的原始價格申請折扣率,因此,你也希望它能夠異步執行. 爲了實現這一操作,你像第一個調用傳遞 getPrice 給 supplyAsync 那樣,將applyDiscount(quote) 方法以lambda表達式的方法傳遞給了supplyAsync工廠方法. 該方法最終會返回一個CompletableFuture對象的列表,到目前爲止,你已經進行了兩次異步操作,用了兩個不同的CompletableFuture對象進行建模,你希望把它們以級聯的方式串聯起來進行工作.

java8 CompletableFuture API提供了名爲 thenCompose 的方法,thenCompose操作允許你對兩個異步操作執行流水線.即第一個異步操作完成後,把結果作爲參數傳遞給第二個異步操作

將這這三次map操作的結果收集到一個列表,你就得到了List ,等這些CompletableFuture對象最終執行完畢,你就可以利用join取得他們的返回值.

測試結果

[淘寶 price is 5604.87, 天貓 price is 3289.73, 京東 price is 6447.24, 亞馬遜 price is 10259.54, 實體店 price is 11947.6]
Done in 2070 ms

thenCompose()方法像大多數CompletableFuture的方法一樣,也提供了一個 thenComposeAsync 方法. 通常而言,名字中不帶Async的方法,和它的前一個任務一樣,在同一線程中運行;而名字以Async結尾的方法會將後續的任務提交到一個線程池,所以每個任務是由不同任務處理的.

將兩個 CompletableFuture 對象整合起來,無論它們是否存在依賴
上節代碼中, 你對一個 CompletableFuture 對象調用了 thenCompose 方法,並向其傳遞了第二個 CompletableFuture ,而第二個 CompletableFuture 又需要使用第一個 CompletableFuture 的執行結果作爲輸入。但是,另一種比較常見的情況是,你需要將兩個完 全不相干的 CompletableFuture 對象的結果整合起來,而且你也不希望等到第一個任務完全結 束纔開始第二項任務。

這種情況,你應當使用 thenCombine 方法,它接收名爲 BiFunction 的第二參數,這個參數 定義了當兩個 CompletableFuture 對象完成計算後,結果如何合併。同 thenCompose 方法一樣, thenCombine 方法也提供有一個 Async 的版本。這裏,如果使用 thenCombineAsync 會導致 BiFunction 中定義的合併操作被提交到線程池中,由另一個任務以異步的方式執行

Future<Double> futurePriceInUSD =
        CompletableFuture.supplyAsync(() -> shop.getPrice(product))
        //接受兩個參數,一個是 CompletableFuture,第二個參數定義了兩個 CompletableFuture 對象完成計算後,結果如何合併。
        .thenCombine(
            CompletableFuture.supplyAsync(
            () -> exchangeService.getRate(Money.EUR, Money.USD)),
            (price, rate) -> price * rate
);

小結

  • 在執行邊角耗時的操作時,尤其是那些依賴遠程服務的操作,使用異步任務,可以極大的改善程序的性能.

  • 在設計API時,你應該儘可能的爲客戶提供異步的API.使用CompletableFuture 類提供的特性,你能輕鬆實現這一目標.

  • CompletableFuture 還提供了異常管理機制,讓你有機會拋出並管理異步任務中所發生的異常.

  • 將同步的API封裝到CompletableFuture 中,你能夠以異步的方式使用其結果.

  • 如果異步任務之間相互獨立,或者說一個異步任務時另一些異步任務的輸入,你可以將這些異步任務構造或者合併成一個.

  • 你可以爲 CompletableFuture 註冊一個回調函數,在Future執行完畢或者結果可用時,針對性的執行一些操作.

  • 你可以決定什麼時候結束程序的運行,是等待由CompletableFuture 構成的列表中所有對象都執行完畢,還是其中一個首先完成就終止程序的運行.

引用《java實戰》

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