Java Streams vs. 命令型ループ:パフォーマンス、可読性、そして開発者の決定
Java 8 で何が変わったか
2014年に Java 8 が登場し、ラムダ式と Stream API が Java に加わった。これにより、関数型プログラミングのスタイルでデータ処理を書けるようになった。
それまでの Java は命令型言語で、プログラムは「どうやって」タスクを実行するかをステップごとに指定する書き方が主流だった。Stream API は逆で、「何を」やりたいかを宣言する書き方を可能にする。配列やリストといったソースから要素のシーケンスを作り、filter/map/reduce のチェーンで処理を記述する。
この記事では、命令型ループから Stream API への移行が、パフォーマンスと可読性の観点で意味があるかを検証する。
Streams による関数型プログラミングの利点
ラムダと Streams の組み合わせが威力を発揮する場面を、具体的な例で見よう。配列内の偶数の二乗和を求めるコードだ。
命令型(ループ):
int sumOfEvenSquares(int[] v) {
int result = 0;
for (int i = 0; i < v.length; i++) {
if (v[i] % 2 == 0) {
result += v[i] * v[i];
}
}
return result;
}関数型(Stream):
int sumOfEvenSquares(int[] v) {
return IntStream.of(v)
.filter(x -> x % 2 == 0)
.map(x -> x * x)
.sum();
}Stream 版の方が明らかに短い。理由は内部反復だ。開発者はループを回す実装の詳細(順次か並列かも含めて)をフレームワークに任せ、データ処理のロジックに集中できる。
さらに、ラムダ式やメソッド参照(Integer::max など)のおかげで、無名内部クラスのボイラープレートが消える。
パフォーマンス比較:Streams vs 命令型ループ
Stream を使い始めるとき、多くの開発者が気にするのは、JVM で最適化された命令型ループと比べて、どれくらいパフォーマンスのペナルティがあるかだ。
GitHub の公開プロジェクトで Streams が実際にどう使われているかを模倣したベンチマーク結果がある。それを見ていこう。
パフォーマンスに影響する要因
Stream のパフォーマンスは一定ではない。いくつかの要因に依存する。
入力サイズ
入力サイズとは、データソース(リストや配列)の要素数のことだ。
- 小さい入力(1〜1,000要素)では、Stream は命令型ループより遅くなる傾向がある。
- 大きい入力(10,000〜1,000,000要素)では、Stream のパフォーマンスが向上し、場合によっては命令型同等かそれよりわずかに速い。
皮肉なのは、GitHub での実際の Stream 使用の91%が10未満の要素を扱っていたことだ。つまり、現実の大半のユースケースは、Stream が最も不利な領域で使われている。
パイプラインの長さと操作のタイプ
Stream パイプラインは、ソース、0個以上の中間操作、そして終端操作で構成される。
- パイプラインの長さ(中間操作の数 + 終端操作)はパフォーマンスに影響するが、単純な法則はない。
anyMatch()のような一部の終端操作は中間操作なしで単独だと速い。一方、collect()は中間操作が1つある場合に良い結果を出すことがある。 - ステートフル操作(
sorted()やdistinct()など)は入力全体を処理してからでないと結果を出せないため、パフォーマンスに悪影響を与えうる。
並列 Streams
順次から並列へ、パイプラインを .parallel() 一つで切り替えられるのは Stream API の大きな卖点だ。
だが、実際には並列 Stream はほとんど使われていない。GitHub での調査では、パイプラインのわずか 0.34% にとどまる。
並列化は銀の弾丸ではない。順次より速くなる保証がない。使う前に計測すること。並列 Stream で良いパフォーマンスを得るには、オートボクシングを避け、分割しやすいデータ構造を選ぶ。ArrayList は優秀、HashSet/TreeSet は良好、LinkedList は不向きだ。
Stream デバッグの課題と対処
ラムダと Stream のデバッグは、コードの簡潔さと中間操作の遅延評価のせいで難しくなることがある。
いくつかのアプローチを紹介する。
peek()を使う: 中間操作peek(Consumer<T>): Stream<T>で、パイプラインの特定の地点で要素を観察できる。ログ出力やブレークポイントの設定に使う。データフローを変えずに、処理も止めずに確認できる。- ラムダを分割する: ラムダ内の途中値を検査したいときは、単一行ラムダを一時変数を宣言するブロックに書き換えて、ブレークポイントを設定しやすくする。
- IDE のツールを使う: IntelliJ IDEA には Java Stream Debugger があり、各操作を通過する値を順番にトレースできる。
いつ Streams を使うか
ベンチマーク研究の結論を言えば、Stream を命令型ループの代わりに使ったときのペナルティは、多くの場合わずかだ。Stream のパフォーマンスは主に入力サイズとパイプライン内の操作の性質で決まる。
私の判断基準はこうだ。
- 可読性と保守性を優先する場面では Stream を選ぶ。コードが短くなり、表現力が上がり、バグの入り込む余地が減る。
- ギリギリのパフォーマンスが求められるホットパスでは命令型ループを検討する。特に小さい入力や、高度に最適化されたアルゴリズムでは、ループの方がわずかに速いことがある。
- 大規模なデータ処理では、順次/並列 Stream が適している。ただし並列化の効果は計測して確認し、
ArrayListのような分割しやすいデータ構造を優先する。
調査結果を総合すると、保守性とバグ削減のメリットが、わずかなパフォーマンスの犠牲を上回る場面が多い。つまり、Stream をもっと使っていい。ただし、ホットパスでは計測してから判断する。