java.lang.IllegalStateException: Duplicate Key を Collectors.toMap() で修正する方法

intermediate Java2026-04-28| Java 8以降、任意のOS(Linux/macOS/Windows)、Spring Boot、Jakarta EE、またはスタンドアロンJVMアプリケーション

Error Message

java.lang.IllegalStateException: Duplicate key (attempt to merge values key1 and key2)
#java#streams#map#illegalstateexception

インシデント発生

午前2時、バッチジョブが以下のエラーで落ちました:

java.lang.IllegalStateException: Duplicate key userId=1042
  at java.util.stream.Collectors.duplicateKeyException(Collectors.java:133)
  at java.util.stream.Collectors.lambda$toMap$1(Collectors.java:174)
  at java.util.HashMap.merge(HashMap.java:1262)
  at java.util.stream.Collectors.lambda$toMap$2(Collectors.java:174)
  at java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:195)
  at com.example.UserService.buildUserMap(UserService.java:58)

クラッシュしたコードは一見何の問題もありません:

Map<Long, User> userMap = users.stream()
    .collect(Collectors.toMap(User::getId, u -> u));

開発環境では正常動作。ステージングでも正常動作。本番環境だけが午前2時に落ちます。テストデータセットにはない重複データが本番にあるからです。

なぜこうなるのか

2引数の Collectors.toMap() はキーの重複を一切許容しません。2つの要素が同じキーにマップされた瞬間、IllegalStateException をスローして処理を停止します。「後勝ち」もなければ、サイレントな上書きもありません。ただクラッシュするだけです。

本番環境でのエラーの全文はこうなります:

java.lang.IllegalStateException: Duplicate key (attempt to merge values key1 and key2)

よくある原因は3つです:

  • ユニークだと思っていたフィールドに対してデータベースが複数行を返した — 制約の陳腐化、論理削除、またはデータ移行の失敗が原因
  • 複数のソースからリストを組み立てた際に重複排除をしなかった
  • キー関数が想定ほどユニークではなかった(null許容フィールドなど、すべてのnullが1つのキーに集約される)

Step 1 — 重複箇所を特定する

コードを修正する前に、実際に何が重複しているかを確認します。以下の診断コードを追加してください:

// 2回以上登場するキーを探す
Map<Long, Long> idCount = users.stream()
    .collect(Collectors.groupingBy(User::getId, Collectors.counting()));

idCount.entrySet().stream()
    .filter(e -> e.getValue() > 1)
    .forEach(e -> System.err.println("Duplicate id: " + e.getKey() + " count: " + e.getValue()));

これにより、どのキーが重複しているか、何件あるかがわかります。本番データのスナップショットに対して一度実行してみてください。3件見つかる場合もあれば、3,000件見つかる場合もあります。その規模によって、適切な修正方法が変わってきます。

修正方法1 — マージ関数を使う(最もよくある修正)

toMap() に第3引数としてマージ関数を渡し、2つの要素がキーを共有した場合の処理を定義します。

後の値を使う(最もよくある意図)

Map<Long, User> userMap = users.stream()
    .collect(Collectors.toMap(
        User::getId,
        u -> u,
        (existing, replacement) -> replacement  // 後勝ち
    ));

最初の値を使う

Map<Long, User> userMap = users.stream()
    .collect(Collectors.toMap(
        User::getId,
        u -> u,
        (existing, replacement) -> existing  // 先勝ち
    ));

重複が意図的な場合に値をマージする

// 例:同じuserIdのスコアを合計する
Map<Long, Integer> scoreMap = records.stream()
    .collect(Collectors.toMap(
        Record::getUserId,
        Record::getScore,
        Integer::sum
    ));

ビジネス要件に合ったストラテジーを選んでください。ルックアップテーブルを構築していて重複がデータ品質の問題である場合は「後勝ち」で構いません。同じ注文に対する2つのトランザクションなど、重複に意味のあるデータが含まれている場合は、適切なマージ処理が必要です。

修正方法2 — 値をリストとして収集する

キーごとにすべての値が必要な場合、toMap() を使うのは正しくありません。groupingBy() を使いましょう:

// Map<userId, List<User>>
Map<Long, List<User>> grouped = users.stream()
    .collect(Collectors.groupingBy(User::getId));

// Map<userId, count>
Map<Long, Long> counts = users.stream()
    .collect(Collectors.groupingBy(User::getId, Collectors.counting()));

典型的なユースケースは、注文IDとその明細行のマッピングです。重複はバグではなく、データモデルそのものです。groupingBy() はまさにこのために設計されています。

修正方法3 — ログ付きで重複を排除する

重複が不正なデータであり、処理を継続しつつ記録も残したい場合:

Map<Long, User> userMap = users.stream()
    .collect(Collectors.collectingAndThen(
        Collectors.toMap(
            User::getId,
            u -> u,
            (a, b) -> {
                log.warn("Duplicate user id {}, dropping: {}", a.getId(), b);
                return a;
            }
        ),
        Collections::unmodifiableMap
    ));

サービスは処理を継続します。警告ログには、データ品質の問題を担当する人が対処できる具体的な情報(ID、件数、タイムスタンプ)が残ります。

修正方法4 — Mapの実装クラスを指定する

第4引数で出力されるMapの型を制御できます。順序が重要な場合に便利です:

Map<Long, User> userMap = users.stream()
    .collect(Collectors.toMap(
        User::getId,
        u -> u,
        (existing, replacement) -> replacement,
        LinkedHashMap::new  // 挿入順序を保持
    ));

挿入順序を保持するには LinkedHashMap::new、キーのソートには TreeMap::new を使います。この引数を省略すると、順序保証のない通常の HashMap が返されます。

修正の検証

マージ関数を適用したら、意図的に重複データを渡すテストを書きましょう:

@Test
void toMap_withDuplicates_shouldKeepLast() {
    List<User> users = List.of(
        new User(1L, "Alice"),
        new User(1L, "Alice-updated"),  // IDが重複
        new User(2L, "Bob")
    );

    Map<Long, User> result = users.stream()
        .collect(Collectors.toMap(
            User::getId,
            u -> u,
            (existing, replacement) -> replacement
        ));

    assertEquals(2, result.size());
    assertEquals("Alice-updated", result.get(1L).getName());
    assertEquals("Bob", result.get(2L).getName());
}

また、重複の発生源を上流まで遡って調査してください。データがデータベースクエリから来ている場合、SQLが意図せずクロスジョインを生成していないか、リポジトリ層が重複排除されていない行を返していないかを確認しましょう。

クイックリファレンス

  • 2引数の toMap() — キーが重複した時点で例外をスロー、例外なし
  • 3引数の toMap() — マージ関数で重複を処理。本番環境ではこちらを使うこと
  • groupingBy() — 1つのキーに複数の値が対応するのが正しいデータモデルの場合
  • ログ付き重複排除 — 重複が不正データであり、証拠を残したい場合

本当の教訓

2引数の Collectors.toMap() は本番コードにおける地雷です。開発環境とステージング環境には、整備された手作りのテストデータがあります。本番環境には5年分のインポート処理、再実行、失敗したデータ移行、そして誰も覚えていないレコードが存在します。

チームの規約として徹底してください:完全に把握できていないデータを扱う toMap() には必ずマージ関数を付ける。3引数バージョンは速度が遅いわけでも、読みにくいわけでもありません。ただ、現実があなたの前提と一致しない場合にコードが何をするかを明示的に示すことを強制するだけです。そして本番環境では、現実は必ず前提を裏切るものです。

Related Error Notes