Java基礎系列-Java8 Stream 簡明教程

Rayjun發表於2019-11-23

Stream 是 Java8 中一個重大的更新。Stream 為Java 真正帶來了函數語言程式設計的特性。對函數語言程式設計不瞭解的人往往不知道如何動手,通過Benjamin 的教程來完整的學習一下 Java 的這個特性,學會這些技能會讓你的程式碼看起來更酷。


這是一個通過程式碼示例來深度講解 Java8 Stream 的教程。當我第一次看到 Stream 的 API 時,我感到很迷惑,因為這個名稱聽起來和 Java I/O 包中的 InputStreamOutputStream 有關係。但是實際上它們是完全不同的東西。 Stream 是 Monad(函數語言程式設計),它為 Java 帶來了函數語言程式設計的特性,下面是維基百科對 Monad 的解釋:

In functional programming, a monad is a structure that represents computations defined as sequences of steps. A type with a monad structure defines what it means to chain operations, or nest functions of that type together.

這份教程會講解 Java8 Stream 的原理以及不同操作之間的區別。你將會學習到 Stream 操作的處理順序以及不同的順序對效能的影響。還會對常用的操作如 ReducecollectflatMap 進行詳細講解。在教程的最後會說明並行 Stream 的優點。

注:Stream 中的 API 稱之為操作

如果你還不熟悉 Java8 的 lambda 表示式、函式式介面以及方法引用,可以先去讀一下這份Java8 教程

Stream 原理

一個 Stream 代表著一組元素以及支援對這些元素進行計算的不同操作

List<String> myList =
    Arrays.asList("a1", "a2", "b1", "c2", "c1");

myList
    .stream()
    .filter(s -> s.startsWith("c"))
    .map(String::toUpperCase)
    .sorted()
    .forEach(System.out::println);

// C1
// C2
複製程式碼

Stream 操作分為中間操作終端操作。中間操作會返回一個 Stream 物件,所以可以對中間操作進行鏈式操作。終端操作會返回一個 void 或者非 Stream 的物件。在上面的例子中,filtermapsorted 都是中間操作,而 forEach 則是一個終端操作。Stream 完整的操作 API 可以檢視文件。Stream 鏈式操作可以檢視上面的例子,鏈式操作也稱之為管道操作

許多 Stream 操作接受 Lambda 或者函式式介面來限定操作範圍。這些操作中絕大多數都必須是non-interfering無狀態的,這是什麼意思呢?

注:在函數語言程式設計中,函式本身是可以作為引數的

non-interfering 表示方法在執行的過程中不會改動流中原資料,比如在前面的例子中沒有 lambda 表示式修改了 myList 中的元素。

無狀態表示方法多次執行的結果是確定的,比如前面的例子中沒有 lambda 表示式會依賴在執行過程中會被修改的外部作用域中的變數。

不同種類的 Stream

Stream 可以通過多種方式建立,尤其是各種容器物件。List 和 Set 都支援 stream()parallelStream() 方法來建立序列或者並行的 Stream。並行 Stream 可以同時執行在多個執行緒上,在下文會詳細講解,當前先通過序列 Stream 來演示:

Arrays.asList("a1", "a2", "a3")
        .stream()
        .findFirst()
        .ifPresent(System.out::println); //a1
複製程式碼

呼叫 List 的 stream() 方法會返回一個 Stream 物件。但是得到 Stream 物件不一定要建立 Collection 物件,看下面的程式碼:

Stream.of("a1", "a2", "a3")
         .findFirst()
         .ifPresent(System.out.println);
複製程式碼

只需要通過 Stream.of() 就可以把一堆物件建立為 Stream。

另外在 Java8 還可以通過 IntStreamLongStreamDoubleStream 等來操作原生資料型別 intlongdouble

IntStream 通過 range() 方法可以替代 for 迴圈:

IntStream.range(1,4)
            .forEach(System.out::println);
 // 1
 // 2
 // 3
複製程式碼

