Java StreamのgroupingByで複合キー集計する方法|record・順序制御・落とし穴まで解説

Java Stream の groupingBy で複合キー集計したいなら、classifier で専用キーを作り、downstream で件数や合計の集計方法を選ぶのが最短です。"営業部|2026-04" のような文字列連結で逃げるより、record か不変の専用クラスをキーにしたほうが、後から壊れにくく読みやすいコードになります。

この記事では、groupingBy のどこに何を書くか、record を使った最短の書き方、旧環境での代替、順序制御、null・並列処理・ソートでハマりやすい点、集計結果を一覧向けに戻す方法まで整理します。groupingBy には classifier / downstream / mapFactory の3つの考えどころがあり、返る Map の型やスレッド安全性はデフォルトでは保証されません。 (Oracle Docs)

目次

Java StreamのgroupingByで複合キー集計するときの考え方

まずは、「何をどこに書くのか」を切り分けると迷いません。

決めたいこと書く場所例
何でグループ化するかclassifiero -> new OrderKey(o.region(), o.status())
何を集計するかdownstreamCollectors.counting() / Collectors.summingInt(Order::amount)
Mapの順序や型mapFactoryLinkedHashMap::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 + monthkey形式変更の影響が広がる
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)

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次