目的と前提
大量データを Stream で処理するとき、ボトルネックは「CPUよりメモリ」になりがちです。推定でも十分なので、どれだけメモリを消費するかを見積もり、材質化(toList/toMap)を最小化し、ボクシングや巨大中間結果を避ける設計に切り替えるのが定石です。ここでは、初心者が迷いなく進められるように、簡易推定のやり方、よくある増量要因、そして確実に効く対策を、コードと具体例で深掘りして説明します。
メモリ使用量の簡易推定
ランタイム計測で前後差を測る
厳密なバイト数は難しくても、「処理前後のヒープ差」を測れば十分な目安になります。以下は材質化(collect)でどれだけ増えたかを簡易計測する雛形です。
public class MemMeasure {
static long used() {
System.gc();
try { Thread.sleep(100); } catch (InterruptedException ignored) {}
Runtime rt = Runtime.getRuntime();
return rt.totalMemory() - rt.freeMemory();
}
public static void main(String[] args) {
long before = used();
// 大量データを材質化
var list = java.util.stream.IntStream.range(0, 2_000_000)
.mapToObj(i -> "item-" + i)
.toList();
long after = used();
System.out.printf("elements=%d, delta=%.2f MB%n",
list.size(), (after - before) / (1024.0 * 1024.0));
}
}
JavaGC 後の使用量差は「中間オブジェクト+結果オブジェクトの合計」に近い値を示します。GC を明示しても完全ではありませんが、比較には十分です。
要素あたりの概算で先に見積もる
要素の型から「ざっくりの見積り」を先に立てます。たとえば ArrayList に 1,000 万件の Integer を貯めるなら、参照配列のオーバヘッド(約 8 バイト/要素)+ Integer オブジェクトのオーバヘッド(20〜24 バイト程度を仮置き)で、1 件あたり 30 バイト超、合計 300 MB 超の可能性があります。String は文字数×2 バイト+オブジェクトヘッダ+配列参照分が乗るため、短く見積もっても 30〜50 バイト/件はすぐ越えます。こうした「1件×件数」の掛け算が、設計段階の早期警戒に役立ちます。
メモリ増量の主要因と見抜き方
オートボクシングによる隠れコスト
mapToInt/mapToLong/mapToDouble を使わずに boxed すると、各要素が Integer/Long/Double のオブジェクトになります。これだけで参照配列+個体オブジェクト分のメモリを消費し、1,000 万件規模なら一気に数百 MB に到達します。プリミティブストリームへ寄せるだけで消費を大幅に削減できます。
材質化による巨大中間結果
toList、toSet、toMap、groupingBy、joining は「全部メモリに載せる」動きです。とくに toMap/groupingBy はキーと値のオブジェクト、エントリのオーバヘッドを抱えるため、件数×複数倍の消費になりやすいです。連結文字列(joining)も全件をためてから作るため、巨大文字列や StringBuilder のサイズ増が致命傷になりがちです。
余計なコピーと一時オブジェクト
sorted、distinct、reverse、巨大な flatMap は、中間段階で追加配列やセットを抱えます。必要件数だけ欲しいなら limit を前に、並べ替えが不要なら skip/sorted を外すなど、パイプラインの順序でメモリのピークを下げられます。
対策パターンとコード例
プリミティブストリームでボクシングを避ける
// NG: boxed で大量の Integer が生まれる
var list = java.util.stream.IntStream.range(0, 10_000_000)
.boxed()
.toList();
// OK: プリミティブのまま集計する
long sum = java.util.stream.IntStream.range(0, 10_000_000).sum();
double avg = java.util.stream.IntStream.range(0, 10_000_000)
.average().orElse(Double.NaN);
Javaプリミティブ集約は「要素をオブジェクト化しない」ため、消費メモリを最小化できます。
ストリームを使いつつ材質化を避ける(逐次出力)
try (var writer = java.nio.file.Files.newBufferedWriter(java.nio.file.Paths.get("out.txt"))) {
java.nio.file.Files.lines(java.nio.file.Paths.get("input.txt"))
.filter(line -> !line.isBlank())
.map(String::trim)
.forEach(line -> {
try { writer.write(line); writer.newLine(); }
catch (java.io.IOException e) { throw new java.io.UncheckedIOException(e); }
});
}
Javacollect せずに外へ流す(書き出す・送る)と、ピークメモリはバッファ分のみになります。I/O を終端にするのは最も強力な対策です。
ページング・チャンク処理でピークを抑える
var all = java.util.stream.IntStream.range(0, 10_000_000).mapToObj(i -> "a" + i).toList(); // 仮データ
int size = 100_000;
for (int i = 0; i < all.size(); i += size) {
var chunk = all.subList(i, Math.min(i + size, all.size()));
long errors = chunk.stream().filter(s -> s.startsWith("a9")).count();
System.out.printf("chunk[%d] errors=%d%n", i, errors);
}
Java巨大データを「分けて処理」すれば、ピークメモリをチャンクサイズにほぼ限定できます。外部 API ならページ単位で取り、ページ単位で捌きます。
groupingBy のメモリを抑える設計
// NG: すべてメモリに詰める
var freq = java.nio.file.Files.lines(java.nio.file.Paths.get("in.txt"))
.collect(java.util.stream.Collectors.groupingBy(s -> s, java.util.stream.Collectors.counting()));
// OK: 先にフィルタ・短絡し、キーを圧縮。必要なら一時書き出し後に再集計
long countA = java.nio.file.Files.lines(java.nio.file.Paths.get("in.txt"))
.filter(s -> s.startsWith("A"))
.count();
JavagroupingBy はキー数×オブジェクトの山になります。集計の前に「件数だけ」「条件だけ」に絞ると、結果をメモリに載せず済みます。どうしても集計が必要なら、キー空間を減らす(例:ハッシュ前集約)や、外部ストア・一時ファイルで分散する選択肢もあります。
joining は早めにサイズ制限をかける
// NG: 全件連結は危険
String all = big.stream().collect(java.util.stream.Collectors.joining(","));
// OK: limit と短絡でサイズを制御
String top = big.stream()
.limit(10_000)
.collect(java.util.stream.Collectors.joining(",", "[", "]"));
Java文字列連結は「作ってから返す」ため、件数制御がクリティカルです。limit、フィルタ、takeWhile を上流に置きます。
例題で理解するメモリの差
同じロジックを「材質化」と「逐次出力」で比較
public class CompareMemory {
static long used() {
System.gc(); try { Thread.sleep(100); } catch (InterruptedException ignored) {}
Runtime rt = Runtime.getRuntime();
return rt.totalMemory() - rt.freeMemory();
}
static void materialize() throws Exception {
var list = java.nio.file.Files.lines(java.nio.file.Paths.get("input.txt"))
.filter(l -> l.length() > 10)
.map(String::trim)
.toList();
System.out.println("materialized size=" + list.size());
}
static void streaming() throws Exception {
try (var w = java.nio.file.Files.newBufferedWriter(java.nio.file.Paths.get("out.txt"))) {
java.nio.file.Files.lines(java.nio.file.Paths.get("input.txt"))
.filter(l -> l.length() > 10)
.map(String::trim)
.forEach(l -> {
try { w.write(l); w.newLine(); }
catch (java.io.IOException e) { throw new java.io.UncheckedIOException(e); }
});
}
}
public static void main(String[] args) throws Exception {
long b1 = used(); materialize(); long a1 = used();
long b2 = used(); streaming(); long a2 = used();
System.out.printf("materialize delta=%.2f MB%n", (a1 - b1)/1024.0/1024.0);
System.out.printf("streaming delta=%.2f MB%n", (a2 - b2)/1024.0/1024.0);
}
}
Java材質化は「結果を全部保持」するため差分が大きく、逐次出力はピークが小さいはずです。ファイルサイズやフィルタ条件で差は出ますが、傾向を掴むにはこれで十分です。
ボクシングの有無でヒープ差を確認
public class BoxingMemory {
static long used() {
System.gc(); try { Thread.sleep(100); } catch (InterruptedException ignored) {}
Runtime rt = Runtime.getRuntime();
return rt.totalMemory() - rt.freeMemory();
}
public static void main(String[] args) {
long b1 = used();
var boxed = java.util.stream.IntStream.range(0, 5_000_000).boxed().toList();
long a1 = used();
long b2 = used();
long sum = java.util.stream.IntStream.range(0, 5_000_000).sum();
long a2 = used();
System.out.printf("boxed toList delta=%.2f MB%n", (a1 - b1)/1024.0/1024.0);
System.out.printf("primitive sum delta=%.2f MB, sum=%d%n", (a2 - b2)/1024.0/1024.0, sum);
}
}
Java同じ件数でも、boxed は桁違いに増えることが体感できます。プリミティブで済むところは必ずプリミティブに寄せましょう。
深掘り:設計上の勘どころ
「材質化ゼロ」を目標に組み立てる
大量データの設計では、collect を最後まで使わない意識が重要です。終端は forEach(外部出力)や count/sum のようなプリミティブ集約で閉じる。どうしても結果を集める必要がある場合のみ、件数制御(limit)、段階集約(チャンク)、キー空間削減(マッピング)を前段に置き、ピークメモリを抑えます。
パイプラインの順序でピークを下げる
安い絞り込み(filter)→短絡(limit/takeWhile)→重い処理(map/sorted)→終端、が基本です。これだけで中間データ量が減り、必要なものだけが後段に進むため、ヒープのピークが下がります。
並列化はメモリ節約の道具ではない
並列ストリームはスループットを上げる手段で、メモリ削減には直接効きません。部分結果を複数スレッドで持つため、むしろピークが増えることがあります。大量データのメモリ対策は「逐次・短絡・分割・プリミティブ」が主役です。
文字列連結・グルーピングの扱い
joining は連結結果が巨大になりやすく、groupingBy はキー数に比例して増えます。どちらも「上流での圧縮」が命です。joining は limit を前へ、groupingBy はフィルタでキー空間を減らすか、count に置き換えられる場面では置き換えましょう。
テンプレート
ランタイム差分でメモリ推定
static long usedBytes() {
System.gc(); try { Thread.sleep(100); } catch (InterruptedException ignored) {}
var rt = Runtime.getRuntime();
return rt.totalMemory() - rt.freeMemory();
}
Java大量データの逐次処理(材質化なし)
java.nio.file.Files.lines(java.nio.file.Paths.get("in.txt"))
.filter(l -> !l.isBlank())
.map(String::trim)
.forEach(l -> /* 書き出し/送信/統計 */ {});
Javaプリミティブ集約
long count = java.util.stream.IntStream.range(0, n).count();
long sum = java.util.stream.IntStream.of(arr).sum();
double avg = java.util.stream.DoubleStream.of(vals).average().orElse(Double.NaN);
Javaチャンク処理
for (int i = 0; i < list.size(); i += chunk) {
var sub = list.subList(i, Math.min(i + chunk, list.size()));
// sub.stream() で処理、結果はすぐ外へ
}
Java逐次移動集計(メモリ一定)
var q = new java.util.ArrayDeque<Integer>();
int win = 1000; long sum = 0;
java.nio.file.Files.lines(java.nio.file.Paths.get("nums.txt"))
.mapToInt(Integer::parseInt)
.forEach(x -> {
q.addLast(x); sum += x;
if (q.size() > win) sum -= q.removeFirst();
if (q.size() == win) { /* sum を使って出力 */ }
});
Javaまとめ
大量データの Stream 設計は、正確なバイト数より「ピークを下げる構成」が勝ち筋です。簡易計測で前後差を掴み、プリミティブストリームでボクシングを排除し、collect を最小化、外部へ逐次出力、チャンクで分割。パイプラインの順序で中間データを絞り、joining/groupingBy は上流で圧縮。並列化は速度の話で、メモリは「材質化ゼロ設計」で守る。これを徹底すれば、ヒープに優しく、それでいて速い大量処理が書けます。