所有的原生型別都可以和其他物件一樣使用 Stream,但是所有的原生型別 Stream 都使用專門的 lambda 表示式,比如 int 使用 IntFunction 而不是 Function,使用 IntPredicate 而不是 Predicate

並且原生型別 Stream 還另外支援終端聚合操作 sum() 以及 average():

Arrays.stream(new int[] {1, 2, 3})
    .map(n -> 2 * n + 1)
    .average()
    .ifPresent(System.out::println);  // 5.0
複製程式碼

這些操作在將物件轉化為原生型別的時候非常有用,反之亦然。出於這個目的,普通 Stream 支援特別的 map 操作,比如 mapToInt()mapToLong()mapToDouble()

Stream.of("a1", "a2", "a3")
    .map(s -> s.substring(1))
    .mapToInt(Integer::parseInt)
    .max()
    .ifPresent(System.out::println);  // 3
複製程式碼

原生資料型別可以通過 mapToObj() 轉化為物件:

IntStream.range(1, 4)
    .mapToObj(i -> "a" + i)
    .forEach(System.out::println);

// a1
// a2
// a3
複製程式碼

下面這個例子是一個組合操作:double Stream 的元素首先被轉成 int 最後被轉化成 String:

Stream.of(1.0, 2.0, 3.0)
    .mapToInt(Double::intValue)
    .mapToObj(i -> "a" + i)
    .forEach(System.out::println);

// a1
// a2
// a3
複製程式碼

處理次序

上文中已經詳細描述瞭如何建立和使用不同型別的 Stream,下面會深入研究 Stream 的操作是如何進行的。

中間操作的一個重要特徵是延遲,看下面這個沒有終端操作的例子:

Stream.of("d2", "a2", "b1", "b3", "c")
    .filter(s -> {
        System.out.println("filter: " + s);
        return true;
    });
複製程式碼

當上面的程式碼片段執行完成的時候,控制檯並沒有輸出任何東西。這是因為中間操作在有終端操作的時候才會執行。

給上面的例子加上終端操作 forEach:

Stream.of("d2", "a2", "b1", "b3", "c")
    .filter(s -> {
        System.out.println("filter: " + s);
        return true;
    })
    .forEach(s -> System.out.println("forEach: " + s));
複製程式碼

執行這段程式碼會有如下的輸出:

filter:  d2
forEach: d2
filter:  a2
forEach: a2
filter:  b1
forEach: b1
filter:  b3
forEach: b3
filter:  c
forEach: c
複製程式碼

輸出結果的順序可能會讓人驚訝。之前你可能會認為 Stream 中的元素會在一個操作中全部處理完之後才會進入到下一個操作。但實際的情況是一個元素在所有的操作執行完成之後才會輪到下一個元素。"d2" 首先被 filterforEach 的處理,然後 "a2" 才會被處理。

這樣可以減少每個操作實際處理元素的個數,看下面這個例子:

Stream.of("d2", "a2", "b1", "b3", "c")
    .map(s -> {
        System.out.println("map: " + s);
        return s.toUpperCase();
    })
    .anyMatch(s -> {
        System.out.println("anyMatch: " + s);
        return s.startsWith("A");
    });

// map:      d2
// anyMatch: D2
// map:      a2
// anyMatch: A2
複製程式碼

這個 anyMatch 操作只在輸入元素滿足條件的情況下才會返回 true。在上面的例子中,執行到第二個元素 "a2" 時就會返回 true,然後就會停止處理其他元素,所以 map 操作也只是執行了兩次,這正是得益於 Stream 的鏈式處理次序。

為什麼次序很關鍵

下面的這個例子由兩個中間操作 mapfilter 以及一個終端操作 forEach 組成。再看一下這些操作是如何執行的:

Stream.of("d2", "a2", "b1", "b3", "c")
    .map(s -> {
        System.out.println("map: " + s);
        return s.toUpperCase();
    })
    .filter(s -> {
        System.out.println("filter: " + s);
        return s.startsWith("A");
    })
    .forEach(s -> System.out.println("forEach: " + s));

