Java Stream toMap の重複キー対応戦略

Collectors.toMap() は、Java 8 の Stream API における代表的なコレクタであり、要素のストリームを Map<K, V> へ変換する際に広く利用されます。ただし、キー生成関数が同一のキー値を返す複数の要素が存在すると、IllegalStateException: Duplicate key 例外がスローされ、処理が中断されます。これは、デフォルトの挙動として「キーの重複を許容しない」ためです。

重複キー発生の主な原因

  • キー抽出関数(例:Person::getDepartmentId)が、異なるオブジェクトに対し同一の値を返す
  • 元データに事前に重複する識別子が含まれている(例:ログデータで同一タイムスタンプを持つレコードが複数存在)
  • ビジネスロジック上、同一キーに対する複数の値を許容すべきケースで、明示的なマージ戦略が未定義

実践的な解決手法

1. マージ関数による明示的解決(推奨)

3引数版の toMap(keyMapper, valueMapper, mergeFunction) を使用し、重複時の振る舞いを制御します。以下は、既存値を優先(first-wins)・新規値を優先(last-wins)・またはカスタム合成の例です:

List<Product> products = Arrays.asList(
    new Product("laptop", 999.99, "electronics"),
    new Product("mouse", 29.99, "electronics"),
    new Product("keyboard", 79.99, "electronics")
);

// カテゴリ名 → 最高価格をマップ化(重複カテゴリ時は最大値を選択)
Map<String, Double> categoryMaxPrice = products.stream()
    .collect(Collectors.toMap(
        Product::getCategory,
        Product::getPrice,
        Double::max  // 重複キー時は価格の大きい方を採用
    ));

2. グループ化+集約による柔軟な処理

単一値ではなく、重複キーに関連する全値を保持したい場合は、Collectors.groupingBy() と組み合わせてリスト化・集約します:

// 同一カテゴリ内の全製品をリストで集約
Map<String, List<Product>> categoryToProducts = products.stream()
    .collect(Collectors.groupingBy(Product::getCategory));

// 各カテゴリの平均価格も同時に算出可能
Map<String, Double> avgPriceByCategory = products.stream()
    .collect(Collectors.groupingBy(
        Product::getCategory,
        Collectors.averagingDouble(Product::getPrice)
    ));

3. 前処理による重複排除

キーの一意性が保証される必要がある場合、ストリーム段階で distinct() やキー基準のフィルタリングを行います:

// キー(カテゴリ)ごとの最初の出現のみを採用(順序依存)
Map<String, Product> firstByCategory = products.stream()
    .collect(Collectors.toMap(
        Product::getCategory,
        Function.identity(),
        (a, b) -> a,  // 重複時は最初の要素を維持
        LinkedHashMap::new  // 入力順を保持するMap
    ));

4. エラー回避の代替アプローチ:toConcurrentMap

並列ストリームで動作させる場合や、より細かい競合制御が必要な際には、toConcurrentMap を検討します。ただし、マージ関数の指定は必須です:

ConcurrentMap<String, Product> concurrentMap = products.parallelStream()
    .collect(Collectors.toConcurrentMap(
        Product::getName,
        Function.identity(),
        (p1, p2) -> p1.getPrice() > p2.getPrice() ? p1 : p2  // 高価な方を採用
    ));

タグ: java-stream collectors-tomap Java8 merge-function groupingby

7月31日 16:44 投稿