Java 8新特性(二):Stream API

一書生VOID發表於2018-01-25
Java 8新特性(二):Stream API

本文首發於一書生VOID的部落格。 原文連結:Java 8新特性(二):Stream API


本篇文章繼續介紹Java 8的另一個新特性——Stream API。新增的Stream API與InputStreamOutputStream是完全不同的概念,Stream API是對Java中集合操作的增強,可以利用它進行各種過濾、排序、分組、聚合等操作。Stream API配合Lambda表示式可以加大的提高程式碼可讀性和編碼效率,Stream API也支援並行操作,我們不用再花費很多精力來編寫容易出錯的多執行緒程式碼了,Stream API已經替我們做好了,並且充分利用多核CPU的優勢。藉助Stream API和Lambda,開發人員可以很容易的編寫出高效能的併發處理程式。

Stream API簡介

Stream API是Java 8中加入的一套新的API,主要用於處理集合操作,不過它的處理方式與傳統的方式不同,稱為“資料流處理”。流(Stream)類似於關聯式資料庫的查詢操作,是一種宣告式操作。比如要從資料庫中獲取所有年齡大於20歲的使用者的名稱,並按照使用者的建立時間進行排序,用一條SQL語句就可以搞定,不過使用Java程式實現就會顯得有些繁瑣,這時候可以使用流:

List<String> userNames =
        users.stream()
        .filter(user -> user.getAge() > 20)
        .sorted(comparing(User::getCreationDate))
        .map(User::getUserName)
        .collect(toList());
複製程式碼

可以把流跟集合做一個比較。在Java中,集合是一種資料結構,或者說是一種容器,用於存放資料,流不是容器,它不關心資料的存放,只關注如何處理。可以把流當做是Java中的Iterator,不過它比Iterator強大多了。

流與集合另一個區別在於他們的遍歷方式,遍歷集合通常使用for-each方式,這種方式稱為外部迭代,而流使用內部迭代方式,也就是說它幫你把迭代的工作做了,你只需要給出一個函式來告訴它接下來要幹什麼:

// 外部迭代
List<String> list = Arrays.asList("A", "B", "C", "D");
for (String str : list) {
    System.out.println(str);
}

// 內部迭代
list.stream().forEach(System.out::println);
複製程式碼

在這個大資料的時代,資料變得越來越多樣化,很多時候我們會面對海量資料,並對其做一些複雜的操作(比如統計,分組),依照傳統的遍歷方式(for-each),每次只能處理集合中的一個元素,並且是按順序處理,這種方法是極其低效的。你也許會想到並行處理,但是編寫多執行緒程式碼並非易事,很容易出錯並且維護困難。不過在Java 8之後,你可以使用Stream API來解決這一問題。

Stream API將迭代操作封裝到了內部,它會自動的選擇最優的迭代方式,並且使用並行方式處理時,將集合分成多段,每一段分別使用不同的執行緒處理,最後將處理結果合併輸出。

需要注意的是,流只能遍歷一次,遍歷結束後,這個流就被關閉掉了。如果要重新遍歷,可以從資料來源(集合)中重新獲取一個流。如果你對一個流遍歷兩次,就會丟擲java.lang.IllegalStateException異常:

List<String> list = Arrays.asList("A", "B", "C", "D");
Stream<String> stream = list.stream();
stream.forEach(System.out::println);
stream.forEach(System.out::println); // 這裡會丟擲java.lang.IllegalStateException異常,因為流已經被關閉
複製程式碼

流通常由三部分構成:

  1. 資料來源:資料來源一般用於流的獲取,比如本文開頭那個過濾使用者的例子中users.stream()方法。
  2. 中間處理:中間處理包括對流中元素的一系列處理,如:過濾(filter()),對映(map()),排序(sorted())。
  3. 終端處理:終端處理會生成結果,結果可以是任何不是流值,如List<String>;也可以不返回結果,如stream.forEach(System.out::println)就是將結果列印到控制檯中,並沒有返回。

建立流

建立流的方式有很多,具體可以劃分為以下幾種:

由值建立流

使用靜態方法Stream.of()建立流,該方法接收一個變長引數:

Stream<Stream> stream = Stream.of("A", "B", "C", "D");
複製程式碼

也可以使用靜態方法Stream.empty()建立一個空的流:

Stream<Stream> stream = Stream.empty();
複製程式碼

由陣列建立流

