ねらいと前提
「ストリームがどこでどう変わっているか」を素早く可視化できると、原因特定が一気に速くなります。中間確認の王道は peek と「小さく材質化(toList など)」の使い分けです。重要なのは、ストリームが遅延評価であること、終端操作が無いと中間操作は走らないこと、並列や短絡がログに影響することを常に意識することです。
peek による中間確認の基本
もっとも手早いデバッグは「段階ごとにのぞき見」
peek は「要素をそのまま流しつつ副作用を挟む」中間操作です。filter や map の直後に置いて、通過した値を確認します。
List<String> names = List.of("one", "two", "three", "four");
List<String> out = names.stream()
.filter(s -> s.length() > 3)
.peek(s -> System.out.println("after filter: " + s)) // ここで中間確認
.map(String::toUpperCase)
.peek(s -> System.out.println("after map: " + s)) // さらに確認
.toList(); // 終端が必要
// 出力例:
// after filter: three
// after map: THREE
// after filter: four
// after map: FOUR
Java終端(collect など)が無いと peek は動きません。デバッグ時は必ず終端を残します。
実運用のログにはロガーを使う
標準出力ではなくロガー(例:SLF4J)を使うと、並列時のスレッド名やレベル制御が行えます。
var log = org.slf4j.LoggerFactory.getLogger("stream");
list.stream()
.filter(this::cheapCond)
.peek(x -> {
if (log.isDebugEnabled()) {
log.debug("[{}] pass filter: {}", Thread.currentThread().getName(), x);
}
})
.map(this::transform)
.peek(y -> log.debug("[{}] after map: {}", Thread.currentThread().getName(), y))
.forEach(this::sink);
Java並列ストリームではログ行が混ざるため、スレッド名を出すと追跡しやすくなります。
並列・順序・短絡がログに与える影響
並列では出力順が崩れる
parallel() で実行すると、peek の出力順は入力順と一致しません。順序を保証したい終端は forEachOrdered を使いますが、コストが増えるため「必要な場所だけ」に限定します。
Stream.of(1,2,3,4,5).parallel()
.map(n -> n * 2)
.peek(n -> System.out.println("peek: " + n))
.forEachOrdered(n -> System.out.println("ordered: " + n));
Java短絡操作はログを「途中で止める」
limit、findFirst、anyMatch は必要数に達したらパイプラインを止めます。デバッグ時に「全部見たいのに途中で止まる」なら、短絡を外すか位置を後段に移してください。
Stream.of("A","B","C","D")
.filter(s -> s.compareTo("C") >= 0)
.peek(s -> System.out.println("after filter: " + s))
.limit(1) // ここで短絡
.forEach(System.out::println);
Java段階的スナップショットと小さく材質化
小さく切り出して材質化し、値を落ち着いて確認する
大量データを丸ごと材質化するのは危険ですが、「先頭 N 件だけ」なら安全です。上流に limit を置いてピークメモリを抑えます。
List<String> preview = input.stream()
.filter(this::cheapCond)
.map(this::normalize)
.limit(50) // 先頭だけ見る
.toList();
System.out.println("preview: " + preview);
Javateeing(2 系統の終端)で値と統計を同時に見る
Java 12 以降なら、Collectors.teeing で「リスト」と「統計」などを同時取得できます。実動パイプラインを変えずに、デバッグ用の統計を添えるのに便利です。
record Debug<T>(List<T> sample, long count) {}
Debug<String> dbg = input.stream()
.filter(this::cheapCond)
.collect(java.util.stream.Collectors.teeing(
java.util.stream.Collectors.limit(20).collect(java.util.stream.Collectors.toList()), // 先頭20件
java.util.stream.Collectors.counting(), // 総件数
Debug::new
));
System.out.println("count=" + dbg.count() + ", sample=" + dbg.sample());
Java例題で身につけるトラブルシュート
例題 1:クレンジングパイプラインの各段階を検証する
文字列ログを「空行除去 → トリム → エラー行抽出」する処理で、どこで値が消えているかを確認します。
List<String> logs = List.of(" INFO start ", "", "ERROR failed", " WARN retry ");
List<String> errors = logs.stream()
.peek(s -> System.out.println("raw: '" + s + "'"))
.filter(s -> !s.isBlank())
.peek(s -> System.out.println("after nonBlank: '" + s + "'"))
.map(String::trim)
.peek(s -> System.out.println("after trim: '" + s + "'"))
.filter(s -> s.startsWith("ERROR"))
.peek(s -> System.out.println("after error filter: '" + s + "'"))
.toList();
System.out.println("errors=" + errors);
Javaこの出力を見れば「どの段階でドロップしたか」「トリム漏れが無いか」が一目で分かります。
例題 2:例外発生箇所の特定と惰性評価の落とし穴
map 内で例外が出るケース。終端が無いと例外が「起きないように見える」点を体感します。
Stream<String> s = Stream.of("10", "x", "30")
.map(v -> {
System.out.println("map before: " + v);
int n = Integer.parseInt(v); // "x" で例外
System.out.println("map after: " + n);
return n;
});
// s.count(); // ← 終端を呼ばないと map が走らず、例外も出ない
try {
long c = s.count(); // ここで初めて走り、"x" で例外
} catch (NumberFormatException e) {
System.err.println("failed on: " + e.getMessage());
}
Javaストリームは遅延評価です。中間で何が起きるかを知るには「終端を必ず呼ぶ」ことが前提になります。
デバッグ補助ユーティリティ
tap ヘルパーで peek をラップする
毎回 peek にラムダを書くのが煩雑なら、ラベル付きの tap を用意すると段階名が揃って読みやすくなります。
public final class Debug {
public static <T> java.util.function.UnaryOperator<Stream<T>> tap(String label) {
return s -> s.peek(x -> System.out.println(label + ": " + x));
}
}
// 使い方
List<String> out = Debug.tap("raw")
.andThen(s -> s.filter(v -> !v.isBlank()))
.andThen(Debug.tap("nonBlank"))
.andThen(s -> s.map(String::trim))
.andThen(Debug.tap("trim"))
.apply(Stream.of(" a ", " ", "b"))
.toList();
JavaSupplier で同じ上流を何度も検証する
同じパイプラインに対して「ログあり」「ログなし」など複数パターンを繰り返すときは、Supplier でストリーム再生成にすると安全です。
Supplier<Stream<String>> base = () -> data.stream()
.filter(this::cheapCond)
.map(this::normalize);
base.get().peek(v -> System.out.println("debug: " + v)).count(); // ログあり
List<String> done = base.get().toList(); // ログなし実行
Java深掘り:正しさと性能を両立するコツ
副作用は「デバッグ専用」に限定する
peek は「本番ロジックを変えない」ために存在します。本番で意味のある副作用(外部リストへの add など)を入れると、並列や短絡で破綻します。ログ以外は入れないのが鉄則です。
大量データでは「サンプリング」と「統計」を優先
全件ログは現実的ではありません。先頭 N 件のサンプル、件数、ヒストグラム(範囲別件数)などの統計を出す設計に切り替えると、原因にたどり着きやすくなります。
long total = big.stream().count();
List<String> sample = big.stream().limit(100).toList();
System.out.println("total=" + total + ", sample=" + sample);
Java並べ替えや重い中間の前に絞り込みと短絡を置く
sorted、distinct、groupingBy の前に filter と limit を置けるかを常に検討します。中間のピークを下げるだけで、ログを入れても耐えられる処理になります。
I/O 終端に寄せて材質化を避ける
デバッグでも collect せずに「逐次出力(forEach)」でファイルやネットに流す構成にすると、メモリピークを一定に保てます。のぞき見は少数件に限定し、あとは終端で外へ吐くのが安全です。
テンプレート(すぐ使える雛形)
段階ごとの peek ログ
stream
.peek(v -> log.debug("raw: {}", v))
.filter(this::cond)
.peek(v -> log.debug("after cond: {}", v))
.map(this::transform)
.peek(v -> log.debug("after map: {}", v))
.forEach(this::sink);
Javaサンプルと件数の同時取得
record Debug<T>(List<T> sample, long count) {}
Debug<T> dbg = stream
.collect(java.util.stream.Collectors.teeing(
java.util.stream.Collectors.limit(50).collect(java.util.stream.Collectors.toList()),
java.util.stream.Collectors.counting(),
Debug::new
));
JavaSupplier で再生成しながら検証
Supplier<Stream<T>> src = () -> source.stream().filter(this::cond).map(this::normalize);
src.get().peek(x -> System.out.println("debug: " + x)).count();
List<T> finalOut = src.get().toList();
Java並列時の順序保証(必要箇所のみ)
stream.parallel()
.map(this::expensive)
.peek(v -> log.debug("[{}] {}", Thread.currentThread().getName(), v))
.forEachOrdered(this::sink);
Javaまとめ
ストリームのデバッグは「peek で段階ごとにのぞく」「小さく材質化して落ち着いて確認する」「同じ上流を Supplier で何度でも再生成する」の三本柱で安定します。遅延評価・短絡・並列の影響を常に念頭に置き、ログはデバッグ専用に留めること。本番の正しさと性能を崩さずに、必要な情報だけを取り出せる設計にするのが、プロのトラブルシュートです。
