なんとなくアドヴェントカレンダー参加したはいいものの、書くネタがないので書けそうなStreamの話でもしようと思う。今回試したコードはGitHubにもおいておく。
可読性の面
Java8で追加されたコイツ。もう知っている人が多いかもしれないが、ベンチマークをして実行時間を測ってみると扱う要素が小さい場合、手続き型と比べる実行時間が遅くなる。その分、メソッド名が明確で、そのソースが何をしたいのかがわかりやすいというメリットがある。
簡易ベンチマーク実行環境
ツールを使ったり、回数を100万とかやるガチガチのベンチマークする気はない
nanoTimeを使えと言われそうだけど、差がわかるくらいでいいのでcurrentTimeMillisにした。
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文字以上のデータを出力する処理も試したけど、結果はほぼ同じだったので省略。
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を使う
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
対象とする条件を指定するだけで対象データ(条件一致している間)の処理が可能なメソッド。中間処理が減った。
List<String>list = Arrays.asList("Java","Ruby","Csharp","Scala","Haskell")
list.stream().takeWhile(lang -> lang.length() < 5).forEach(System.out::println);
dropWhile
対象とする条件を指定するだけで対象データ(条件一致以降)の出力が可能なメソッド。takeWhile同様、中間処理が減った。
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.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万要素作成する。
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以下を抜き出す処理を行う。
##手続き型のサンプル
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のサンプル
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と比べてどうなるのか気になるので試す。
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();
}
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でプログラムの実行結果を計測してみる.
など。