Java8 - Stream API快速入門

王知無發表於2019-04-04

Java8旨在幫助程式設計師寫出更好的程式碼,
其對核心類庫的改進也是關鍵的一部分,Stream是Java8種處理集合的抽象概念,
它可以指定你希望對集合的操作,但是執行操作的時間交給具體實現來決定。

為什麼需要Stream?

Java語言中集合是使用最多的API,幾乎每個Java程式都會用到集合操作,
這裡的Stream和IO中的Stream不同,它提供了對集合操作的增強,極大的提高了操作集合物件的便利性。

集合對於大多數程式設計任務而言都是基本的,為了解釋集合是怎麼工作,我們想象一下當下最火的外賣APP,
當我們點菜的時候需要按照距離價格銷量等進行排序後篩選出自己滿意的菜品。
你可能想選擇距離自己最近的一家店鋪點菜,儘管用集合可以完成這件事,但集合的操作遠遠算不上完美。

假如讓你編寫上面示例中的程式碼,你可能會寫出如下:

// 店鋪屬性
public class Property {
    String  name;
    // 距離,單位:米
    Integer distance;
    // 銷量,月售
    Integer sales;
    // 價格,這裡簡單起見就寫一個級別代表價格段
    Integer priceLevel;
    public Property(String name, int distance, int sales, int priceLevel) {
        this.name = name;
        this.distance = distance;
        this.sales = sales;
        this.priceLevel = priceLevel;
    }
    // getter setter 省略
}
複製程式碼

我想要篩選距離我最近的店鋪,你可能會寫下這樣的程式碼:

public static void main(String[] args) {
    Property p1 = new Property("叫了個雞", 1000, 500, 2);
    Property p2 = new Property("張三丰餃子館", 2300, 1500, 3);
    Property p3 = new Property("永和大王", 580, 3000, 1);
    Property p4 = new Property("肯德基", 6000, 200, 4);
    List<Property> properties = Arrays.asList(p1, p2, p3, p4);
    Collections.sort(properties, (x, y) -> x.distance.compareTo(y.distance));
    String name = properties.get(0).name;
    System.out.println("距離我最近的店鋪是:" + name);
}
複製程式碼

這裡也使用了部分lambda表示式,在Java8之前你可能寫的更痛苦一些。
要是要處理大量元素又該怎麼辦呢?為了提高效能,你需要並行處理,並利用多核架構。
但寫並行程式碼比用迭代器還要複雜,而且除錯起來也夠受的!

Stream中操作這些東西當然是非常簡單的,小試牛刀:

// Stream操作
String name2 = properties.stream()
                .sorted(Comparator.comparingInt(x -> x.distance))
                .findFirst()
                .get().name;
System.out.println("距離我最近的店鋪是:" + name);
複製程式碼

新的API對所有的集合操作都提供了生成流操作的方法,寫的程式碼也行雲流水,我們非常簡單的就篩選了離我最近的店鋪。
在後面我們繼續講解Stream更多的特性和玩法。

外部迭代和內部迭代

當你處理集合時,通常會對它進行迭代,然後處理返回的每個元素。比如我想看看月銷量大於1000的店鋪個數。

使用for迴圈進行迭代

int count = 0;
for (Property property : properties) {
    if(property.sales > 1000){
        count++;
    }
}
複製程式碼

上面的操作是可行的,但是當每次迭代的時候你需要些很多重複的程式碼。將for迴圈修改為並行執行也非常困難,
需要修改每個for的實現。

從集合背後的原理來看,for迴圈封裝了迭代的語法糖,首先呼叫iterator方法,產生一個Iterator物件,
然後控制整個迭代,這就是外部迭代。迭代的過程通過呼叫Iterator物件的hasNext和next方法完成。

使用迭代器進行計算

int count = 0;
Iterator<Property> iterator = properties.iterator();
while(iterator.hasNext()){
    Property property = iterator.next();
    if(property.sales > 1000){
        count++;
    }
}
複製程式碼

而迭代器也是有問題的。它很難抽象出未知的不能操作;此外它本質上還是序列化的操作,總體來看使用
for迴圈會將行為和方法混為一談。

另一種辦法是使用內部迭代完成,properties.stream()該方法返回一個Stream而不是迭代器。

