はじめに:あるコレクションから重複を許容しない別のコレクションを作りたい
Streamってはじめはとっつきにくいですよね。
でも慣れてくるとサクッと書けるし、中間処理で何をしているかがぱっと見でわかりやすくなってきます。
一方であまり複雑な処理をしたり多用しすぎると可読性が下がりやすいので、チーム開発でどこまでStreamを書いてよいかは気にした方がいいと思っています。
個人的にはStreamともう少し仲良くなったうえで距離感を図りたい。
そんななか、ふと掲題のような疑問が浮かんだので検証してみました。
Javaのバージョンは21.0.7で検証しています。
比較したい処理の概要
ある処理ターゲットのオブジェクトの詰まったリストから、以下の条件を満たすコードのみを抽出して別のリストを作ります。
- コードがnullでない
- コードが空でもない
- コードが重複しない
public record ExamineTarget(int recordIdx, TargetType targetType,
String code, String name, Date registeredAt) {
}
final List<String> rawCodeListStream = targets.stream() //
.map(target -> target.code()) //
.filter(Objects::nonNull) //
.filter(code -> !code.isEmpty()) //
.distinct() //
.toList();
final List<String> rawCodeListFor = new ArrayList<>();
for (ExamineTarget target : targets) {
final String code = target.code();
if (code == null || code.isEmpty() || rawCodeListFor.contains(code)) {
continue;
}
rawCodeListFor.add(code);
}
実行コード全体(計測処理付き)
※すべて同一パッケージ内
class Examiner {
void examine(List<ExamineTarget> targets) {
System.out.println("### " + String.format("%,d", targets.size()) + " 件での比較結果");
examineTargetsByStream(targets);
examineTargetsByFor(targets);
}
private void collectTargetsByStream(List<ExamineTarget> targets) {
// 開始時間を記録
final long startTime = System.currentTimeMillis();
final List<String> rawCodeListStream = targets.stream() //
.map(target -> target.code()) //
.filter(Objects::nonNull) //
.filter(code -> !code.isEmpty()) //
.distinct() //
.toList();
// 終了時間を記録
final long endTime = System.currentTimeMillis();
// 経過時間を計算
final long elapsedTime = endTime - startTime;
System.out.println("- 【Stream版】処理時間: " + String.format("%,d", elapsedTime) + " ミリ秒");
}
private void collectTargetsByFor(List<ExamineTarget> targets) {
// 開始時間を記録
final long startTime = System.currentTimeMillis();
final List<String> rawCodeListFor = new ArrayList<>();
for (ExamineTarget target : targets) {
final String code = target.code();
if (code == null || code.isEmpty() || rawCodeListFor.contains(code)) {
continue;
}
rawCodeListFor.add(code);
}
// 終了時間を記録
final long endTime = System.currentTimeMillis();
// 経過時間を計算
final long elapsedTime = endTime - startTime;
System.out.println("- 【拡張for文版】処理時間: " + String.format("%,d", elapsedTime) + " ミリ秒");
}
}
record ExamineTarget(int recordIdx, TargetType targetType,
String code, String name, Date registeredAt) {
}
enum TargetType {
INSERT, //
UPDATE, //
DELETE, //
//
INVALID, //
//
;
}
public class Main {
public static void main(String[] args) {
final int targetCount = 10_000_000; // 検証したいコレクション数に変更する
List<ExamineTarget> targets = generateTargets(targetCount);
new Examiner().examine(targets);
}
private static List<ExamineTarget> generateTargets(final int count) {
List<ExamineTarget> targets = new ArrayList<>();
Date date = new java.util.Date();
for (int i = 0; i < count; i++) {
targets.add(new ExamineTarget(
i,
TargetType.INSERT,
"Code" + i,
"Name" + i,
date));
}
return targets;
}
}
比較結果
件数事のStream版と拡張for文版の実行結果は次の通りでした。
| 件数 | Stream版 (ミリ秒) | 拡張for文版 (ミリ秒) |
|---|---|---|
| 1,000件 | 5 | 3 |
| 1万件 | 6 | 92 |
| 5万件 | 12 | 2,864 |
| 10万件 | 31 | 26,554 |
1万件程度であればそこまで大きな変化はありませんが、5万件だとかなりの差が出て、Stream優勢です。
・・・それにしても顕著すぎないか?
なぜ大量件数になるほどStreamのほうが速くなったのか?
GPT-4oに訊いてみました。
Streamと拡張for文の両方で共有する目的は、
targetsリストから重複しない非ヌルで空でないコードを集めることです。しかし、処理方法において設計上の違いがあります。この違いが、処理速度の差を生じさせています。特に、件数が増えると顕著になります。
Stream版について:
distinct()を用いることで、重複を効率的に削除するために内部的にハッシュベースのセットを使用します。- ハッシュベースのセットを使用すると、重複のチェックが効率化され、比較速度が高速化されます。
- ストリームは並列処理にも対応する能力があるため、大量のデータを扱う場合には特にパフォーマンスが向上する仕組みがあります(今回は一例で並列処理は使用していませんが、ストリームの背後にある設計理念として効率的な処理が期待できます)。
拡張for文版について:
- ループごとに
rawCodeListFor.contains(code)を呼び出すことで、リスト内の各要素を順番にチェックしています。contains()メソッドはリスト内を線形探索するため、データ量が増えるほど比較の回数が指数的に増し、時間がかかります。この場合、リストに追加されていない要素に対しても毎回全探索を行うため、件数が増えると劇的に遅くなります。これらの理由から、件数が増えると拡張for文の処理時間が長くなる結果となります。ストリームを用いることで、このようなボトルネックをクリアにすることができているのです。もし拡張for文版を改善したい場合は、
HashSetを用いて重複チェックを行うと高速化できます。
なるほど!
主因は拡張for文側のcontainsメソッドの繰り返し実行にありました。
結果を格納するrawCodeListForがList(ArrayList)なので、for文が回るたびに常に線形探索を実施しています。OpenJDKのArrayList#containsのソースをたどっていくとレコードを全部チェックする処理に入っていることが読み取れます。
つまり件数が多くなればなるほどcontainsの実行回数も、その探索対象となるrawCodeListForの要素数も上がっていくばかりです。
それに比べてStream#distinctは内部的にはHashSetを利用しているので線形探索が起こらないと。OpenJDKのソース上だとこのあたりです。LinkedHashSetを使っているのが読み取れます。
HashSet#containsに変更して再度比較してみる
拡張for文版に対してGPT-4oにお勧めされたHashSetを用いてのリファクタを実施して再度結果比較をしてみます。
containsの実施対象となるrawCodeListForのみSetに変更します。
private void collectTargetsByFor(List<ExamineTarget> targets) {
final Set<String> rawCodeSetFor = new HashSet<>(); // HashSetに変更
for (ExamineTarget target : targets) {
final String code = target.code();
// Setに重複レコードは入らないのでcontainsは不要な場合もあるが、比較のために敢えてそのままにする
if (code == null || code.isEmpty() || rawCodeSetFor.contains(code)) {
continue;
}
rawCodeSetFor.add(code);
}
}
比較結果
- 10万件
- 【Stream版】処理時間: 24 ミリ秒
- 【拡張for文版】処理時間: 11 ミリ秒
- 1,000万件
- 【Stream版】処理時間: 783 ミリ秒
- 【拡張for文版】処理時間: 637 ミリ秒
めちゃくちゃ速くなった!!楽しくなって1,000万件でも試してみましたが、どちらもむしろ拡張for文のほうが速くなりました。
拡張for文版をLinkedHashSetでも検証してみる
Stream#distinctでは内部的にLinkedHashSetを使っていそうだったので、拡張for文のほうもLinkedHashSet使えば順序は担保しつつ高速化できそうだな?と思ったので併せて検証してみました。
前出したSetに変更した拡張for文版の抽出メソッドのうち、Setをnewする部分をnew LinkedHashSet()<>に変更しただけです。
1,000万件での比較結果はこちら。
- 【Stream版】処理時間: 818 ミリ秒
- 【拡張for文版・HashSet版】処理時間: 578 ミリ秒
- 【拡張for文版・LinkedHashSet】処理時間: 714 ミリ秒
LinkedHashSet版は、HashSet版よりは若干劣るけどStream版よりは速い、という結果でした。
何度か実行するとたまにStream版のほうが速くなる場合もありましたが、概ね上記のような結果でした。
おわりに:重複を許容しないコレクションを作る場合、Streamと拡張for文どちらを選択するか?
もとのコレクションをListで持つ必要がないのであれば、Setに変えて拡張for文にする選択肢がとれそうです。
順序の担保が不要で重複も排除したい場合が挙げられます。
その場合、以下の場合においてはそもそもSetは重複を許容しないのでcontainsの検査も抜けてさらにスッキリします。
- もとのコレクションに重複がない
- もとのコレクションに重複がありえるが、後勝ちでOK
コレクションの順序の担保だけが必要であればLinkedHashSetを活用した拡張for文も検討対象になります。
ただしLinkedHashSetはn番目の要素をダイレクトに取得するなどは不得意なので使いどころが限られそうです(Java21以降であればgetFirst/getLastでの要素の取得だけは簡単にできます)。
もとのコレクションがListでないといけない、かつ大量件数を扱う可能性がある場合はStream一択になりそうです。
あとは並列処理をしたい場合もStreamを採用することになります。
加えてチーム開発の場合、コードの読みやすさやデバッグのしやすさも検討材料にする必要があります。
今回の処理程度であれば個人的にStreamのほうが明快で読みやすいですが、もっと複雑になってくるとStreamは読みづらくなります。
それにfor文のほうがデバッグはしやすいです。
また、今回はStreamにおけるmap, filter, containsにフォーカスした検証でしたが、そのほかの処理だとまた別のボトルネックによって採用/不採用が変わる可能性は大いにありますね。
これらを総合的に加味して、そのときどきで最善の選択を採るようにしたいですね(月並み)。