Java8 Lambda表示式和流操作如何讓你的程式碼變慢5倍

ImportNew發表於2015-12-19

有許許多多關於 Java 8 中流效率的討論,但根據 Alex Zhitnitsky 的測試結果顯示:堅持使用傳統的 Java 程式設計風格——iterator 和 for-each 迴圈——比 Java 8 的實現效能更佳。

Java 8 中的 Lambda 表示式和流(Stream)受到了熱烈歡迎。這是 Java 迄今為止最令人激動的特徵。這些新的語言特徵允許採用函式式風格來進行編碼,我們可以用這些特性完成許多有趣的功能。這些特性如此有趣以至於被認為是不合理的。我們對此表示懷疑,於是決定對這些特性進行測試。

我們建立一個簡單的任務:從一個 ArrayList 找出最大值,將傳統方式與 Java 8 中的新方式進行測試比較。說實話,測試的結果讓我感到非常驚訝。

命令式風格與 Java 8 函數語言程式設計風格比較

我喜歡直接進入主題,所以先看一下結果。為了做這次基準測試,我們先建立了一個 ArrayList,並插入一個 100000 個隨機整數,並通過 7 種不同的方式遍歷所有的值來查詢最大值。實現分為兩組:Java 8 中引入的函式式風格與 Java 一直使用的命令式風格。

這是每個方法耗費的時長:

Java8 Lambda表示式和流操作如何讓你的程式碼變慢5倍

最大錯誤記錄是並行流上的 0.042,完整輸出結果在這篇文章結尾部分可以看到。

小貼士:

哇哦!Java 8 中提供的任何一種新方式都會產生約 5 倍的效能差異。有時使用簡單迭代器迴圈比混合 lambda 表示式和流更有效,即便這樣需要多寫幾行程式碼,且需要跳過甜蜜的語法糖(syntactic suger)。

使用迭代器或 for-each 迴圈是遍歷 ArrayList 最有效的方式,效能比採用索引值的傳統 for 迴圈方式好兩倍。

在 Java 8 的方法中,並行流的效能最佳。但是請小心,在某些情況下它也可能會導致程式執行得更慢。

Lambda 表示式的速度介於流與並行流之間。這個結果確實挺令人驚訝的,因為 lambda 表示式的實現方式是基於流的 API 來實現的。

不是所有的情況都如上所示:當我們想演示在 lambda 表示式和流中很容易犯錯時,我們收到了很多社群的反饋,要求我們優化基準測試程式碼,如消除整數的自動裝包和解包操作。第二次測試(已優化)的結果在這篇文章結束位置可以看到。

讓我們快速看一下每個方法,按照執行速度由快到慢:

命令式風格

iteratorMaxInteger()——使用迭代器遍歷列表:

public int iteratorMaxInteger() {
    int max = Integer.MIN_VALUE;
    for (Iterator it = integers.iterator(); it.hasNext(); ) {
        max = Integer.max(max, it.next());
    }
    return max;
}

forEachLoopMaxInteger()——不使用迭代器,使用 For-Each 迴圈遍歷列表(不要誤用 Java 8 的 forEach)

public int forEachLoopMaxInteger() {
    int max = Integer.MIN_VALUE;
    for (Integer n : integers) {
        max = Integer.max(max, n);
    }
    return max;
}

forMaxInteger()——使用簡單的 for 迴圈和索引遍歷列表:

public int forMaxInteger() {
    int max = Integer.MIN_VALUE;
    for (int i = 0; i < size; i++) {
        max = Integer.max(max, integers.get(i));
    }
    return max;
}

函式式風格

parallelStreamMaxInteger()——使用 Java 8 並行流遍歷列表:

public int parallelStreamMaxInteger() {
    Optional max = integers.parallelStream().reduce(Integer::max);
    return max.get();
}

lambdaMaxInteger()——使用 lambda 表示式及流遍歷列表。優雅的一行程式碼:

public int lambdaMaxInteger() {
    return integers.stream().reduce(Integer.MIN_VALUE, (a, b) -> Integer.max(a, b));
}

forEachLambdaMaxInteger()——這個用例有點混亂。可能是因為 Java 8 的 forEach 特性有一個很煩人的東西:只能使用 final 變數,所以我們建立一個 final 包裝類來解決該問題,這樣我們就能訪問到更新後的最大值。

public int forEachLambdaMaxInteger() {
    final Wrapper wrapper = new Wrapper();
    wrapper.inner = Integer.MIN_VALUE;

    integers.forEach(i -> helper(i, wrapper));
    return wrapper.inner.intValue();
}

public static class Wrapper {
    public Integer inner;
}

private int helper(int i, Wrapper wrapper) {
    wrapper.inner = Math.max(i, wrapper.inner);
    return wrapper.inner;
}

順便提一下,如果要討論 forEach,我們提供了一些有趣的關於它的缺點的見解,答案參見 StackOverflow

streamMaxInteger()——使用 Java 8 的流遍歷列表:

public int streamMaxInteger() {
    Optional max = integers.stream().reduce(Integer::max);
    return max.get();
}

優化後的基準測試

根據這篇文章的反饋,我們建立另一個版本的基準測試。原始碼的不同之處可以在這裡檢視。下面是測試結果:

Java8 Lambda表示式和流操作如何讓你的程式碼變慢5倍

修改總結:

列表不再用 Volatile 修飾。

新方法 forMax2 刪除對成員變數的訪問。

刪除 forEachLambda 中的冗餘 helper 函式。現在 lambda 表示式作為一個值賦給變數。可讀性有所降低,但是速度更快。

消除自動裝箱。如果你在 Eclipse 中開啟專案的自動裝箱警告,舊的程式碼會有 15 處警告。

優化流程式碼,在 reduce 前先使用 mapToInt。

非常感謝 Patrick Reinhart, Richard Warburton, Yan Bonnel, Sergey Kuksenko, Jeff Maxwell, Henrik Gustafsson 以及每個 Twitter 上評論的人,感謝你們的貢獻。

測試基礎

我們使用 JMH(Java Microbenchmarking Harness) 執行基準測試。如果想知道怎麼將其應用在你自己的專案中,可以參考這篇文章,我們通過一個自己寫的例項來演示 JMH 的主要特性。

基礎測試的配置包含 2 個JVM、5 次預熱迭代和 5 次測量迭代。該測試執行在 c3.xlarge Amazon EC2 例項上(CPU:4 核,記憶體:7.5G,儲存:2 x 40 GB SSD),採用 Java 8u66 和 JMH 1.11.2。所有的原始碼都在 GitHub 上,你可以在這裡看到原始的輸出結果。

順便做一下免責申明:基準測試往往不是完全可信的,也很難保證絕對正確。雖然我們試圖以最準確的方式來執行,但仍然建議接受結果時抱有懷疑的態度。

最後的思考

開始使用 Java 8 的第一件事情是在實踐中使用 lambda 表示式和流。但是請記住:它確實非常好,好到可能會讓你上癮!但是,我們也看到了,使用傳統迭代器和 for-each 迴圈的 Java 程式設計風格比 Java 8 中的新方式效能高很多。

當然,這也不是絕對的。但這確實是一個相當常見的例子,它顯示可能會有大約 5 倍的效能差距。如果這影響到系統的核心功能或成為系統一個新的瓶頸,那就相當可怕了。

相關文章