Edited at
JavaDay 13

ふわっとStreamで試したいこと試してみた。

More than 1 year has passed since last update.

なんとなくアドヴェントカレンダー参加したはいいものの、書くネタがないので書けそうなStreamの話でもしようと思う。今回試したコードはGitHubにもおいておく。


可読性の面

Java8で追加されたコイツ。もう知っている人が多いかもしれないが、ベンチマークをして実行時間を測ってみると扱う要素が小さい場合、手続き型と比べる実行時間が遅くなる。その分、メソッド名が明確で、そのソースが何をしたいのかがわかりやすいというメリットがある。


簡易ベンチマーク実行環境

ツールを使ったり、回数を100万とかやるガチガチのベンチマークする気はない

nanoTimeを使えと言われそうだけど、差がわかるくらいでいいのでcurrentTimeMillisにした。


Bench.java

public class Main {

public static void main(String[] args) {
System.out.println("Excution time:"+benchMark()+"[sec]");
}

private static double benchMark(){
long start = System.currentTimeMillis();
HogeSomeTask task = new HogeSomeTask();
task.something_do();
long end = System.currentTimeMillis();

return ((end - start) / 1000.0);
}
}


OS:Ubuntu 16.04

CPU:Intel Core i7-2700K CPU @ 5.9GHz(古いのは許して欲しい)

JDK:Open-jdk9


サンプルコード

以下のコードは条件として、5文字未満の対象データを出力する処理のコードとベンチマークをした結果である。これと逆に5文字以上のデータを出力する処理も試したけど、結果はほぼ同じだったので省略。


Procedural.java

    public void use_for(){ 

List<String>list = Arrays.asList("Java","Ruby","Csharp","Scala","Haskell");
for(String lang : list){
if(lang.length() < 5){
System.out.println(lang);
}
}
}

10回平均実行時間は0.001[sec]。かなり速い


Streamを使う


Stream.java

 public void use_stream(){ 

List<String>list = Arrays.asList("Java","Ruby","Csharp","Scala","Haskell");
list.stream().filter(lang -> lang.length() < 5).forEach(System.out::println);
 }

10回平均実行時間は0.025[sec]。手続き型と比べて少し遅くなっている。


Java9で、Streamにも新機能が出た

今更な感じもあるけど、メソッドについて軽くおさらいしておこう。take/dropWhileメソッドによってScalaっぽく書ける。


takeWhile

対象とする条件を指定するだけで対象データ(条件一致している間)の処理が可能なメソッド。中間処理が減った。


takeWhileExample.java

   List<String>list = Arrays.asList("Java","Ruby","Csharp","Scala","Haskell")   

list.stream().takeWhile(lang -> lang.length() < 5).forEach(System.out::println);


dropWhile

対象とする条件を指定するだけで対象データ(条件一致以降)の出力が可能なメソッド。takeWhile同様、中間処理が減った。


dropWhileExample.java

  List<String> list = Arrays.asList("Java","Ruby","Csharp","Scala","Haskell");

list.stream().dropWhile(lang -> lang.length() < 5).forEach(System.out::println);


ofNullable

対象のデータがnullではない場合は、Streamを返送。nullの場合は、空のStreamを返送するメソッド。

以下のようにOptionalから直接Streamを書けるようになった


optional.java

Optional.ofNullable(null).stream().forEach(System.out::println);



気になる速さは

すべて試してベンチマークしてみたので、表でまとめた。ofNullableに関しては、nullを安全に扱う点が大きいらしいので、データを扱うパフォーマンスを検証してる今回においては、アドカレの時間もないので省略させてもらう。


1.上のサンプルコードの処理

平均実行時間(10回)

手続き型
0.001[sec]

Stream
0.025[sec]

parallelStream
0.026[sec]

takeWhile
0.026[sec]

takeWhile(parallelStream使用)
0.032[sec]

Java8からあるメソッドと比較しても大差は無い。中間処理が遅くしているようだ


2. 1と逆の処理

平均実行時間(10回)

手続き型
0.001[sec]

Stream
0.023[sec]

parallelStream
0.031[sec]

dropWhile
0.024[sec]