// map:     d2
// filter:  D2
// map:     a2
// filter:  A2
// forEach: A2
// map:     b1
// filter:  B1
// map:     b3
// filter:  B3
// map:     c
// filter:  C
複製程式碼

正如上面的例子所分析,map 和 filter 對每個字串各執行了 5 次,而 forEach 僅僅執行了一次。

可以簡單的調整操作的順序來減少操作執行的總次數,下面的例子中把 filter 操作放到了 map 前面:

Stream.of("d2", "a2", "b1", "b3", "c")
    .filter(s -> {
        System.out.println("filter: " + s);
        return s.startsWith("a");
    })
    .map(s -> {
        System.out.println("map: " + s);
        return s.toUpperCase();
    })
    .forEach(s -> System.out.println("forEach: " + s));

// filter:  d2
// filter:  a2
// map:     a2
// forEach: A2
// filter:  b1
// filter:  b3
// filter:  c
複製程式碼

調整後,map 只執行了一次,整個操作管道在輸入大量元素時的執行速度會快很多。如果 Stream 有很多的操作,時序考慮一下能不能通過調整持續來優化。

在上面的例子中另外加上 sorted 操作:

Stream.of("d2", "a2", "b1", "b3", "c")
    .sorted((s1, s2) -> {
        System.out.printf("sort: %s; %s\n", s1, s2);
        return s1.compareTo(s2);
    })
    .filter(s -> {
        System.out.println("filter: " + s);
        return s.startsWith("a");
    })
    .map(s -> {
        System.out.println("map: " + s);
        return s.toUpperCase();
    })
    .forEach(s -> System.out.println("forEach: " + s));
複製程式碼

sotred 是一個另類的中間操作,它是有狀態的。因為在排序的過程中必須要維護資料的狀態。

執行上面的例子會產生如下輸出:

sort:    a2; d2
sort:    b1; a2
sort:    b1; d2
sort:    b1; a2
sort:    b3; b1
sort:    b3; d2
sort:    c; b3
sort:    c; d2
filter:  a2
map:     a2
forEach: A2
filter:  b1
filter:  b3
filter:  c
filter:  d2
複製程式碼

首先,sorted 會把輸入的所有元素排好序之後才會進入下一個操作,和其他操作不同,sorted 是水平執行的。所以在上面的例子中 sorted 才會被執行 8 次。

通過調整操作的次序可以再一次提升執行的效能:

Stream.of("d2", "a2", "b1", "b3", "c")
    .filter(s -> {
        System.out.println("filter: " + s);
        return s.startsWith("a");
    })
    .sorted((s1, s2) -> {
        System.out.printf("sort: %s; %s\n", s1, s2);
        return s1.compareTo(s2);
    })
    .map(s -> {
        System.out.println("map: " + s);
        return s.toUpperCase();
    })
    .forEach(s -> System.out.println("forEach: " + s));

// filter:  d2
// filter:  a2
// filter:  b1
// filter:  b3
// filter:  c
// map:     a2
// forEach: A2
複製程式碼

在這個例子中 sorted 永遠也不會被執行,在 filter 執行完了之後就剩下一個元素,也就沒有排序的必要。在輸入大量元素的情況下,效能也會得到極大的提升。

重用 Stream

Java8 中的 Stream 是不能被重用的。一旦執行了終端操作,那麼 Stream 就會被關閉:

Stream<String> stream =
    Stream.of("d2", "a2", "b1", "b3", "c")
        .filter(s -> s.startsWith("a"));

stream.anyMatch(s -> true);    // ok
stream.noneMatch(s -> true);   // exception
複製程式碼

在 anyMatch 之後呼叫 noneMatch 會產生如下的異常:

java.lang.IllegalStateException: stream has already been operated upon or closed
    at java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:229)
    at java.util.stream.ReferencePipeline.noneMatch(ReferencePipeline.java:459)
    at com.winterbe.java8.Streams5.test7(Streams5.java:38)
    at com.winterbe.java8.Streams5.main(Streams5.java:28)
