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 // 高価な方を採用
));