詳解Java 8中Stream類型的“懶”加載


在進入正題之前,我們需要先引入Java 8中Stream類型的兩個很重要的操作:

中間和終結操作(Intermediate and Terminal Operation)

Stream類型有兩種類型的方法:

  • 中間操作(Intermediate Operation)
  • 終結操作(Terminal Operation)

官方文檔給出的描述為[不想看字母的請直接跳過]:

Stream operations are divided into intermediate and terminal operations, and are combined to form stream pipelines. A stream pipeline consists of a source (such as a Collection, an array, a generator function, or an I/O channel); followed by zero or more intermediate operations such as Stream.filter or Stream.map; and a terminal operation such as Stream.forEach or Stream.reduce.

Intermediate operations return a new stream. They are always lazy; executing an intermediate operation such as filter() does not actually perform any filtering, but instead creates a new stream that, when traversed, contains the elements of the initial stream that match the given predicate. Traversal of the pipeline source does not begin until the terminal operation of the pipeline is executed.

Terminal operations, such as Stream.forEach or IntStream.sum, may traverse the stream to produce a result or a side-effect. After the terminal operation is performed, the stream pipeline is considered consumed, and can no longer be used; if you need to traverse the same data source again, you must return to the data source to get a new stream. In almost all cases, terminal operations are eager, completing their traversal of the data source and processing of the pipeline before returning. Only the terminal operations iterator() and spliterator() are not; these are provided as an "escape hatch" to enable arbitrary client-controlled pipeline traversals in the event that the existing operations are not sufficient to the task.

Processing streams lazily allows for significant efficiencies; in a pipeline such as the filter-map-sum example above, filtering, mapping, and summing can be fused into a single pass on the data, with minimal intermediate state. Laziness also allows avoiding examining all the data when it is not necessary; for operations such as "find the first string longer than 1000 characters", it is only necessary to examine just enough strings to find one that has the desired characteristics without examining all of the strings available from the source. (This behavior becomes even more important when the input stream is infinite and not merely large.)

其實看完這個官方文檔,擼主整個人是很蒙圈的,給大家講講官方文檔這段話到底說了些什么:

第一段:流操作分為中間操作和終結操作(我就這么翻譯了啊),這兩種操作外加數據源就構成了所謂的pipeline,處理管道。

第二段:說中間操作會返回一個流;中間操作是懶的(lazy,究竟怎么個懶法,我們后面會講到);還拿filter舉了個例子說,執行中間操作filter的時候實際上並沒有進行任何的過濾操作,而是創建了一個新的流,這個新流包含啥呢?包含的是在遍歷原來流(initial stream)過程中符合篩選條件的元素(很奇怪哎,這不明顯是一個過濾操作嗎?怎么說沒有呢);要注意的是:中間操作在pipeline執行到終結操作之前是不會開始執行的(這將在我們后面的內容中講到);

第三段:人家說了,終結操作是eager的,也就是說,執行到終結操作的時候我就要開始遍歷數據源並且執行中間操作這個過程了,不會再去等誰了。而且一旦pipeline中的終結操作完成了,那么這個pipeline的使命就完成了,如果你還有新的終結操作,那么對不起,這個舊的pipeline就用不了了,你得新建一個stream,然后在造一遍輪子。這里有一句話我實在沒弄明白什么意思啊,"

Only the terminal operations iterator() and spliterator() are not; these are provided as an "escape hatch" to enable arbitrary client-controlled pipeline traversals in the event that the existing operations are not sufficient to the task.

",還希望道友們幫忙解釋一下,感激不盡!

第四段:誇了一下stream“懶”執行的好處:效率高。將中間操作融合在一起,使操作對對象的狀態改變最小化;而且還能使我們避免一些沒必要的工作,給了個例子:在一堆字符串里要找出第一個含超過1000個字符的字符串,通過stream operation的laziness那么我們就不用遍歷全部元素了,只需執行能找出滿足條件的元素的操作就行(其實這個需求不通過stream pipeline也能做到不是嗎?);其實最重要的還是當面對一個無限數據源的操作時,它的不可替代性才體現了出來,因為經典java中collection是finite的,當然這個不是我們今天的目標,這里就不拓展開講了。

願文檔后面還有一點內容,講了中間操作有的是持有狀態的(stateful),有的是無狀態的(stateless),他們在對原數據的遍歷上也有一些不同感興趣的同學可自己去研究研究,我們今天主要還是看看中間操作是怎么個“懶”法以及這個“懶”的過程是怎么樣的。