使用內部迭代進行計算

long count = properties.stream()
                .filter(p -> p.sales > 1000)
                .count();
複製程式碼

上述程式碼是通過Stream API完成的,我們可以把它理解為2個步驟:

  1. 找出所有銷量大於1000的店鋪
  2. 計算出店鋪個數

為了找出銷量大於1000的店鋪,需要先做一次過濾:filter,你可以看看這個方法的入參就是前面講到的Predicate斷言型函式式介面,
測試一個函式完成後,返回值為boolean。
由於Stream API的風格,我們沒有改變集合的內容,而是描述了Stream的內容,最終呼叫count()方法計算出Stream
裡包含了多少個過濾之後的物件,返回值為long。

建立Stream

你已經知道Java8種在Collection介面新增了Stream方法,可以將任何集合轉換成一個Stream。
如果你操作的是一個陣列可以使用Stream.of(1, 2, 3)方法將它轉換為一個流。

也許有人知道JDK7中新增了一些類庫如Files.readAllLines(Paths.get("/home/biezhi/a.txt"))這樣的讀取檔案行方法。
List作為Collection的子類擁有轉換流的方法,那麼我們讀取這個文字檔案到一個字串變數中將變得更簡潔:

String content = Files.readAllLines(Paths.get("/home/biezhi/a.txt")).stream()
            .collect(Collectors.joining("\n"));
複製程式碼

這裡的collect是後面要講解的收集器,對Stream進行了處理後得到一個文字檔案的內容。

JDK8也為我們提供了一些便捷的Stream相關類庫:

Java8 - Stream API快速入門

建立一個流是很簡單的,下面我們試試用建立好的Stream做一些操作吧。

流操作

java.util.stream.Stream中定義了許多流操作的方法,為了更好的理解Stream API掌握它常用的操作非常重要。
流的操作其實可以分為兩類:處理操作聚合操作

  • 處理操作:諸如filter、map等處理操作將Stream一層一層的進行抽離,返回一個流給下一層使用。
  • 聚合操作:從最後一次流中生成一個結果給呼叫方,foreach只做處理不做返回。

filter

filter看名字也知道是過濾的意思,我們通常在篩選資料的時候用到,頻率非常高。
filter方法的引數是Predicate<T> predicate即一個從T到boolean的函式。


Java8 - Stream API快速入門

篩選出距離我在1000米內的店鋪

properties.stream()
            .filter(p -> p.distance < 1000)
複製程式碼

篩選出名稱大於5個字的店鋪

properties.stream()
            .filter(p -> p.name.length() > 5);
複製程式碼

map

有時候我們需要將流中處理的資料型別進行轉換,這時候就可以使用map方法來完成,將流中的值轉換為一個新的流。


Java8 - Stream API快速入門


列出所有店鋪的名稱

properties.stream()
            .map(p -> p.name);
複製程式碼

傳給map的lambda表示式接收一個Property型別的引數,返回一個String。
引數和返回值不必屬於同一種型別,但是lambda表示式必須是Function介面的一個例項。

flatMap

有時候我們會遇到提取子流的操作,這種情況用的不多但是遇到flatMap將變得更容易處理。


Java8 - Stream API快速入門

例如我們有一個List<List<String>>結構的資料:

List<List<String>> lists = new ArrayList<>();
lists.add(Arrays.asList("apple", "click"));
lists.add(Arrays.asList("boss", "dig", "qq", "vivo"));
lists.add(Arrays.asList("c#", "biezhi"));
複製程式碼

要做的操作是獲取這些資料中長度大於2的單詞個數

lists.stream()
        .flatMap(Collection::stream)
        .filter(str -> str.length() > 2)
        .count();
複製程式碼

在不使用flatMap前你可能需要做2次for迴圈。這裡呼叫了List的stream方法將每個列表轉換成Stream物件,
其他的就和之前的操作一樣。

max和min

Stream中常用的操作之一是求最大值和最小值,Stream API 中的max和min操作足以解決這一問題。

我們需要篩選出價格最低的店鋪:

Property property = properties.stream()
            .max(Comparator.comparingInt(p -> p.priceLevel))
            .get();
複製程式碼

查詢Stream中的最大或最小元素,首先要考慮的是用什麼作為排序的指標。
以查詢價格最低的店鋪為例,排序的指標就是店鋪的價格等級

