Java 8帶來了很多可以使編碼更簡潔的特性。例如,像下面的代碼:
Collections.sort(transactions, new Comparator<Transaction>(){ public int compare(Transaction t1, Transaction t2){ return t1.getValue().compareTo(t2.getValue()); }});
可以用替換為如下更為緊湊的代碼,功能相同,但是讀上去與問題語句本身更接近了:
transactions.sort(comparing(Transaction::getValue));
Java 8引入的主要特性是Lambda表達(dá)式、方法引用和新的Streams API。它被認(rèn)為是自20年前Java誕生以來語言方面變化最大的版本。要想通過詳細(xì)且實(shí)際的例子來了解如何從這些特性中獲益,可以參考本文作者和Alan Mycroft共同編寫的《Java 8 in Action: Lambdas, Streams and Functional-style programming》一書。
這些特性支持程序員編寫更簡潔的代碼,還使他們能夠受益于多核架構(gòu)。實(shí)際上,編寫可以優(yōu)雅地并行執(zhí)行的程序還是Java專家們的特權(quán)。然而,借助新的Streams API,Java 8改變了這種狀況,讓每個(gè)人都能夠更容易地編寫利用多核架構(gòu)的代碼。
相關(guān)廠商內(nèi)容
在這篇文章中,我們將使用以下三種風(fēng)格,以不同方法計(jì)算一個(gè)大數(shù)據(jù)集的方差,并加以對(duì)比。
方差是統(tǒng)計(jì)學(xué)中的概念,用于度量一組數(shù)的偏離程度。方差可以通過對(duì)每個(gè)數(shù)據(jù)與平均值之差的平方和求平均值來計(jì)算。例如,給定一組表示人口年齡的數(shù):40、30、50和80,我們可以這樣計(jì)算方差:
下面是計(jì)算方差的一種典型的命令式風(fēng)格實(shí)現(xiàn):
public static double varianceImperative(double[] population){ double average = 0.0; for(double p: population){ average += p; } average /= population.length; double variance = 0.0; for(double p: population){ variance += (p - average) * (p - average); } return variance/population.length;}
為什么說這是命令式的呢?我們的實(shí)現(xiàn)用修改狀態(tài)的語句序列描述了計(jì)算過程。這里,我們顯式地對(duì)人口年齡數(shù)組中的每個(gè)元素進(jìn)行迭代,而且每次迭代時(shí)更新average和variance這兩個(gè)局部變量。這種代碼很適合只有一個(gè)CPU的硬件架構(gòu)。確實(shí),它可以非常直接地映射到CPU的指令集。
那么,如何編寫適合在多核架構(gòu)上執(zhí)行的實(shí)現(xiàn)代碼呢?應(yīng)該使用線程嗎?這些線程是不是要在某個(gè)點(diǎn)上同步?Java 7引入的Fork/Join框架緩解了一些困難,所以讓我們使用該框架來開發(fā)方差算法的一個(gè)并行版本吧。
public class ForkJoinCalculator extends RecursiveTask<Double> { public static final long THRESHOLD = 1_000_000; private final SequentialCalculator sequentialCalculator; private final double[] numbers; private final int start; private final int end; public ForkJoinCalculator(double[] numbers, SequentialCalculator sequentialCalculator) { this(numbers, 0, numbers.length, sequentialCalculator); } private ForkJoinCalculator(double[] numbers, int start, int end, SequentialCalculator sequentialCalculator) { this.numbers = numbers; this.start = start; this.end = end; this.sequentialCalculator = sequentialCalculator; } @Override protected Double compute() { int length = end - start; if (length <= THRESHOLD) { return sequentialCalculator.computeSequentially(numbers, start, end); } ForkJoinCalculator leftTask = new ForkJoinCalculator(numbers, start, start + length/2, sequentialCalculator); leftTask.fork(); ForkJoinCalculator rightTask = new ForkJoinCalculator(numbers, start + length/2, end, sequentialCalculator); Double rightResult = rightTask.compute(); Double leftResult = leftTask.join(); return leftResult + rightResult; }}
這里我們編寫了一個(gè)RecursiveTask類的子類,它對(duì)一個(gè)double數(shù)組進(jìn)行切分,當(dāng)子數(shù)組的長度小于等于給定閾值(THRESHOLD)時(shí)停止切分。切分完成后,對(duì)子數(shù)組進(jìn)行順序處理,并將下列接口定義的操作應(yīng)用于子數(shù)組。
public interface SequentialCalculator { double computeSequentially(double[] numbers, int start, int end);}
利用該基礎(chǔ)設(shè)施,可以按如下方式并行計(jì)算方差。
public static double varianceForkJoin(double[] population){ final ForkJoinPool forkJoinPool = new ForkJoinPool(); double total = forkJoinPool.invoke(new ForkJoinCalculator(population, new SequentialCalculator() { @Override public double computeSequentially(double[] numbers, int start, int end) { double total = 0; for (int i = start; i < end; i++) { total += numbers[i]; } return total; } })); final double average = total / population.length; double variance = forkJoinPool.invoke(new ForkJoinCalculator(population, new SequentialCalculator() { @Override public double computeSequentially(double[] numbers, int start, int end) { double variance = 0; for (int i = start; i < end; i++) { variance += (numbers[i] - average) * (numbers[i] - average); } return variance; } })); return variance / population.length;}
本質(zhì)上,即便使用Fork/Join框架,相對(duì)于順序版本,并行版本的編寫和最后的調(diào)試仍然困難許多。
Java 8讓我們可以以不同的方式解決這個(gè)問題。不同于編寫代碼指出計(jì)算如何實(shí)現(xiàn),我們可以使用Streams API粗線條地描述讓它做什么。作為結(jié)果,庫能夠知道如何為我們實(shí)現(xiàn)計(jì)算,并施以各種各樣的優(yōu)化。這種風(fēng)格被稱為聲明式編程。Java 8有一個(gè)為利用多核架構(gòu)而專門設(shè)計(jì)的并行Stream。我們來看一下如何使用它們來更快地計(jì)算方差。
假定讀者對(duì)本節(jié)探討的Stream有些了解。作為復(fù)習(xí),Stream<T>是T類型元素的一個(gè)序列,支持聚合操作。我們可以使用這些操作來創(chuàng)建表示計(jì)算的一個(gè)管道(pipeline)。這里的管道和UNIX的命令管道一樣。并行Stream就是一個(gè)可以并行執(zhí)行管道的Stream,可以通過在普通的Stream上調(diào)用parallel()方法獲得。要復(fù)習(xí)Stream,可以參考Javadoc文檔。
好消息是,Java 8 API內(nèi)建了一些算術(shù)操作,如max、min和average。我們可以使用Stream的幾種基本類型特化形式來訪問前面幾個(gè)方法:IntStream(int類型元素)、LongStream(long類型元素)和DoubleStream(double類型元素)。例如,可以使用IntStream.rangeClosed()創(chuàng)建一系列數(shù),然后使用max()和min()方法計(jì)算Stream中的最大元素和最小元素。
回到最初的問題,我們想使用這些操作來計(jì)算一個(gè)規(guī)模較大的人口年齡數(shù)據(jù)的方差。第一步是從人口年齡數(shù)組創(chuàng)建一個(gè)Stream,可以通過Arrays.stream()靜態(tài)方法實(shí)現(xiàn):
DoubleStream populationStream = Arrays.stream(population).parallel();
我們可以使用DoubleStream所支持的average()方法:
double average = populationStream.average().orElse(0.0);
下一步是使用average計(jì)算方差。人口年齡中的每個(gè)元素首先需要減去平均值,然后計(jì)算差的平方??梢詫⑵湟曌饕粋€(gè)Map操作:使用一個(gè)Lambda表達(dá)式(double p) -> (p - average) * (p - average)把每個(gè)元素轉(zhuǎn)換為另一個(gè)數(shù),這里是轉(zhuǎn)換為該元素與平均值差的平方。一旦轉(zhuǎn)換完成,我們就可以調(diào)用sum()方法來計(jì)算所有結(jié)果元素的和了。.
不過別那么著急。Stream只能消耗一次。如果復(fù)用populationStream,我們會(huì)碰到下面這個(gè)令人驚訝的錯(cuò)誤:
java.lang.IllegalStateException: stream has already been operated upon or closed
所以我們需要使用第二個(gè)流來計(jì)算方差,如下所示:
public static double varianceStreams(double[] population){ double average = Arrays.stream(population).parallel().average().orElse(0.0); double variance = Arrays.stream(population).parallel() .map(p -> (p - average) * (p - average)) .sum() / population.length; return variance;}
通過使用Streams API內(nèi)建的操作,我們以聲明式、而且非常簡潔的方式重寫了最初的命令式風(fēng)格代碼,而且聲明式風(fēng)格讀上去幾乎就是方差的數(shù)學(xué)定義。我們?cè)賮硌芯恳幌氯N實(shí)現(xiàn)版本的性能。
我們以非常不同的風(fēng)格編寫了三個(gè)版本的方差算法。Stream版本是最簡潔的,而且是以聲明式風(fēng)格編寫的,它讓類庫去確定具體的實(shí)現(xiàn),并利用多核基礎(chǔ)設(shè)施。不過你可能想知道它們的執(zhí)行效果如何。為找出答案,讓我們創(chuàng)建一個(gè)基準(zhǔn)測(cè)試,對(duì)比一下三個(gè)版本的表現(xiàn)。我們先隨機(jī)生成1到140之間的3000萬個(gè)人口年齡數(shù)據(jù),然后計(jì)算其方差。我們使用jmh來研究每個(gè)版本的性能。Jmh是OpenJDK支持的一個(gè)Java套件。讀者可以從GitHub克隆該項(xiàng)目,自己運(yùn)行基準(zhǔn)測(cè)試。
基準(zhǔn)測(cè)試運(yùn)行的機(jī)器是Macbook Pro,配備2.3 GHz的4核Intel Core i7處理器,16GB 1600MHz DDR3內(nèi)存。此外,我們使用的JDK 8版本如下:
java version "1.8.0-ea"Java(TM) SE Runtime Environment (build 1.8.0-ea-b121)Java HotSpot(TM) 64-Bit Server VM (build 25.0-b63, mixed mode)
結(jié)果用下面的柱狀圖說明。命令式版本用了60毫秒,F(xiàn)ork/Join版本用了22毫秒,而流版本用了46毫秒。
這些數(shù)據(jù)應(yīng)該謹(jǐn)慎對(duì)待。比如,如果在32位JVM上運(yùn)行測(cè)試,結(jié)果很可能有較大的差別。然而有趣的是,使用Java 8中的Streams API這種不同的編程風(fēng)格,為在場(chǎng)景背后執(zhí)行一些優(yōu)化打開了一扇門,而這在嚴(yán)格的命令式風(fēng)格中是不可能的;相對(duì)于使用Fork/Join框架,這種風(fēng)格也更為直接。
聯(lián)系客服