Stream之所以“懶”的秘密也在於每次在使用Stream時,都會連接多個中間操作,並在最后附上一個結束操作。 像map()和filter()這樣的方法是中間操作,在調用它們時,會立即返回另一個Stream對象。而對於reduce()及findFirst()這樣的方法,它們是終結操作,在調用它們時才會執行真正的操作來獲取需要的值。

從一個例子出發:

比如,當我們需要打印出第一個長度為3的大寫名字時:

public class LazyStreams {
    private static int length(final String name) {
        System.out.println("getting length for " + name);
        return name.length();
    }
    private static String toUpper(final String name ) {
        System.out.println("converting to uppercase: " + name);
        return name.toUpperCase();
    }
    public static void main(final String[] args) {
        List<String> names = Arrays.asList("Brad", "Kate", "Kim", "Jack", "Joe", "Mike", "Susan", "George", "Robert", "Julia", "Parker", "Benson");

        final String firstNameWith3Letters = names.stream()
            .filter(name -> length(name) == 3)
            .map(name -> toUpper(name))
            .findFirst()
            .get();

        System.out.println(firstNameWith3Letters);
    }
}

你可能認為以上的代碼會對names集合進行很多操作,比如首先遍歷一次集合得到長度為3的所有名字,再遍歷一次filter得到的集合,將名字轉換為大寫。最后再從大寫名字的集合中找到第一個並返回。這也是經典情況下Java Eager處理的角度。此時的處理順序是這樣的

對於Stream操作,更好的代碼閱讀順序是從右到左,或者從下到上。每一個操作都只會做到恰到好處。如果以Eager的視角來閱讀上述代碼,它也許會執行15步操作:

 

 

可是實際情況並不是這樣,不要忘了Stream可是非常“懶”的,它不會執行任何多余的操作。實際上,只有當findFirst方法被調用時,filter和map方法才會被真正觸發。而filter也不會一口氣對整個集合實現過濾,它會一個個的過濾,如果發現了符合條件的元素,會將該元素置入到下一個中間操作,也就是map方法中。所以實際的情況是這樣的:

\

 

控制台的輸出是這樣的:

getting length for Brad
getting length for Kate
getting length for Kim
converting to uppercase: Kim
KIM

為了更好理解上述過程,我們將Lambda表達式換為經典的Java寫法,即匿名內部類的形式:

final String firstNameWith3Letters = names.stream()
            .filter(new Predicate<String>{
                public boolean test(String name){
                    return length(name)==3;
                }
             })
            .map(new Function<String,String>{
                public String apply(String name){
                    return toUpper(name);
                }
            })
            .findFirst()
            .get();  

執行的見下圖:

 

很容易得出之前的結論:只有當findFirst方法被調用時,filter和map方法才會被真正觸發。而filter也不會一口氣對整個集合實現過濾,它會一個個的過濾,如果發現了符合條件的元素,會將該元素置入到下一個中間操作,也就是map方法中。

當終結操作獲得了它需要的答案時,整個計算過程就結束了。如果沒有獲得到答案,那么它會要求中間操作對更多的集合元素進行計算,直到找到答案或者整個集合被處理完畢。

JDK會將所有的中間操作合並成一個,這個過程被稱為熔斷操作(Fusing Operation)。因此,在最壞的情況下(即集合中沒有符合要求的元素),集合也只會被遍歷一次,而不會像我們想象的那樣執行了多次遍歷,也許這就回答了官方文檔中為什么說"Processing streams lazily allows for significant efficiencies"了。

為了看清楚在底層發生的事情,我們可以將以上對Stream的操作按照類型進行分割:

Stream<String> namesWith3Letters = names.stream()
    .filter(name -> length(name) == 3)
    .map(name -> toUpper(name));

System.out.println("Stream created, filtered, mapped...");
System.out.println("ready to call findFirst...");

final String firstNameWith3Letters = namesWith3Letters.findFirst().get();

System.out.println(firstNameWith3Letters);
// 輸出結果 // Stream created, filtered, mapped... // ready to call findFirst... // getting length for Brad // getting length for Kate // getting length for Kim // converting to uppercase: Kim // KIM 

根據輸出的結果,我們可以發現在聲明了Strema對象上的中間操作之后,中間操作並沒有被執行。只有當真正發生了findFirst()調用之后,才會執行中間操作。

參考資料:

擼主比較懶,上文中的例子和前兩張圖來自於 CSDN 博主 dm_vincent 的博客《 [Java 8] (7) 利用Stream類型的"懶"操作 》


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM