JavaのCollectors.toMapで重複キーが発生した時のマージ方法を解説する記事のアイキャッチ。

Java

JavaのCollectors.toMapでDuplicate keyになる原因|重複時の対処法

AIによる要約

Collectors.toMapは、異なる要素から同じキーが作られると、標準ではIllegalStateExceptionを投げます。第3引数へマージ関数を渡せば回避できますが、機械的に先勝ち・後勝ちへするとデータ欠落を隠す危険があります。キーが本当に一意か、重複時にどちらを採用するか、複数件を残すべきかを業務仕様から決めます。1キーに複数値が正しいならgroupingByを使います。

新人SE
新人SE
StreamをMapへ変えただけなのに、Duplicate keyで本番データだけ落ちました。第3引数を足せば終わりですか?
ポンコツSE
ポンコツSE
例外は直せますが、どちらのデータを残すかを決めないままマージすると、今度は静かにデータを捨てます。先に業務上の一意性を確認します。

`Collectors.toMap`は一覧をMapへ変換できる便利なCollectorです。しかし、開発用データが一意だっただけで「キーは重複しない」と思い込み、本番や結合テストで`IllegalStateException: Duplicate key`に遭遇するケースがあります。

重要なのは例外を消すことではありません。重複は異常なのか、先の値を残すのか、後の値で更新するのか、複数件を保持するのかを決めることです。

この記事のポイント

  • toMapは同じキーが来ると標準では例外になる
  • 第3引数のマージ関数で重複時の処理を指定できる
  • 先勝ち・後勝ちは業務仕様を確認して選ぶ
  • 重複が異常なら例外のまま明示的に止める
  • 1キー複数値ならgroupingByを使う

XのJavaクイズでDuplicate keyを確認する

A、B、AのListをCollectors.toMapでMapへ変換できるかを問うJavaクイズ画像。
同じキーAが2回来る場合、このMapは作れるでしょうか。
List<String> values = List.of("A", "B", "A");

Map<String, Integer> result = values.stream()
        .collect(Collectors.toMap(
                value -> value,
                String::length
        ));

答えは、重複キーAで`IllegalStateException`です。標準のtoMapには、同じキーをどのようにまとめるかが指定されていないためです。

Collectors.toMapで重複キーAによりIllegalStateExceptionとなり、第3引数でマージ関数を指定できると説明する回答画像。
第3引数でマージできますが、残す値は業務仕様から決めます。

Xの出題ポスト回答ポストも参照できます。

toMapの3つの関数を理解する

Map<Long, String> namesById = users.stream()
        .collect(Collectors.toMap(
                User::getId,
                User::getName
        ));

第1引数はキーを作る関数、第2引数は値を作る関数です。異なるUserから同じIDが作られた場合、Mapには同じキーを2つ入れられません。そこで重複時の処理を第3引数へ追加します。

Map<Long, String> namesById = users.stream()
        .collect(Collectors.toMap(
                User::getId,
                User::getName,
                (existing, incoming) -> existing
        ));

`existing`はMapに先に入っていた値、`incoming`は後から来た値です。この例は先勝ちですが、変数名を`left`、`right`だけにすると意図が分かりにくいため、役割が伝わる名前にします。

重複時の4つの判断

先に来た値を残す

(existing, incoming) -> existing

入力順に優先度があり、先頭が正式値と決まっている場合に使います。ただしStreamの順序が保証されているか確認してください。Setや並列処理、DB取得順が未指定のデータへ「先」を持ち込むと結果が不安定になります。

後に来た値で上書きする

(existing, incoming) -> incoming

更新日時で並べた後に最新値を残すなど、後勝ちの根拠がある場合に使います。単に例外を消すために後勝ちにすると、古いデータと新しいデータのどちらが残ったか説明できません。

値を合計・結合する

Map<String, Integer> quantityByProduct = lines.stream()
        .collect(Collectors.toMap(
                OrderLine::productCode,
                OrderLine::quantity,
                Integer::sum
        ));

同じ商品コードの数量を合計するなら、重複は異常ではなく集約条件です。金額の場合は丸めや税計算のタイミング、文字列結合なら区切りや順序も仕様に含めます。

重複を異常として止める

Map<String, User> usersByEmployeeNo = users.stream()
        .collect(Collectors.toMap(
                User::employeeNo,
                Function.identity(),
                (existing, incoming) -> {
                    throw new IllegalStateException(
                            "社員番号が重複しています: "
                                    + existing.employeeNo());
                }
        ));

社員番号や外部連携IDなど本来一意であるべき項目なら、曖昧にマージせず止める方が安全です。標準例外のメッセージだけで調査しづらい場合は、キーや入力元を含む業務向け例外へ変換します。ただし個人情報をメッセージへ出しすぎないでください。

