Java Stream の groupingBy で複合キー集計したいなら、classifier で専用キーを作り、downstream で件数や合計の集計方法を選ぶのが最短です。"営業部|2026-04" のような文字列連結で逃げるより、record か不変の専用クラスをキーにしたほうが、後から壊れにくく読みやすいコードになります。
この記事では、groupingBy のどこに何を書くか、record を使った最短の書き方、旧環境での代替、順序制御、null・並列処理・ソートでハマりやすい点、集計結果を一覧向けに戻す方法まで整理します。groupingBy には classifier / downstream / mapFactory の3つの考えどころがあり、返る Map の型やスレッド安全性はデフォルトでは保証されません。 (Oracle Docs)
Java StreamのgroupingByで複合キー集計するときの考え方
まずは、「何をどこに書くのか」を切り分けると迷いません。
| 決めたいこと | 書く場所 | 例 |
|---|---|---|
| 何でグループ化するか | classifier | o -> new OrderKey(o.region(), o.status()) |
| 何を集計するか | downstream | Collectors.counting() / Collectors.summingInt(Order::amount) |
| Mapの順序や型 | mapFactory | LinkedHashMap::new / () -> new TreeMap<>(...) |
複合キー集計で詰まりやすいのは、キー設計と集計方法を同時に考えてしまうことです。先に「何で分けるか」、次に「何を集計するか」、最後に「結果をどのMapに入れるか」の順で決めると、コードがかなり素直になります。groupingBy 自体は Java 8 から使えます。 (Oracle Docs)
まずはrecordをキーにするのが最短
record は、コンストラクタ・アクセサ・equals・hashCode・toString を自動生成し、フィールドも final です。複合キーのような「ただの値のまとまり」を表すにはかなり相性がいい選択です。 (Oracle Docs)
record Order(
String region,
String status,
String salesperson,
int amount
) {}
record OrderKey(String region, String status) {}
// まずは「キーごとの注文一覧」
Map<OrderKey, List<Order>> grouped = orders.stream()
.collect(Collectors.groupingBy(
o -> new OrderKey(o.region(), o.status())
));
// 件数だけ欲しい
Map<OrderKey, Long> countByKey = orders.stream()
.collect(Collectors.groupingBy(
o -> new OrderKey(o.region(), o.status()),
Collectors.counting()
));
// 金額合計が欲しい
Map<OrderKey, Integer> amountByKey = orders.stream()
.collect(Collectors.groupingBy(
o -> new OrderKey(o.region(), o.status()),
Collectors.summingInt(Order::amount)
));
// 注文自体ではなく担当者だけ集めたい
Map<OrderKey, Set<String>> salespeopleByKey = orders.stream()
.collect(Collectors.groupingBy(
o -> new OrderKey(o.region(), o.status()),
Collectors.mapping(Order::salesperson, Collectors.toSet())
));
groupingBy(classifier) だけなら Map<Key, List<T>>、件数だけなら Collectors.counting()、数値合計なら Collectors.summingInt(...) を downstream に渡せば、集計対象を1回走査する形でまとめられます。mapping() は groupingBy の下流で値だけを抜き出したいときに向いています。 (Oracle Docs)
Java 9 以降なら、filtering() や flatMapping() も同じ考え方で下流に置けます。たとえば「優先注文だけ数える」「明細の品目名をフラットに集める」といった処理も、groupingBy の中で完結できます。集計後に不変コレクションへ変換したいなら collectingAndThen() も使えます。 (GitHub)
recordが使えない環境は専用キーclassで書く
record が使えない環境でも、考え方は同じです。不変の専用キー型を作り、equals と hashCode を正しく実装するだけです。
final class OrderKey {
private final String region;
private final String status;
OrderKey(String region, String status) {
this.region = region;
this.status = status;
}
public String getRegion() {
return region;
}
public String getStatus() {
return status;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof OrderKey)) return false;
OrderKey other = (OrderKey) o;
return Objects.equals(region, other.region)
&& Objects.equals(status, other.status);
}
@Override
public int hashCode() {
return Objects.hash(region, status);
}
}
HashMap 系でキーとして使う以上、equals と hashCode の整合性は必須です。Object の契約でも、equals に使う情報が変わらない限り hashCode は一貫している必要があります。複数フィールドの hashCode 実装には Objects.hash(...) が使えます。 (Oracle Docs)
文字列連結キーは短期では便利でも長期で困る
たとえば、次のような書き方です。
Map<String, Long> countByKey = orders.stream()
.collect(Collectors.groupingBy(
o -> o.region() + "|" + o.status(),
Collectors.counting()
));
小さなサンプルでは動きますが、実務では次の問題が出やすくなります。
| 問題 | 具体例 | 実務で困ること | |
|---|---|---|---|
| 区切り文字の衝突 | A | B を含む値 | 復元時に分解できない | |
| 型情報が消える | 日付・数値も文字列化 | 比較・ソートが不自然になる | |
| 項目追加に弱い | region + status + month | key形式変更の影響が広がる | |
| nullが曖昧 | "null | DONE" | 未設定なのか文字列なのか判別しにくい |
「今すぐ集計できる」ことと、「半年後に安全に直せる」ことは別です。複合キーは、最初から型で表したほうが結果的に速いです。
順序やMapの型を制御したいときは mapFactory を使う
groupingBy(classifier, downstream) の戻り Map は、仕様上は型や可変性などが保証されません。現行の OpenJDK 実装ではこのオーバーロードが HashMap::new に委譲されていますが、HashMap 前提で順序や実装型に依存するのは避けるべきです。順序が必要なら、groupingBy(classifier, mapFactory, downstream) を明示的に使います。 (Oracle Docs)
入力で最初に現れた順に見たい
Map<OrderKey, Long> countByKey = orders.stream()
.collect(Collectors.groupingBy(
o -> new OrderKey(o.region(), o.status()),
LinkedHashMap::new,
Collectors.counting()
));
LinkedHashMap は挿入順で反復できる Map です。同じキーが再度出てきても、その再挿入で順序は変わりません。つまりこの書き方なら、「最初に見つかったグループの順」で結果を扱いやすくなります。 (Oracle Docs)
キー順で並べたい
Map<OrderKey, Long> countByKey = orders.stream()
.collect(Collectors.groupingBy(
o -> new OrderKey(o.region(), o.status()),
() -> new TreeMap<>(
Comparator.comparing(OrderKey::region)
.thenComparing(OrderKey::status)
),
Collectors.counting()
));
TreeMap は自然順序、または作成時に渡した Comparator でキーを並べます。複合キーを record にしただけでは Comparable にはならないので、TreeMap::new をそのまま渡すと失敗しやすいです。複合キーをソートしたいなら、Comparator を持つ supplier を渡すのが安全です。キー項目に null があり得るなら Comparator.nullsFirst(...) も検討してください。 (Oracle Docs)
失敗しやすいポイント
classifierがnullを返すと落ちる
groupingBy の実装では、classifier が返したキーそのものが null だと NullPointerException になります。単一フィールドをそのままキーにしていて null が混じるケースは要注意です。 (GitHub)
たとえば、status が null になり得るなら、次のように先に埋めるか除外します。
Map<OrderKey, Long> countByKey = orders.stream()
.collect(Collectors.groupingBy(
o -> new OrderKey(
o.region() == null ? "UNKNOWN" : o.region(),
o.status() == null ? "UNSET" : o.status()
),
Collectors.counting()
));
なお、new OrderKey(region, status) のようにキーオブジェクト自体を返しているなら、その内部項目に null があってもキーそのものは non-null です。ただし、null を本当に同じグループとして集計してよいかは業務ルールで決めておくべきです。
可変キーを使わない
キーに使った値を後から変える設計は避けたほうが安全です。equals や hashCode の判定に使う値が変わると、Map から取り出せなくなる原因になります。複合キーには record か、final フィールドだけを持つ不変クラスが向いています。 (Oracle Docs)
parallelStreamならgroupingByConcurrentとは限らない
groupingBy は並行 collector ではなく、並列ストリームでは Map 同士のマージがコストになることがあります。順序が不要なら groupingByConcurrent が並列性能で有利になる可能性がありますが、こちらは unordered かつ concurrent です。順序前提のロジックやテストがあるなら、安易に置き換えない方が安全です。 (Oracle Docs)
複合キーMapを扱いやすい形に戻す方法
複合キーの Map<OrderKey, Long> は集計には便利ですが、そのまま画面表示や CSV 出力に流すと扱いづらいことがあります。そんなときは、行データへ戻すのが簡単です。
record OrderSummaryRow(String region, String status, long count) {}
List<OrderSummaryRow> rows = countByKey.entrySet().stream()
.map(e -> new OrderSummaryRow(
e.getKey().region(),
e.getKey().status(),
e.getValue()
))
.collect(Collectors.toList());
この形にしておくと、テーブル表示、JSON 変換、CSV 出力、API レスポンスにそのまま載せやすくなります。複合キー集計の「結果を戻す」ときは、まず entrySet() から行DTOへ変換すると考えると実装が整理しやすいです。
最初から階層で使いたいなら nested groupingBy
地域ごとに状態集計を表示したいなど、読み方が最初から階層構造なら、複合キーより nested groupingBy のほうが自然なこともあります。
Map<String, Map<String, Long>> byRegionThenStatus = orders.stream()
.collect(Collectors.groupingBy(
Order::region,
Collectors.groupingBy(
Order::status,
Collectors.counting()
)
));
判断基準は単純です。最終形がフラットな一覧なら複合キー、親子構造なら nested groupingBy が読みやすいことが多いです。
groupingBy以外の代替策
1レコードが1つの値を足し込むだけならtoMap + mergeでもよい
「グループ化して一覧を持ちたい」のではなく、「キーごとに数値を合算したい」だけなら、toMap の merge function 付きオーバーロードでも書けます。
Map<OrderKey, Integer> amountByKey = orders.stream()
.collect(Collectors.toMap(
o -> new OrderKey(o.region(), o.status()),
Order::amount,
Integer::sum
));
toMap には、同じキーが出たときの衝突を解決する merge function 付きオーバーロードがあります。キーごとに最終値をどうマージするかが明確なら、groupingBy より短く書ける場面があります。 (Oracle Docs)
分岐が複雑ならfor文のほうが読みやすい
次のようなケースでは、Stream にこだわらず for 文で集計したほうが保守しやすいことがあります。
- 例外処理やログ出力を細かく入れたい
- 複数の Map を同時に更新したい
BigDecimalや業務ルール分岐が多い- デバッガで1件ずつ追いかけたい
「Stream で書ける」ことと、「チームで読みやすい」ことは別です。特に集計条件が増えてきたら、無理に1本の Stream に押し込まない方が結果的に安全です。
迷ったらこの方針で進めれば失敗しにくい
Java Stream の groupingBy で複合キー集計をするなら、まず record Key(...) を作って classifier に置き、downstream には counting() か summingInt() を入れるところから始めるのが無難です。順序が必要になったら LinkedHashMap、並び順が必要になったら comparator 付き TreeMap を追加します。画面やCSVに出す段階で entrySet() から行データへ戻せば、内部の集計構造と外向けの出力構造をきれいに分けられます。record が使えない環境でも、同じ考え方を不変の専用クラスに置き換えるだけで対応できます。 (Oracle Docs)

コメント