使用靜態方法Arrays.stream()從陣列建立一個流,該方法接收一個陣列引數:

String[] strs = {"A", "B", "C", "D"};
Stream<Stream> stream = Arrays.stream(strs);
複製程式碼

通過檔案生成流

使用java.nio.file.Files類中的很多靜態方法都可以獲取流,比如Files.lines()方法,該方法接收一個java.nio.file.Path物件,返回一個由檔案行構成的字串流:

Stream<String> stream = Files.lines(Paths.get("text.txt"), Charset.defaultCharset());
複製程式碼

通過函式建立流

java.util.stream.Stream中有兩個靜態方法用於從函式生成流,他們分別是Stream.generate()Stream.iterate()

// iteartor
Stream.iterate(0, n -> n + 2).limit(51).forEach(System.out::println);

// generate
Stream.generate(() -> "Hello Man!").limit(10).forEach(System.out::println);
複製程式碼

第一個方法會列印100以內的所有偶數,第二個方法列印10個Hello Man!。值得注意的是,這兩個方法生成的流都是無限流,沒有固定大小,可以無窮的計算下去,在上面的程式碼中我們使用了limit()來避免列印無窮個值。

一般來說,iterate()用於生成一系列值,比如生成以當前時間開始之後的10天的日期:

Stream.iterate(LocalDate.now(), date -> date.plusDays(1)).limit(10).forEach(System.out::println);
複製程式碼

generate()方法用於生成一些隨機數,比如生成10個UUID:

Stream.generate(() -> UUID.randomUUID().toString()).limit(10).forEach(System.out::println);
複製程式碼

使用流

Stream介面中包含許多對流操作的方法,這些方法分別為:

  • filter():對流的元素過濾
  • map():將流的元素對映成另一個型別
  • distinct():去除流中重複的元素
  • sorted():對流的元素排序
  • forEach():對流中的每個元素執行某個操作
  • peek():與forEach()方法效果類似,不同的是,該方法會返回一個新的流,而forEach()無返回
  • limit():擷取流中前面幾個元素
  • skip():跳過流中前面幾個元素
  • toArray():將流轉換為陣列
  • reduce():對流中的元素歸約操作,將每個元素合起來形成一個新的值
  • collect():對流的彙總操作,比如輸出成List集合
  • anyMatch():匹配流中的元素,類似的操作還有allMatch()noneMatch()方法
  • findFirst():查詢第一個元素,類似的還有findAny()方法
  • max():求最大值
  • min():求最小值
  • count():求總數

下面逐一介紹這些方法的用法。

過濾和排序

Stream.of(1, 8, 5, 2, 1, 0, 9, 2, 0, 4, 8)
    .filter(n -> n > 2)     // 對元素過濾,保留大於2的元素
    .distinct()             // 去重,類似於SQL語句中的DISTINCT
    .skip(1)                // 跳過前面1個元素
    .limit(2)               // 返回開頭2個元素,類似於SQL語句中的SELECT TOP
    .sorted()               // 對結果排序
    .forEach(System.out::println);
複製程式碼

查詢和匹配

Stream中提供的查詢方法有anyMatch()allMatch()noneMatch()findFirst()findAny(),這些方法被用來查詢或匹配某些元素是否符合給定的條件:

// 檢查流中的任意元素是否包含字串"Java"
boolean hasMatch = Stream.of("Java", "C#", "PHP", "C++", "Python")
        .anyMatch(s -> s.equals("Java"));

// 檢查流中的所有元素是否都包含字串"#"
boolean hasAllMatch = Stream.of("Java", "C#", "PHP", "C++", "Python")
        .allMatch(s -> s.contains("#"));

// 檢查流中的任意元素是否沒有以"C"開頭的字串
boolean hasNoneMatch = Stream.of("Java", "C#", "PHP", "C++", "Python")
        .noneMatch(s -> s.startsWith("C"));

// 查詢元素
Optional<String> element = Stream.of("Java", "C#", "PHP", "C++", "Python")
        .filter(s -> s.contains("C"))
        // .findFirst()     // 查詢第一個元素
        .findAny();         // 查詢任意元素
複製程式碼

注意最後一行程式碼的返回型別,是一個Optional<T>類(java.util.Optional),它一個容器類,代表一個值存在或不存在。上面的程式碼中,findAny()可能什麼元素都沒找到。Java 8的庫設計人員引入了Optional<T>,這樣就不用返回眾所周知容易出問題的null了。有關Optional<T>類的詳細用法,將在下一篇文章中介紹。

