Java 8 的 JVM 有多快?Fork-Join 性能基準測試

Java 8 已經發布一段時間了,許多開發者已經開始使用 Java 8。本文也將討論最新發布在 JDK 中的併發功能更新。事實上,JDK 中已經有多處java.util.concurrent 改動,但本文重點將是 Fork-Join 框架的改進。咱們將討論一點 Fork-Join,而後實現一個簡單的基準測試以比較 FJ 在 Java 7 和Java 8 中的性能。html

Java 8 的 JVM 有多快?Fork-Join 性能基準測試

##你可能對Fork/Join在乎的地方java

ForkJoin 是一個一般用於並行計算遞歸任務的框架。它最先被引入Java 7 中,從那時起它就能很好地完成目標任務。緣由在於,許多大型任務本質上均可以遞歸表示。git

以最有名的 MapReduce 編程爲例:對一篇文章中不一樣詞的出現次數進行統計。很顯然,能夠將文檔分爲不少部分,逐項地記錄字數,最後再合併成結果。誠然,ForkJoin實際上是 MapReduce 基本法則的一種實現,區別在於,全部的 worker 都是同一個虛擬機中的線程,而不是一組機器。github

ForkJoin 框架的核心部分是 ForkJoinPool ,它是一個 ExecutorService, 可以接收異步任務,返回Future對象,所以可用於跟蹤執行中的計算狀態。編程

使 ForkJoinPool 不一樣於其餘 ExecutorServices 的是,在當下並不執行任務的工做線程會檢查其夥伴的工做狀態,並向他們借取任務。這種技術稱爲 work-stealing 。那麼,work-stealing 有什麼妙用呢?api

queue

work-stealing 是一種分散式的工做量管理方法,無需將工做單元分配給全部可用的工做線程,而是每一個線程本身管理其任務隊列。關鍵在於高效地管理這些隊列。安全

關於讓每一個工做進程處理本身的隊列,有兩個主要問題:服務器

  • 外部提交的任務去哪裏了?
  • 咱們怎樣組織 work-stealing 以有效訪問隊列

本質上來講,在執行大型任務時,外部提交任務和由工做線程建立的任務之間區別不大。他們都有相似的執行要求並提供結果。然而,運做方式是不一樣的。最主要的區別在於由工做進程建立的任務能夠被竊取。這意味着即使被放進了一個工做進程的任務隊列中,他們仍可能被其餘工做進程執行。併發

ForkJoin 框架處理它的方法很簡單,每一個工做線程都有2個任務隊列,一個用於外部任務,另外一個用於實現竊取工做進程的運做。當外部提交任務時,會將任務添加至隨機的工做隊列中。當一個任務被分爲更小的任務時,工做線程將他們添加到本身的任務隊列中,並但願其餘工做線程來幫忙。oracle

竊取任務的想法基於如下事實:工做線程在它任務隊列末尾添加任務。在正常的執行過程當中,每一個工做線程試着去從任務隊列的隊首拿任務,當其我的隊列的任務爲空時,這一操做就會失敗,轉而竊取別的工做線程的任務隊列末尾的任務。這有效避免了多數任務隊列的互鎖問題,提升了性能。

另外一個使 ForkJoin 池工做更快的訣竅是當一個工做線程竊取任務時,它留下了它在哪裏取得任務的線索,這樣原始的工做線程能夠找到它而且幫助該工做線程,所以父任務的的工做進展會更快。

總而言之,這是一套極其複雜的系統,須要大量的背景知識使其順利運行。而且,系統的屬性和性能與具體實現的方式關係很大。所以筆者懷疑,若不進行重大的重構,系統會完全改變。

Java 7 中 ForkJoin 有什麼問題?

在 Java 7 中引入 ForkJoin 框架以後,它運行良好。然而它並無中止進步。在 Java 8 的併發性更新中, ForkJoin 獲得改善。從此次的 Java 加強方案中,咱們能夠了解改善的內容。

增長了 ForkJoinPools 的功能並提升其性能,使其應用在用戶但願的日益普遍的應用中,且效率更高。新特性包括對最適於 IO-bound 使用的 completion-based 設計的支持等。

另外一個消息來源固然是與改進做者的對話,例如,Doug Lea 早前曾提到的更新有:

當大量的用戶提交大量任務時,吞吐量能大幅度提升。其原理是將外部提交者與工做線程類似地對待——均使用隨機任務隊列和竊取任務。當全部任務都爲異步,且被提交至 pool 而不是 forked 時,能極大地提升吞吐量。

然而找出究竟什麼被改變了、哪些場景被影響了並不簡單。所以,讓咱們換一種方式解決。筆者會建立一個基準測試程序以模仿簡單的 ForkJoin 計算,並測量 ForkJoin 處理任務與單個線程依次完成任務各自所需時間,但願這種方法能幫咱們找出改善的具體內容。

##Java 8 和 Java 7 性能的比較

筆者建立了一個基準測試程序以探索 Java 7 和 Java 8 之間的區別是否真的明顯。若是你想查看源碼,或者親自嘗試,這裏是其 Github repo

因爲Oracle工程師的努力,OpenJDK如今已經包含 Java Microbenchmark Harness (JMH)項目,該項目專用於建立基準測試程序,且不容易出現常見的微基準測試問題與錯誤。

JMH 還附帶了 Maven 原型項目。所以,將一切設置好其實很簡單。

org.openjdk.jmh
  mh-core
  0.4.1

在寫本文時,JMH core 的最新版本是 0.4.1 ,包括了 @Param 註釋,可用一系列的參數化輸入運行基準測試程序。這減輕了手動重複執行相同基準測試的痛苦,並簡化了獲取結果的流程。

如今,每一個基準測試迭代會得到本身的 ForkJoinPool 實例,這也減小了經常使用 ForkJoinPool 實例化在 Java 8 與其以前版本中的區別。

@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 5, time = 3, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 20, time = 3, timeUnit = TimeUnit.SECONDS)
@Fork(1)
@State(Scope.Benchmark)
public class FJPBenchmark {

  @Param({ "200", "400", "800", "1600"})
  public int N;

  public List<recursivetask> tasks;
  public ForkJoinPool pool = new ForkJoinPool();

  @Setup
  public void init() {
    Random r = new Random();
    r.setSeed(0x32106234567L);
    tasks = new ArrayList<recursivetask>(N * 3);

    for (int i = 0; i < N; i++) {
      tasks.add(new Sin(r.nextDouble()));
      tasks.add(new Cos(r.nextDouble()));
      tasks.add(new Tan(r.nextDouble()));
    }
  }

  @GenerateMicroBenchmark
  public double forkJoinTasks() {
    for (RecursiveTask task : tasks) {
      pool.submit(task);
    }
    double sum = 0;
    Collections.reverse(tasks);
    for (RecursiveTask task : tasks) {
      sum += task.join();
    }
    return sum;
  }

  @GenerateMicroBenchmark
  public double computeDirectly() {
    double sum = 0;
    for (RecursiveTask task : tasks) {
      sum += ((DummyComputableThing) task).dummyCompute();
    }
    return sum;
  }
}

SinCosTanRecursiveTask 的實例,實際上 Sin 和 Cos 並不遞歸,但會分別計算 Math.sin(input)Math.cos(input) 的值 。Tan 的任務實際上會遞歸爲一組 Sin 和 Cos ,並返回二者的除法結果。

JMH 處理項目的代碼並從標有 @GenerateMicroBenchmark 註釋的方法處生成基準測試程序。你在該類上方看到的其餘註釋指定了基準測試的選項:迭代次數,計入最終結果的迭代次數,是否 fork 另外一個 JVM 進程用於基準測試以及測量哪些值。測量值能夠是代碼的吞吐量,或這些方法在一段時間內的執行次數。

@Param 指定運行基準測試程序時幾個輸入的大小。總而言之,JMH很是簡單,建立基準測試程序不須要手動處理迭代、定時或整理結果。

用 Java 7 和 8 運行該基準測試獲得如下結果。筆者分別使用的是1.7.0_40 and 1.8.0.版本。

shelajev@shrimp ~/repo/blogposts/fork-join-blocking-perf » java -version
java version "1.7.0_40"
Java(TM) SE Runtime Environment (build 1.7.0_40-b43)
Java HotSpot(TM) 64-Bit Server VM (build 24.0-b56, mixed mode)

Benchmark                   (N)   Mode   Samples         Mean   Mean error    Units
o.s.FJPB.computeDirectly    200  thrpt        20       27.890        0.306   ops/ms
o.s.FJPB.computeDirectly    400  thrpt        20       14.046        0.072   ops/ms
o.s.FJPB.computeDirectly    800  thrpt        20        6.982        0.043   ops/ms
o.s.FJPB.computeDirectly   1600  thrpt        20        3.481        0.122   ops/ms
o.s.FJPB.forkJoinTasks      200  thrpt        20       11.530        0.121   ops/ms
o.s.FJPB.forkJoinTasks      400  thrpt        20        5.936        0.126   ops/ms
o.s.FJPB.forkJoinTasks      800  thrpt        20        2.931        0.027   ops/ms
o.s.FJPB.forkJoinTasks     1600  thrpt        20        1.466        0.012   ops/ms


shelajev@shrimp ~/repo/blogposts/fork-join-blocking-perf » java -version
java version "1.8.0"
Java(TM) SE Runtime Environment (build 1.8.0-b132)
Java HotSpot(TM) 64-Bit Server VM (build 25.0-b70, mixed mode)

Benchmark                   (N)   Mode   Samples         Mean   Mean error    Units
o.s.FJPB.computeDirectly    200  thrpt        20       27.680        2.050   ops/ms
o.s.FJPB.computeDirectly    400  thrpt        20       13.690        0.994   ops/ms
o.s.FJPB.computeDirectly    800  thrpt        20        6.783        0.548   ops/ms
o.s.FJPB.computeDirectly   1600  thrpt        20        3.364        0.304   ops/ms
o.s.FJPB.forkJoinTasks      200  thrpt        20       15.868        0.291   ops/ms
o.s.FJPB.forkJoinTasks      400  thrpt        20        8.060        0.222   ops/ms
o.s.FJPB.forkJoinTasks      800  thrpt        20        4.006        0.024   ops/ms
o.s.FJPB.forkJoinTasks     1600  thrpt        20        1.968        0.043   ops/ms

爲了便於查看結果,下面以圖表形式進行展現。

咱們能夠看到 JDK 7 與 8 間的基線結果(直接用同一線程運行程序的吞吐量)差別並不大。然而,若加入管理遞歸任務的時間,使用 ForkJoin 來執行,則 Java 8 的速度更快。這個簡單的基準測試代表,在最新版的 Java 中,管理 ForkJoin 任務的效率有了 35% 左右的性能提升。

基線和 FJ 計算之間的結果差別是由於咱們刻意建立的遞歸任務很是單薄。該任務實質上只是調用一個優化後的數學類。所以,直接進行數學運算會快得多。一個更強壯的任務必將改變這一狀況,可是它們會減輕 ForkJoin 管理的開銷,而這是咱們起初就想測量的目標。不過,通常而言,執行遞歸任務比屢次執行同個方法調用要高效得多。

同時,Java 7 和 Java 8 的基線測試結果也有略微的不一樣。這個差別是能夠忽視的,但極可能不是由於 Java 7 和 8 中數學類的實現差別形成的。而是一個測量假象,JMH 努力抵消卻仍是沒法避免。

免責聲明:固然,這些結果是模擬所得的,你應該持保留態度。然而,除了討論 Java 性能,筆者也想展現 JMH 建立基準測試程序是如何簡單,且能避免一些常見基準測試問題,好比沒有提早預熱 JVM 。若是基準測試自己存在缺陷,熱身也無濟於事,可是確定仍是有所裨益。所以,若是你看到以上代碼中的邏輯缺陷,請必定告訴筆者。

##總結: 首先,ForkJoinPool, ForkJoinPool.WorkQueueForkJoinTask 類的源碼並不容易閱讀,它包含許非安全原理,所以你可能無法在15分鐘徹底理解ForkJoin 框架。

然而,這些類的文檔豐富,而且包含許多內部註釋。它也可能學習挖掘JDK最有趣的地方。

另外一個相關的發現是 ForkJoinPool 在 Java8 中的性能更好,至少在一些用例中是這樣的。雖然筆者不能精確地描述這背後的緣由,但若是我在代碼中用到 ForkJoin ,我必定會升級 Java 版本。

原文地址:http://zeroturnaround.com/rebellabs/is-java-8-the-fastest-jvm-ever-performance-benchmarking-of-fork-join/ 本文做者:Oleg Shelajev 系 OneAPM 工程師編譯整理。

OneAPM 爲您提供端到端的 Java 應用性能解決方案,咱們支持全部常見的 Java 框架及應用服務器,助您快速發現系統瓶頸,定位異常根本緣由。分鐘級部署,即刻體驗,Java 監控歷來沒有如此簡單。想閱讀更多技術文章,請訪問 OneAPM 官方技術博客 本文轉自 OneAPM 官方博客

相關文章
相關標籤/搜索