複製程式碼

如果需要解決這一點,可以為每一個終端操作建立一個新的 Stream,比如可以使用 Supplier 來建立所有中間操作已經執行完成的 Stream:

Supplier<Stream<String>> streamSupplier =
    () -> Stream.of("d2", "a2", "b1", "b3", "c")
            .filter(s -> s.startsWith("a"));

streamSupplier.get().anyMatch(s -> true);   // ok
streamSupplier.get().noneMatch(s -> true);  // ok
複製程式碼

每呼叫一次 get() 方法都會建立一個新的 Stream,然後就可以執行需要執行的終端操作了。

進階操作

Stream 支援大量不同的操作,在上面的例子中已經介紹了最重要的操作如 filtermap。完整的操作可以在官方文件中檢視。下面會重點介紹更加複雜的操作 collectflatMapreduce

這節絕大部分的程式碼例子都會使用下面 Person list 作為演示資料:

class Person {
    String name;
    int age;

    Person(String name, int age) {
        this.name = name;
        this.age = age;
    }

    @Override
    public String toString() {
        return name;
    }
}

List<Person> persons =
    Arrays.asList(
        new Person("Max", 18),
        new Person("Peter", 23),
        new Person("Pamela", 23),
        new Person("David", 12));
複製程式碼

Collect

如果需要將 Stream 中執行的結果轉成一個不同的型別,比如 List、Set 或者 Map,collect 就非常有用。collect 操作接受由 suppileraccumulatorcombinerfinisher 等四個部分組成的 Collector 物件。聽起來很複雜,但 java8 中 Collectors 類中的大量方法開箱即用,對很多通用的操作並不需要自己去實現:

注:suppiler, accumulator, combiner, finisher 都是函式式介面

List<Person> filtered =
    persons
        .stream()
        .filter(p -> p.name.startsWith("P"))
        .collect(Collectors.toList());

System.out.println(filtered);    // [Peter, Pamela]
複製程式碼

很簡單就可以從 Stream 中獲取一個 List,如果需要一個 Set,呼叫 Collectors.toSet() 就行了。

下面的這個例子是通過年齡來給 person 分組:

Map<Integer, List<Person>> personsByAge = persons
    .stream()
    .collect(Collectors.groupingBy(p -> p.age));

personsByAge
    .forEach((age, p) -> System.out.format("age %s: %s\n", age, p));

// age 18: [Max]
// age 23: [Peter, Pamela]
// age 12: [David]
複製程式碼

Collectors 功能很多,還可以用來對 Stream 中的元素做聚合操作,比如計算所有 person 的平均年齡:

Double averageAge = persons
    .stream()
    .collect(Collectors.averagingInt(p -> p.age));

System.out.println(averageAge);     // 19.0
複製程式碼

還可以用來做統計,summarizing 會返回一個內建的統計物件,通過這個物件可以很方便的得到最大年齡、最小年齡、平均年齡等統計結果:

IntSummaryStatistics ageSummary =
    persons
        .stream()
        .collect(Collectors.summarizingInt(p -> p.age));

System.out.println(ageSummary);
// IntSummaryStatistics{count=4, sum=76, min=12, average=19.000000, max=23}
複製程式碼

下面的例子中把所有 person 的名字拼成了一個字串:

String phrase = persons
    .stream()
    .filter(p -> p.age >= 18)
    .map(p -> p.name)
    .collect(Collectors.joining(" and ", "In Germany ", " are of legal age."));

System.out.println(phrase);
// In Germany Max and Peter and Pamela are of legal age.
複製程式碼

joining 接收一個間隔符和可選的字首、字尾字串。

為了輸出 map 結果。必須指定 map 的 key 和 value。需要注意 key 必須是唯一的,否則會報 IllegalStateException 異常。可以通過傳入另外一個合併方法作為引數來避免這個異常:

Map<Integer, String> map = persons
    .stream()
    .collect(Collectors.toMap(
        p -> p.age,
        p -> p.name,
        (name1, name2) -> name1 + ";" + name2));

System.out.println(map);
// {18=Max, 23=Peter;Pamela, 12=David}
複製程式碼

上面介紹了一些很強大 Collectors 的內建方法。下面來實現一個自定義的 collector。將所有 Person 的名稱轉成大寫並輸入到一個字串中,每個名字使用 | 來隔開。自定義的 collecotr 使用 Collecotr.of() 來實現,需要實現其中的四個部分:supplieraccumulatorcombinerfinisher

Collector<Person, StringJoiner, String> personNameCollector =
    Collector.of(
        () -> new StringJoiner(" | "),          // supplier
        (j, p) -> j.add(p.name.toUpperCase()),  // accumulator
        (j1, j2) -> j1.merge(j2),               // combiner
        StringJoiner::toString);                // finisher

String names = persons
    .stream()
    .collect(personNameCollector);

System.out.println(names);  // MAX | PETER | PAMELA | DAVID
複製程式碼

在 Java 中,String 物件是不可變的。所以需要一個 StringJoiner 來組合字串,suppiler 例項化一個帶 | 分隔符的 StringJoiner 物件。accumulator 把字串轉成大寫並且放進 StringJoiner 物件,combiner 將兩個 StringJoiner 物件合成一個,最後 finisher 把 StringJoiner 物件輸出為 String 物件。

flatMap

在上面已經介紹瞭如何使用 map 將 Stream 中的物件轉成另外一種型別的物件。map 只能把一種型別轉成另外一種特定的型別,在把一種型別轉成任意種型別的情況下,map 就有點受限制了。而 flatMap 正是來解決這個問題的。

flatMap 會把 Stream 中的每個元素轉成另一個 Stream 中的其他物件。所以每個元素依賴 STream 會被轉成 0 個,1 個或者多個其他物件。這些生成的新的 stream 會在 flatMap 操作結束的時候返回。

在使用 flatMap 之前,需要定義以下的資料結構:

class Foo {
    String name;
    List<Bar> bars = new ArrayList<>();

    Foo(String name) {
        this.name = name;
    }
}

class Bar {
    String name;

    Bar(String name) {
        this.name = name;
    }
}
複製程式碼

接下來,利用 Stream 初始化一些物件:

List<Foo> foos = new ArrayList<>();

// create foos
IntStream
    .range(1, 4)
    .forEach(i -> foos.add(new Foo("Foo" + i))); 

// create bars
foos.forEach(f ->
    IntStream
        .range(1, 4)
        .forEach(i -> f.bars.add(new Bar("Bar" + i + " <- " + f.name))));
複製程式碼

現在,生成了包含三個 foo 物件的 list,每個 foo 物件中又包含三個 bar 物件。

flatMap 接收一個返回 Stream 物件的方法作為引數,為了分解 foo 中的每個 bar 物件,傳入一個合適的方法:

foos.stream()
    .flatMap(f -> f.bars.stream())
    .forEach(b -> System.out.println(b.name));

// Bar1 <- Foo1
// Bar2 <- Foo1
// Bar3 <- Foo1
// Bar1 <- Foo2
// Bar2 <- Foo2
// Bar3 <- Foo2
// Bar1 <- Foo3
// Bar2 <- Foo3
// Bar3 <- Foo3
複製程式碼

上面那個例子成功的把一個包含三個 foo 物件的 Stream 轉成了包含 9 個 bar 物件的 Stream。

而且,上面的那些程式碼可以被簡化成一個 Stream 管道操作:

IntStream.range(1, 4)
    .mapToObj(i -> new Foo("Foo" + i))
    .peek(f -> IntStream.range(1, 4)
        .mapToObj(i -> new Bar("Bar" + i + " <- " f.name))
        .forEach(f.bars::add))
    .flatMap(f -> f.bars.stream())
    .forEach(b -> System.out.println(b.name));
複製程式碼

flatMap 操作對 java8 中的 Optional 物件也有用,Optional 物件的操作會返回另一個型別的 Optional 物件。所以這個特性可以用來消除空指標檢查。

定義類的抽象層次如下:

class Outer {
    Nested nested;
}

class Nested {
    Inner inner;
}

class Inner {
    String foo;
}
複製程式碼

為了從 Outer 物件中呼叫 Inner 物件中的 foo 字串,需要做很多的空指標檢查來避免潛在的空指標異常:

Outer outer = new Outer();
if (outer != null && outer.nested != null && outer.nested.inner != null) {
    System.out.println(outer.nested.inner.foo);
}
複製程式碼

這些操作可以通過 flatMap 來進行優化:

Optional.of(new Outer())
    .flatMap(o -> Optional.ofNullable(o.nested))
    .flatMap(n -> Optional.ofNullable(n.inner))
    .flatMap(i -> Optional.ofNullable(i.foo))
    .ifPresent(System.out::println);
複製程式碼

每呼叫一次都會返回一個 Optional 物件,物件中包裹著目標物件或者 null。

Reduce

Reduce 組合 Stream 中所有的元素,然後產生一個單獨的結果。Java8 支援三種 reduce 方法。第一種 reduce 對於每個 Stream 只會返回一個元素。下面這個例子計算除了年齡最大的人的名字:

persons
    .stream()
    .reduce((p1, p2) -> p1.age > p2.age ? p1 : p2)
    .ifPresent(System.out::println);    // Pamela
複製程式碼

reduce 方法接受一個 BinaryOperator 函式。在 Person 這個例子中,實際上是一個 BiFunction,兩個運算元的型別都是一致的。BiFunction 與 Function 很像,但是前者接收兩個引數。這個例子中比較所有 person 的年齡屬性來找出最大年齡的 person。

第二種 reduce 方法接受一個目標物件和一個 BinaryOperator。下面這個方法可以聚合所有的 person 屬性來建立一個新的 person:

Person result =
    persons
        .stream()
        .reduce(new Person("", 0), (p1, p2) -> {
            p1.age += p2.age;
            p1.name += p2.name;
            return p1;
        });

System.out.format("name=%s; age=%s", result.name, result.age);
// name=MaxPeterPamelaDavid; age=76
複製程式碼

第三種 reduce 接受三個引數:一個目標物件、一個 BiFunction、一個 BinaryOperator 型別的 combiner。因為這個傳入的值不一定是 Person 型別,所以我們可以利用這個特性來計算所有 Person 年齡的總和:

Integer ageSum = persons
    .stream()
    .reduce(0, (sum, p) -> sum += p.age, (sum1, sum2) -> sum1 + sum2);

System.out.println(ageSum);  // 76
複製程式碼

最後的結果是 76,那麼中間的計算過程是什麼樣的的呢?下面 debug 了計算的過程:

Integer ageSum = persons
    .stream()
    .reduce(0,
        (sum, p) -> {
            System.out.format("accumulator: sum=%s; person=%s\n", sum, p);
            return sum += p.age;
        },
        (sum1, sum2) -> {
            System.out.format("combiner: sum1=%s; sum2=%s\n", sum1, sum2);
            return sum1 + sum2;
        });

// accumulator: sum=0; person=Max
// accumulator: sum=18; person=Peter
// accumulator: sum=41; person=Pamela
// accumulator: sum=64; person=David
複製程式碼

可以看到 accumulator 函式完成了所有的計算,呼叫的第一次得到的是初始值 0 和 Max person。然後後續的三步完成了所有年齡的的累加。在最後一步得到了所有年齡的累加結果 76。

但是上面的例子看起來稍微有點問題,因為 combiner 函式根本沒有執行,但是真的是這樣的嗎?看下面的程式碼我們就能發現祕密所在:

Integer ageSum = persons
    .parallelStream()
    .reduce(0,
        (sum, p) -> {
            System.out.format("accumulator: sum=%s; person=%s\n", sum, p);
            return sum += p.age;
        },
        (sum1, sum2) -> {
            System.out.format("combiner: sum1=%s; sum2=%s\n", sum1, sum2);
            return sum1 + sum2;
        });

// accumulator: sum=0; person=Pamela
// accumulator: sum=0; person=David
// accumulator: sum=0; person=Max
// accumulator: sum=0; person=Peter
// combiner: sum1=18; sum2=23
// combiner: sum1=23; sum2=12
// combiner: sum1=41; sum2=35
複製程式碼

在並行執行的情況下有著完全不同的執行行為。在這裡 combiner 執行了,accumulator 在並行情況下被執行的時候,combiner 用來累加 accumulator 的執行結果。

在下一節會詳細分析並行 Stream。

並行 Stream

在輸入元素數量很多的情況下,通過並行執行 Stream 可以提升執行效能。並行 Stream 使用了 ForkJoinPool,這個物件可以通過 ForkJoinPool.commonPool() 來得到。底層的執行緒池最多可以有五個執行緒,取決於物理機器可以用的 CPU 有幾個。

ForkJoinPool commonPool = ForkJoinPool.commonPool();
System.out.println(commonPool.getParallelism());    // 3
複製程式碼

在我的機器上這個執行緒的數量被設定為 3。這個值可以通過 JVM 的引數來進行調整:

-Djava.util.concurrent.ForkJoinPool.common.parallelism=5
複製程式碼

Collection 物件可以通過 parallelStream() 來建立一個並行的 Stream。或者也可以對一個序列的 Stream 物件呼叫 parallel() 來轉成並行 Stream。

為了理解 Stream 是如何並行執行的,下面這個例子把執行緒的情況都列印出來了:

Arrays.asList("a1", "a2", "b1", "c2", "c1")
    .parallelStream()
    .filter(s -> {
        System.out.format("filter: %s [%s]\n",
            s, Thread.currentThread().getName());
        return true;
    })
    .map(s -> {
        System.out.format("map: %s [%s]\n",
            s, Thread.currentThread().getName());
        return s.toUpperCase();
    })
    .forEach(s -> System.out.format("forEach: %s [%s]\n",
        s, Thread.currentThread().getName()));
複製程式碼

通過研究 debug 輸出,可以看到 Stream 執行過程中哪些執行緒確實用到了:

filter:  b1 [main]
filter:  a2 [ForkJoinPool.commonPool-worker-1]
map:     a2 [ForkJoinPool.commonPool-worker-1]
filter:  c2 [ForkJoinPool.commonPool-worker-3]
map:     c2 [ForkJoinPool.commonPool-worker-3]
filter:  c1 [ForkJoinPool.commonPool-worker-2]
map:     c1 [ForkJoinPool.commonPool-worker-2]
forEach: C2 [ForkJoinPool.commonPool-worker-3]
forEach: A2 [ForkJoinPool.commonPool-worker-1]
map:     b1 [main]
forEach: B1 [main]
filter:  a1 [ForkJoinPool.commonPool-worker-3]
map:     a1 [ForkJoinPool.commonPool-worker-3]
forEach: A1 [ForkJoinPool.commonPool-worker-3]
forEach: C1 [ForkJoinPool.commonPool-worker-2]
複製程式碼

並行 Stream 執行操作的過程中用到了執行緒池中所有的執行緒。上面輸出的結果順序可能每次都是不一樣的,這是因為執行緒執行的順序本身就是不一樣的。

給上面的例子加上 sort 操作:

Arrays.asList("a1", "a2", "b1", "c2", "c1")
    .parallelStream()
    .filter(s -> {
        System.out.format("filter: %s [%s]\n",
            s, Thread.currentThread().getName());
        return true;
    })
    .map(s -> {
        System.out.format("map: %s [%s]\n",
            s, Thread.currentThread().getName());
        return s.toUpperCase();
    })
    .sorted((s1, s2) -> {
        System.out.format("sort: %s <> %s [%s]\n",
            s1, s2, Thread.currentThread().getName());
        return s1.compareTo(s2);
    })
    .forEach(s -> System.out.format("forEach: %s [%s]\n",
        s, Thread.currentThread().getName()));
複製程式碼

執行的結果看起來有點奇怪:

filter:  c2 [ForkJoinPool.commonPool-worker-3]
filter:  c1 [ForkJoinPool.commonPool-worker-2]
map:     c1 [ForkJoinPool.commonPool-worker-2]
filter:  a2 [ForkJoinPool.commonPool-worker-1]
map:     a2 [ForkJoinPool.commonPool-worker-1]
filter:  b1 [main]
map:     b1 [main]
filter:  a1 [ForkJoinPool.commonPool-worker-2]
map:     a1 [ForkJoinPool.commonPool-worker-2]
map:     c2 [ForkJoinPool.commonPool-worker-3]
sort:    A2 <> A1 [main]
sort:    B1 <> A2 [main]
sort:    C2 <> B1 [main]
sort:    C1 <> C2 [main]
sort:    C1 <> B1 [main]
sort:    C1 <> C2 [main]
forEach: A1 [ForkJoinPool.commonPool-worker-1]
forEach: C2 [ForkJoinPool.commonPool-worker-3]
forEach: B1 [main]
forEach: A2 [ForkJoinPool.commonPool-worker-2]
forEach: C1 [ForkJoinPool.commonPool-worker-1]
複製程式碼

可以看到了 sort 操作只會在主執行緒中執行。並行 Stream 中的 sort 操作實際用到了 Java8 中的新介面 Arrays.parallelSort()。在 Javadoc 中說明了陣列的長度決定了這個排序操作是序列還是並行執行:

If the length of the specified array is less than the minimum granularity, then it is sorted using the appropriate Arrays.sort method.

回到上面的例子,可以發現 combiner 函式只會在並行情況下執行。下面來看一下哪些執行緒確實執行了:

List<Person> persons = Arrays.asList(
    new Person("Max", 18),
    new Person("Peter", 23),
    new Person("Pamela", 23),
    new Person("David", 12));

persons
    .parallelStream()
    .reduce(0,
        (sum, p) -> {
            System.out.format("accumulator: sum=%s; person=%s [%s]\n",
                sum, p, Thread.currentThread().getName());
            return sum += p.age;
        },
        (sum1, sum2) -> {
            System.out.format("combiner: sum1=%s; sum2=%s [%s]\n",
                sum1, sum2, Thread.currentThread().getName());
            return sum1 + sum2;
        });
複製程式碼

上面例子的輸出說明了 accumulator 和 combiner 在並行 Stream 中會在所有的可用執行緒上執行:

accumulator: sum=0; person=Pamela; [main]
accumulator: sum=0; person=Max;    [ForkJoinPool.commonPool-worker-3]
accumulator: sum=0; person=David;  [ForkJoinPool.commonPool-worker-2]
accumulator: sum=0; person=Peter;  [ForkJoinPool.commonPool-worker-1]
combiner:    sum1=18; sum2=23;     [ForkJoinPool.commonPool-worker-1]
combiner:    sum1=23; sum2=12;     [ForkJoinPool.commonPool-worker-2]
combiner:    sum1=41; sum2=35;     [ForkJoinPool.commonPool-worker-2]
複製程式碼

所有在輸入元素的量很大的情況下,並行 Stream 會帶來很大的效能提升。但是需要注意一些操作比如 reducecollect 需要額外的 combine 操作,但是在序列 Stream 中並不需要。

此外,所有的並行 Stream 都依賴 ForkJoinPool 執行緒池。所以應當儘量避免實現一些阻塞 Stream 的操作,因為這樣會降低那些依賴並行 Stream 的程式的效能。

(完)

原文

關注微信公眾號,聊點其他的

Java基礎系列-Java8 Stream 簡明教程

相關文章