實際上測試結果發現,findFirst()findAny()返回的都是第一個元素,那麼兩者之間到底有什麼區別?通過檢視javadoc描述,大致意思是findAny()是為了提高並行操作時的效能,如果沒有特別需要,還是建議使用findAny()方法。

歸約

歸約操作就是將流中的元素進行合併,形成一個新的值,常見的歸約操作包括求和,求最大值或最小值。歸約操作一般使用reduce()方法,與map()方法搭配使用,可以處理一些很複雜的歸約操作。

// 獲取流
List<Book> books = Arrays.asList(
       new Book("Java程式設計思想", "Bruce Eckel", "機械工業出版社", 108.00D),
       new Book("Java 8實戰", "Mario Fusco", "人民郵電出版社", 79.00D),
       new Book("MongoDB權威指南(第2版)", "Kristina Chodorow", "人民郵電出版社", 69.00D)
);

// 計算所有圖書的總價
Optional<Double> totalPrice = books.stream()
       .map(Book::getPrice)
       .reduce((n, m) -> n + m);

// 價格最高的圖書
Optional<Book> expensive = books.stream().max(Comparator.comparing(Book::getPrice));
// 價格最低的圖書
Optional<Book> cheapest = books.stream().min(Comparator.comparing(Book::getPrice));
// 計算總數
long count = books.stream().count()
複製程式碼

在計算圖書總價的時候首先使用map()方法得到所有圖書價格的流,然後再使用reduce()方法進行歸約計算。與map()方法類似的還有一個flatMap()flatMap()方法讓你把一個流中的每個值都換成另一個流,然後把所有的流連線起來成為一個新的流。看看下面的程式碼:

List<String[]> result = Stream.of("Hello Man")
        .map(s -> s.split(""))
        .collect(Collectors.toList());
複製程式碼

上面程式碼返回的結果是一個List<String[]>型別,也就是[["H", "e", "l", "l", "o"], ["M", "a", "n"]]這種結構,而我們想要的到["H", "e", "l", "l", "o", "M", "a", "n"]這種結構,這時候就需要使用flatMap()方法了:

List<String> result = Stream.of("Hello Man")
        .map(s -> s.split(""))
        .flatMap(Arrays::stream)
        .collect(Collectors.toList());
複製程式碼

使用flatMap()方法的效果是,各個陣列並不是分別對映成一個流,而是對映成流的內容。所有使用map(Arrays::stream)時生成的單個流都被合併起來,也就是對流扁平化操作。

資料收集

前面兩部分內容分別為流式資料處理的前兩個步驟:從資料來源建立流、使用流進行中間處理。下面我們介紹流式資料處理的最後一個步驟——資料收集。

資料收集是流式資料處理的終端處理,與中間處理不同的是,終端處理會消耗流,也就是說,終端處理之後,這個流就會被關閉,如果再進行中間處理,就會丟擲異常。資料收集主要使用collect方法,該方法也屬於歸約操作,像reduce()方法那樣可以接收各種做法作為引數,將流中的元素累積成一個彙總結果,具體的做法是通過定義新的Collector介面來定義的。

在前面部分的例子中使用收集器(Collector)是由java.util.stream.Collectors工具類中的toList()方法提供,Collectors類提供了許多常用的方法用於處理資料收集,常見的有歸約、彙總、分組等。

歸約和彙總

我們使用前面歸約操作中計算圖書總價,最大值,最小值,輸入總數那個例子來看看收集器如何進行上述歸約操作:

// 求和
long count = books.stream().collect(counting());

// 價格最高的圖書
Optional<Book> expensive = books.stream().collect(maxBy(comparing(Book::getPrice)));

// 價格最低的圖書
Optional<Book> cheapest = books.stream().collect(minBy(comparing(Book::getPrice)));
複製程式碼

上面的程式碼假設你已經使用靜態匯入了CollectorsComparator兩個類,這樣你就不用再去寫Collectors.counting()Comparator.comparing()這樣的程式碼了:

import static java.util.stream.Collectors.*;
import static java.util.Comparator.*;
複製程式碼

Collectors工具類為我們提供了用於彙總的方法,包括summarizingInt()summarizingLong()summarizingDouble(),由於圖書的價格為Double型別,所以我們使用summarizingDouble()方法進行彙總。該方法會返回一個DoubleSummaryStatistics物件,包含一系列歸約操作的方法,如:彙總、計算平均數、最大值、最小值、計算總數:

DoubleSummaryStatistics dss = books.stream().collect(summarizingDouble(Book::getPrice));
double sum = dss.getSum();          // 彙總
double average = dss.getAverage();  // 求平均數
long count = dss.getCount();        // 計算總數
double max = dss.getMax();          // 最大值
double min = dss.getMin();          // 最小值
複製程式碼

Collectors類還包含一個joining()方法,該方法用於連線字串:

String str = Stream.of("A", "B", "C", "D").collect(joining(","));
複製程式碼

上面的程式碼用於將流中的字串通過逗號連線成一個新的字串。

分組

和關聯式資料庫一樣,流也提供了類似於資料庫中GROUP BY分組的特性,由Collectors.groupingBy()方法提供:

Map<String, List<Book>> booksGroup = books.stream().collect(groupingBy(Book::getPublisher));
複製程式碼

上面的程式碼按照出版社對圖書進行分組,分組的結果是一個Map物件,Mapkey值是出版社的名稱,value值是每個出版社分組對應的集合。分組方法groupingBy()接收一個Function介面作為引數,上面的例子中我們使用了方法引用傳遞了出版社作為分組的依據,但實際情況可能比這複雜,比如將價格在0-50之間的書籍分成一組,50-100之間的分成一組,超過100的分成一組,這時候,我們可以直接使用Lambda表示式來表示這個分組邏輯:

Map<String, List<Book>> booksGroup = books
    .stream()
    .collect(groupingBy(book -> {
        if (book.getPrice() > 0 && book.getPrice() <= 50) {
            return "A";
        } else if (book.getPrice() > 50 && book.getPrice() <=100) {
            return "B";
        } else {
            return "C";
        }
    }));
複製程式碼

groupingBy()方法還支援多級分組,他有一個過載方法,除了接收一個Function型別的引數外,還接收一個Collector型別的引數:

Map<String, Map<String, List<Book>>> booksGroup = books.stream().collect(
        groupingBy(Book::getPublisher, groupingBy(book -> {
            if (book.getPrice() > 0 && book.getPrice() <= 50) {
                return "A";
            } else if (book.getPrice() > 50 && book.getPrice() <=100) {
                return "B";
            } else {
                return "C";
            }
        }))
);
複製程式碼

上面的程式碼將之前兩個分組合併成一個,實現了多級分組,首先按照出版社進行分組,然後按照價格進行分組,返回型別是一個Map<String, Map<String, List<Book>>>groupingBy()的第二個引數可以是任意型別,只要是Collector介面的例項就可以,比如先分組,再統計數量:

Map<String, Long> countGroup = books.stream()
        .collect(groupingBy(Book::getPublisher, counting()));
複製程式碼

還可以在進行分組後獲取每組中價格最高的圖書:

Map<String, Book> expensiveGroup = books.stream()
        .collect(groupingBy(Book::getPublisher, collectingAndThen(
            maxBy(comparingDouble(Book::getPrice)),
                Optional::get
        )));
複製程式碼

並行資料處理

在Java 7之前,處理並行資料集合非常麻煩,首先需要將一個龐大資料集合分成幾個子集合;然後需要為每一個子集合編寫多執行緒處理程式,還需要對他們做執行緒同步來避免訪問共享變數導致處理結果不準確;最後,等待所有執行緒處理完畢後將處理結果合併。在Java 7之後新新增了一個fork/join的框架,讓這一切變得更加簡單。

並行流

並行流使用集合的parallelStream()方法可以獲取一個並行流。Java內部會將流的內容分割成若干個子部分,然後將它們交給多個執行緒並行處理,這樣就將工作的負擔交給多核CPU的其他核心處理。

我們通過一個簡單粗暴的例子演示並行流的處理效能。假設有一個方法,接受一個數字n作為引數,返回從1到n的所有自然數之和:

public static long sequentialSum(long n) {
	return Stream.iterate(1L, i -> i + 1)
			.limit(n)
			.reduce(0L, Long::sum);
}
複製程式碼

上面的方法也可以通過傳統的for迴圈方式實現:

public static long iterativeSum(long n) {
	long result = 0;
	for (long i = 1L; i <= n; i++) {
		result += i;
	}
	return result;
}
複製程式碼

編寫測試程式碼:

public static void main(String[] args) {
	long number = 10000000L;
    System.out.println("Sequential Sum: " + sumPerformanceTest(StreamTest::sequentialSum, number) + " 毫秒");
    System.out.println("Iterative Sum: " + sumPerformanceTest(StreamTest::iterativeSum, number) + " 毫秒");
}

public static long sumPerformanceTest(Function<Long, Long> function, long number) {
	long maxValue = Long.MAX_VALUE;

	for (int i=0; i<10; i++) {
		long start = System.nanoTime();
		long sum = function.apply(n);
		long end = System.nanoTime();
		System.out.println("Result: " + sum);
		long time = ( end - start ) / 1000000;

		if (time < maxValue) {
			maxValue = time;
		}
	}

	return maxValue;
}
複製程式碼

為了方便測試,我們編寫一個sumPerformanceTest()方法,引數number表示給定的一個數,用於計算從1到這個數的所有自然數之和。該方法內部執行10次運算,返回時間最短的一次運算結果。

執行上面的程式碼,可以在控制檯看到如下結果:

Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Sequential Sum: 159 毫秒
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Iterative Sum: 5 毫秒
複製程式碼

可以看出,採用傳統的for迴圈更快,因為它不用做任何自動拆箱/裝箱操作,操作的都是基本型別。這個測試結果並不客觀,提升的效能取決於機器的配置,以上是我在公司的桌上型電腦(機器配置為Intel(R) Core i7-6700 CPU 3.40HZ; 8GB RAM)上執行的結果。

現在我們使用並行流測試一下:

public static long parallelSum(long n) {
	return Stream.iterate(1L, i -> i + 1)
			.limit(n)
			.parallel()
			.reduce(0L, Long::sum);
}

public static void main(String[] args) {
	System.out.println("Parallel Sum: " + sumPerformanceTest(StreamTest::parallelSum, number) + " 毫秒");
}
複製程式碼

並行流執行結果為:

Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Parallel Sum: 570 毫秒
複製程式碼

並行的執行效率比順序執行還要慢,這個結果讓人大跌眼鏡。主要有兩個原因:

  1. iterate()方法生成的物件是基本型別的包裝類(也就是java.lang.Long型別),必須進行拆箱操作才能運算。
  2. iterate()方法不適合用並行流處理。

第一個原因容易理解,自動拆箱操作確實需要花費一定的時間,這從前一個例子可以看出來。第二個原因中iterate()方法不適合用並行流處理,主要原因是iterate()方法內部機制的問題。iterate()方法每次執行都需要依賴前一次的結果,比如本次執行的輸入值為10,這個輸入值必須是前一次運算結果的輸出,因此iterate()方法很難使用並行流分割成不同小塊處理。實際上,上面的並行流程式還增加了順序處理的額外開銷,因為需要把每次操作執行的結果分別分配到不同的執行緒中。

一個有效的處理方式是使用LongStream.rangeClosed()方法,該方法彌補了上述例子的兩個缺點,它生成的是基本型別而非包裝類,不用拆箱操作就可以運算,並且,它生成的是由範圍的數字,很容易拆分。如:生成1-20範圍的數字可以拆分成1-10, 11-20。

public static long rangedSum(long n) {
	return LongStream.rangeClosed(1, n)
			.reduce(0L, Long::sum);
}

public static void main(String[] args) {
	System.out.println("Ranged Sum: " + sumPerformanceTest(StreamTest::rangedSum, number) + " 毫秒");
}
複製程式碼

執行結果為:

Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Ranged Sum: 8 毫秒
複製程式碼

這個結果比起sequentialSum()方法執行的結果還要快,所以選擇合適的資料結構有時候比並行化處理更重要。我們再將rangeClosed()方法生成的流轉化為並行流:

public static long parallelRangedSum(long n) {
    return LongStream.rangeClosed(1, n)
            .parallel()
            .reduce(0L, Long::sum);
}

public static void main(String[] args) {
	System.out.println("Parallel Ranged Sum: " + sumPerformanceTest(StreamTest::parallelRangedSum, number) + " 毫秒");
}
複製程式碼

執行結果為:

Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Result: 200000010000000
Parallel Ranged Sum: 2 毫秒
複製程式碼

我們終於得到了想要的結果,所以並行操作需要選擇合適的資料結構,建議多做測試,找到合適的並行方式再執行,否則很容易跳到坑裡。

相關文章