為了讓Stream物件按照價格等級進行排序,需要傳給它一個Comparator物件。
Java8提供了一個新的靜態方法comparingInt,使用它可以方便地實現一個比較器。
放在以前,我們需要比較兩個物件的某項屬性的值,現在只需要提供一個存取方法就夠了。

收集結果

通常我們處理完流之後想檢視一下結果,比如獲取總數,轉換結果,在前面的示例中你發現呼叫了
filter、map之後沒有下文了,後續的操作應該呼叫Stream中的collect方法完成。

獲取距離我最近的2個店鋪

List<Property> properties = properties.stream()
            .sorted(Comparator.comparingInt(x -> x.distance))
            .limit(2)
            .collect(Collectors.toList());
複製程式碼

獲取所有店鋪的名稱

List<String> names = properties.stream()
                      .map(p -> p.name)
                      .collect(Collectors.toList());
複製程式碼

獲取每個店鋪的價格等級

Map<String, Integer> map = properties.stream()
        .collect(Collectors.toMap(Property::getName, Property::getPriceLevel));
複製程式碼

所有價格等級的店鋪列表

Map<Integer, List<Property>> priceMap = properties.stream()
                .collect(Collectors.groupingBy(Property::getPriceLevel));
複製程式碼

並行資料處理

並行和併發

併發是兩個任務共享時間段,並行則是兩個任務在同一時間發生,比如執行在多核CPU上。
如果一個程式要執行兩個任務,並且只有一個CPU給它們分配了不同的時間片,那麼這就是併發,而不是並行。

並行化是指為縮短任務執行時間,將一個任務分解成幾部分,然後並行執行。

這和順序執行的任務量是一樣的,區別就像用更多的馬來拉車,花費的時間自然減少了。
實際上,和順序執行相比,並行化執行任務時,CPU承載的工作量更大。

資料並行化是指將資料分成塊,為每塊資料分配單獨的處理單元。

還是拿馬拉車那個例子打比方,就像從車裡取出一些貨物,放到另一輛車上,兩輛馬車都沿著同樣的路徑到達目的地。

當需要在大量資料上執行同樣的操作時,資料並行化很管用。
它將問題分解為可在多塊資料上求解的形式,然後對每塊資料執行運算,最後將各資料塊上得到的結果彙總,從而獲得最終答案。

人們經常拿任務並行化和資料並行化做比較,在任務並行化中,執行緒不同,工作各異。
我們最常遇到的JavaEE應用容器便是任務並行化的例子之一,每個執行緒不光可以為不同使用者服務,
還可以為同一個使用者執行不同的任務,比如登入或往購物車新增商品。

Stream並行流

流使得計算變得容易,它的操作也非常簡單,但你需要遵守一些約定。預設情況下我們使用集合的stream方法
建立的是一個序列流,你有兩種辦法讓他變成並行流。

  1. 呼叫Stream物件的parallel方法
  2. 建立流的時候呼叫parallelStream而不是stream方法

我們來用具體的例子來解釋序列和並行流

序列化計算

篩選出價格等級小於4,按照距離排序的2個店鋪名

properties.stream()
            .filter(p -> p.priceLevel < 4)
            .sorted(Comparator.comparingInt(Property::getDistance))
            .map(Property::getName)
            .limit(2)
            .collect(Collectors.toList());
複製程式碼

呼叫 parallelStream 方法即能並行處理

properties.parallelStream()
            .filter(p -> p.priceLevel < 4)
            .sorted(Comparator.comparingInt(Property::getDistance))
            .map(Property::getName)
            .limit(2)
            .collect(Collectors.toList());
複製程式碼

讀到這裡,大家的第一反應可能是立即將手頭程式碼中的stream方法替換為parallelStream方法,
因為這樣做簡直太簡單了!先別忙,為了將硬體物盡其用,利用好並行化非常重要,但流類庫提供的資料並行化只是其中的一種形式。

我們先要問自己一個問題:並行化執行基於流的程式碼是否比序列化執行更快?這不是一個簡單的問題。
回到前面的例子,哪種方式花的時間更多取決於序列或並行化執行時的環境。


Java8 - Stream API快速入門


相關文章