複数件を残すならgroupingByを使う

Map<String, List<Order>> ordersByCustomer =
        orders.stream()
                .collect(Collectors.groupingBy(
                        Order::customerId));

顧客1人に注文が複数あるのが正しいなら、`Map<String, Order>`へ無理に1件だけ入れる設計が間違っています。`Map<String, List<Order>>`で全件を残します。

件数だけ必要なら下流Collectorを使えます。

Map<String, Long> countByStatus = orders.stream()
        .collect(Collectors.groupingBy(
                Order::status,
                Collectors.counting()
        ));

Mapの順序が必要なら第4引数も指定する

Map<String, String> labels = items.stream()
        .collect(Collectors.toMap(
                Item::code,
                Item::label,
                (existing, incoming) -> existing,
                LinkedHashMap::new
        ));

帳票やCSVで入力順を維持する必要がある場合は、`LinkedHashMap`を指定します。通常のtoMapが返すMapの具体実装や順序へ依存したコードを書かないようにします。並び順が要件なら、テストでも順序を確認してください。

現場で起きる原因はキー設計にもある

  • 店舗コードだけをキーにしたが、実際は会社コードとの複合キーだった
  • 削除済みデータも検索対象に含まれていた
  • 大文字・小文字や前後空白を正規化すると同じキーになった
  • 履歴テーブルから最新1件へ絞らず全世代を取得した
  • テストデータには重複がなく、本番移行データにだけ存在した

toMapの修正だけでなく、Repositoryの検索条件、DBの一意制約、複合キー、削除フラグも確認します。「Java側でマージできたから問題なし」とすると、データ品質の異常を見逃します。

重複データを含むテストを書く

@Test
void 同じ商品コードの数量を合計する() {
    List<OrderLine> lines = List.of(
            new OrderLine("A", 2),
            new OrderLine("B", 1),
            new OrderLine("A", 3));

    Map<String, Integer> actual = lines.stream()
            .collect(Collectors.toMap(
                    OrderLine::productCode,
                    OrderLine::quantity,
                    Integer::sum));

    assertEquals(5, actual.get("A"));
    assertEquals(1, actual.get("B"));
}

@Test
void 社員番号の重複は異常として止める() {
    List<User> users = List.of(
            new User("E001", "A"),
            new User("E001", "B"));

    assertThrows(
            IllegalStateException.class,
            () -> toUserMap(users));
}

一意なデータだけのテストでは、マージ関数の意図を検証できません。重複する2件の値を変え、どちらが残るか、合計されるか、例外になるかを明示します。

現場レビューでよくある指摘

// レビューコメント例
重複時に先勝ちとしていますが、先を優先する業務根拠が分かりません。
入力順の保証と採用ルールを確認してください。

// レビューコメント例
顧客ごとに注文は複数存在するため、toMapで1件を捨てず
groupingByで全件を保持してください。

// レビューコメント例
本来一意な社員番号の重複をマージするとデータ不整合を隠します。
キーを含む例外で止め、データ側も調査したいです。

提出前のセルフチェック

  • キーが業務上本当に一意か確認したか
  • 複合キーが必要ではないか
  • 先勝ち・後勝ちの根拠を説明できるか
  • 順序が保証されているか
  • 重複が異常なら例外で止めているか
  • 複数件が正しいならgroupingByを使ったか
  • 重複データを含むテストを書いたか
  • DB検索条件や一意制約も確認したか

Streamとコレクションを実践的に学ぶ参考書

StreamのCollectorは短く書ける分、データの前提をコードへ明示する必要があります。実践編でコレクション操作を整理しておくと判断しやすくなります。

書籍「スッキリわかるJava入門 実践編 第5版」の表紙
商品画像:Amazon.co.jp
PR Java基礎学習後の若手向け

スッキリわかるJava入門 実践編 第5版

中山 清喬 (著), 株式会社フレアリンク (監修)

基礎文法の次に必要な、現場寄りのJava知識を補う。

コレクション、ジェネリクス、ラムダ式、ストリームなど、業務コードで出会いやすい機能を入門編の次に学べます。

  • Java基礎の次に何を学ぶか迷っている
  • コレクションやStreamを整理したい

当サイトはAmazonアソシエイト・プログラムの参加者です。価格・在庫・配送条件はAmazonでご確認ください。

この記事とあわせて読みたい

まとめ

Collectors.toMapは同じキーが来ると標準ではIllegalStateExceptionになります。第3引数でマージできますが、先勝ちや後勝ちを機械的に選ぶとデータ欠落を隠します。

キーの一意性、重複時の採用ルール、順序、DB側の制約を確認してください。1キーに複数値が正しいならgroupingByを使い、重複データを含むテストで仕様を固定しましょう。

-Java
-, , ,