1. Spliterator 入門
Java のコレクションは Iterator だけで反復するものだと思っていたなら、Java 8 まではまさにそのとおりでした。しかし Stream API と並列処理の潮流とともに、新たなヒーロー Spliterator が登場しました。
Spliterator は、コレクションの要素を走査するだけでなく、データソースを分割して並列処理できるインターフェースです。名称は split と iterator を組み合わせた造語です。
大きなケーキを想像してください。普通の Iterator はケーキを一切れずつ順番に食べます。Spliterator はケーキを半分に切って、半分を友人に渡せます—そして二人同時に食べ始められます。友人が多ければ、さらに分割しましょう!
Spliterator インターフェース — 主要メソッド
public interface Spliterator<T> {
boolean tryAdvance(java.util.function.Consumer<? super T> action);
Spliterator<T> trySplit();
long estimateSize();
int characteristics();
// ... 他にもいくつかメソッドがあるが、これらが最重要
}
- tryAdvance — 次の要素に対して処理を行う(next() + アクションのようなもの)。
- trySplit — ソースを二つに分割し、「切り出した」部分用の新しい Spliterator を返す。
- estimateSize — 残り要素数を見積もる。
- characteristics — 特性のビットマスクを返す(順序性、一意性、不変性 など)。
2. Spliterator の使い方: 手動走査と分割
コレクションから Spliterator を取得する
Collection を実装している任意のコレクションは、自身の Spliterator を返せます:
import java.util.List;
import java.util.Spliterator;
List<String> names = List.of("Vasya", "Petya", "Masha", "Lena");
Spliterator<String> spliterator = names.spliterator();
要素を手動で走査する
Spliterator<String> spliterator = names.spliterator();
while (spliterator.tryAdvance(name -> System.out.println("名前: " + name))) {
// すべては tryAdvance 内で行われる
}
コレクションを分割する
最も興味深いのは trySplit() メソッドです:
Spliterator<String> spliterator1 = names.spliterator();
Spliterator<String> spliterator2 = spliterator1.trySplit();
System.out.println("パート1:");
spliterator1.forEachRemaining(System.out::println);
System.out.println("パート2:");
if (spliterator2 != null) {
spliterator2.forEachRemaining(System.out::println);
}
何が起こるか: Spliterator はコレクションを二つの部分に分割しようとします(常に半分とは限らず、実装に依存します)。これで両方の部分を独立して処理できます—スレッドを分けてもかまいません。
3. 並列ストリーム: 目的と仕組み
並列ストリーム(parallelStream())は、要素を順番ではなく複数スレッドで同時に処理するストリームです。大量データやマルチコア環境で特に有効です。
import java.util.List;
List<String> names = List.of("Vasya", "Petya", "Masha", "Lena");
// 通常のストリーム:
names.stream().forEach(System.out::println);
// 並列ストリーム:
names.parallelStream().forEach(System.out::println);
ポイントは何か?
通常のストリームでは要素は単一スレッドで処理されます。並列ストリームでは、ソースが(Spliterator によって)分割され、各部分が別スレッドで処理されます。
内部ではどう動くのか?
- Spliterator がコレクションを部分に分割する—通常は利用可能なコア数(あるいはそれ以上)に合わせます。
- 各部分はそれぞれのスレッドで処理され、共有の ForkJoinPool が使われます。
- 結果は再び集約され、最終的なコレクションや値にまとめられます。
並列ストリームの動作図
flowchart LR
A[コレクション] --> B{Spliterator}
B --> C1[部分 1] --> D1[スレッド 1]
B --> C2[部分 2] --> D2[スレッド 2]
B --> C3[部分 3] --> D3[スレッド 3]
D1 & D2 & D3 --> E[結果の集約]
4. 並列ストリームの利点と制約
利点
- 高速化: 大きなコレクションの処理で、重い計算なら並列ストリームは実行時間を大幅に短縮できます。
- 簡潔さ: マルチスレッドコードを自前で書く必要はありません—stream() を parallelStream() に替えるだけです。
制約と落とし穴
- 常に速いとは限らない: 小さなコレクションではオーバーヘッドが利益を打ち消すことがあります。
- 順序は保証されない: forEach/map/filter などでは順序が変わり得ます。順序が必要な場合は forEachOrdered を使います。
- スレッドセーフティの問題: 副作用のある操作(外部コレクション/変数の変更)はデータ競合を招きます。
- 向かない操作もある: 依存的な計算(例: 逐次的な累積)は期待どおりに動作しないことがあります。
いつ並列ストリームを使うべきか?
- 大きなコレクション(数万要素以上)。
- 各要素で重い処理を行う。
- 厳密な順序が必須ではない。
- 副作用がない(純粋関数)。
使うべきでない場合
- 要素数が少ない。
- 外部の変数やコレクションを変更する。
- 処理順序の保持が重要。
- データソースの分割が苦手(例: LinkedList)。
5. 実践例
例1: 実行時間の比較
import java.util.*;
import java.util.stream.*;
public class ParallelStreamDemo {
public static void main(String[] args) {
List<Integer> numbers = IntStream.range(0, 10_000_000)
.boxed()
.collect(Collectors.toList());
long start = System.currentTimeMillis();
long count = numbers.stream()
.filter(n -> isPrime(n))
.count();
long time = System.currentTimeMillis() - start;
System.out.println("通常のストリーム: " + time + " ミリ秒、素数の個数: " + count);
start = System.currentTimeMillis();
count = numbers.parallelStream()
.filter(n -> isPrime(n))
.count();
time = System.currentTimeMillis() - start;
System.out.println("並列ストリーム: " + time + " ミリ秒、素数の個数: " + count);
}
// 簡易な素数判定(デモ用)
public static boolean isPrime(int n) {
if (n < 2) return false;
for (int i = 2, sqrt = (int)Math.sqrt(n); i <= sqrt; i++)
if (n % i == 0) return false;
return true;
}
}
結果: 大量データでは並列ストリームのほうが高速になることが多いです(特にマルチコア CPU)。小さなデータでは差が出ないか、並列のほうが遅くなることもあります。
例2: 順序の問題
import java.util.List;
List<String> names = List.of("Vasya", "Petya", "Masha", "Lena");
System.out.println("通常のストリーム:");
names.stream().forEach(System.out::println);
System.out.println("並列ストリーム:");
names.parallelStream().forEach(System.out::println);
System.out.println("forEachOrdered を使った並列ストリーム:");
names.parallelStream().forEachOrdered(System.out::println);
結論: 通常のストリームおよび forEachOrdered を使った場合は順序が保たれますが、並列ストリームでそれを使わない場合は順序が変わり得ます。
例3: 副作用の危険
import java.util.*;
import java.util.stream.*;
List<Integer> numbers = IntStream.range(1, 1000).boxed().collect(Collectors.toList());
List<Integer> results = new ArrayList<>();
// 危険!こうしないでください!
numbers.parallelStream().forEach(n -> results.add(n * n));
System.out.println("リストのサイズ: " + results.size());
何が起きるか? 期待より小さいサイズになったり、ConcurrentModificationException が発生することがあります。理由は、ArrayList はスレッドセーフではないのに、並列ストリームは複数スレッドを同時に走らせるからです。
6. Spliterator: 特性と特徴
Spliterator の特性
Spliterator は自分の特性をビットマスクで表します:
- ORDERED — 要素に一定の順序がある(例: List)。
- DISTINCT — すべての要素が一意(例: Set)。
- SORTED — 要素がソート済み。
- SIZED — サイズが既知。
- IMMUTABLE — コレクションが不変。
- CONCURRENT — コレクションがスレッドセーフ。
- SUBSIZED — trySplit() 後のすべての spliterator も自分のサイズを把握している。
Spliterator<String> spliterator = names.spliterator();
int characteristics = spliterator.characteristics();
System.out.println(Integer.toBinaryString(characteristics));
なぜ知っておくべきか? Stream API と並列ストリームは、これらの特性を最適化に利用します。例えば、ソースが不変でソート済みなら、より安全かつ効率的に分割・集約できます。
7. Spliterator を直接使うのはいつ・どうやって?
日常では独自の Spliterator を書く機会は多くありません。標準コレクションがすでに実装しています。しかし独自のデータソースを作る、あるいは走査/分割を細かく制御したい場合には、Spliterator が役立ちます。
例: tryAdvance による手動走査
import java.util.List;
import java.util.Spliterator;
List<String> names = List.of("Vasya", "Petya", "Masha", "Lena");
Spliterator<String> spliterator = names.spliterator();
spliterator.tryAdvance(name -> System.out.println("最初の要素: " + name));
spliterator.forEachRemaining(name -> System.out.println("残り: " + name));
例: コレクションの分割
Spliterator<String> spliterator1 = names.spliterator();
Spliterator<String> spliterator2 = spliterator1.trySplit();
if (spliterator2 != null) {
spliterator2.forEachRemaining(name -> System.out.println("パート2: " + name));
}
spliterator1.forEachRemaining(name -> System.out.println("パート1: " + name));
8. Spliterator と並列ストリームでよくある誤り
誤り1: 小さなコレクションに並列ストリームを使う。 高速化どころか遅くなります。分割やタスクスケジューリングのオーバーヘッドがメリットを上回ります。
誤り2: 要素順序が保たれると期待する。 並列ストリームは順序を保証しません。順序が重要なら forEachOrdered を使いますが、並列化の効果は一部失われます。
誤り3: ラムダ式の中で副作用を持たせる。 並列ストリーム内で外部の変数やコレクションを安全に変更することはできません—データ競合と厄介なバグの原因になります。
誤り4: 並列ストリーム内で非スレッドセーフなコレクションを使う。 複数スレッドから通常の ArrayList に追加するのは、ConcurrentModificationException などのエラーへの近道です。
誤り5: 即時の高速化を期待する。 並列ストリームは魔法ではありません。プロファイリングしましょう。データが少ない、または処理が軽いなら、通常のストリームのほうが速いこともあります。
誤り6: 分割に不向きなソースで並列ストリームを使う。 例えば LinkedList は非効率に分割されることが多く、並列化がかえって遅くなることがあります。
GO TO FULL VERSION