dropWhile(parellelStream使用)
0.028[sec]

上に同じ


手続き型に負ける理由

おおまかに言うと、手続き型は処理がほぼそのまま素直に書き出されてjdkでコンパイルされるので、中間処理をデータが単純でも行うStreamは遅くなるようだ。


補足

遅い根拠をはっきりさせたい。そのためにStreamメソッドに書いてある処理をIntelliJの機能でどんどん辿ってみた。細かいメソッド呼び出しに分割されたり、遅延実行などの仕組みが遅くしている。


Streamが速い時はあるのか

どういう時に書けばいいか、そんなのはもう読みやすいStreamだけ選んでればいいし、遅いと言っても差は大したものではない。ただ、パフォーマンス的に言う話なら、扱う要素が大きい場合に効力を発揮しそうなので試してみた。


条件

テスト用データとして、アルファベット大文字と数字の20文字からなるランダムな文字列を100万要素作成する。


BigData.java

Random r = new Random(2111);

List<String> data = range(0, 1_000_000)
.mapToObj(i->
r.ints().limit(20)
.map(n -> Math.abs(n) % 36)
.map(code -> (code < 10) ? '0' + code : 'A' + code - 10)
.mapToObj(ch -> String.valueOf((char)ch))
.toString())
.collect(toList());

この要素から、数字のみを抜き出し、その合計が30以下を抜き出す処理を行う。


手続き型のサンプル


Procedural.java

  public static long use_for(List<String> data){

long result = 0;
for(String d : data){
String numOnly = d.replaceAll("[^0-9]", "");
if(numOnly.isEmpty()) continue;
int total = 0;
for(char ch : numOnly.toCharArray()){
total += ch - '0';
}
if(total >= 30) continue;
long value = Long.parseLong(numOnly);
result += value;
}
return result;
}


Streamのサンプル


Stream.java

 public static long streamSum(List<String>data){

return data.stream()
.map(d -> d.replaceAll("[^0-9]", ""))
.filter(d -> !d.isEmpty())
.filter(d -> d.chars().map(ch -> ch - '0').sum() < 30)
.mapToLong(d -> Long.parseLong(d)).sum();
}


takeWhile/dropWhileも試す

ただのstream,parallelStreamと比べてどうなるのか気になるので試す。


takeWhileSample.java

  public static long takeWhileSum(List<String> data){

return data.stream()
.map(d -> d.replaceAll("[^0-9]", ""))//数字以外を取り除く
.takeWhile(d -> !d.isEmpty())
.takeWhile(d -> d.chars().map(ch -> ch - '0').sum() < 30)//数字の合計が30より小さい
.mapToLong(d -> Long.parseLong(d)).sum();
}


dropWhileSample

    public static long dropWhileSum(List<String> data){

return data.stream()
.map(d -> d.replaceAll("[^0-9]", ""))
.dropWhile(d -> d.isEmpty())
.dropWhile(d -> d.chars().map(ch -> ch - '0').sum() > 30)
.mapToLong(d -> Long.parseLong(d)).sum();
}


ベンチマークの結果

平均実行時間(10回)

手続き型
2.132[sec]

parallelStream
1.321[sec]

Stream
2.107[sec]

takeWhile
0.457[sec]

takeWhile(parallelStream)
1.325[sec]

dropWhile
2.175[sec]

dropWhile(parallelStream)
1.377[sec]

ついに真価が見えた気がする。takeWhileがずば抜けて速い。

dropWhileは手続き型と変わらないくらいだったが、parallelStreamから呼び出すことでだいぶ良くなった。


結論


  • Streamで書くのが当たり前だがベスト。

  • parallelStreamはstreamより中間処理が効率的(?)

  • パフォーマンスは要素が大きい場合によい結果が出てくる。

  • takeWhileメソッドは更に中間処理の効率がよくなっている。

  • dropWhileメソッドはparallelStreamから呼び出す方がパフォーマンス的にはベター

以上。気になっていた事を試せた良い機会だった。


参考にした記事

Java8のStreamの目的と書きやすさや可読性、並行処理の効果について

C++, Java, Pythonでプログラムの実行結果を計測してみる.

など。