JavaでListをMapに変換するには、Stream APIの collect に Collectors.toMap(キーを作る関数, 値を作る関数)を渡します。 キーが重なる可能性があるときは3つ目の引数(マージ関数)で「どちらを残すか」を決め、順序を保ちたいときは4つ目の引数で LinkedHashMap を指定します。1つのキーに複数の要素をまとめたいときは groupingBy が向いています。

この記事では、toMap の2引数・3引数・4引数と groupingBy・partitioningBy を「結果の型/キー重複時/順序」で比べる早見表を最初に示します。そのうえで、Duplicate key の IllegalStateException と、値が null のときの NullPointerException をスタックトレースごと再現し、先勝ち・後勝ち・合算・リスト化の4つの直し方を実行結果つきで並べます。コードはすべて1ファイルで動く完成形です。

確認環境:Ubuntu 24.04、OpenJDK 21.0.10(java ファイル名.java で実行)。エラー文の比較だけ、OpenJDK 8(1.8.0_504)と OpenJDK 11.0.32 でも同じコードを実行しました。

ListからMapへの変換 早見表

書き方 結果の型 キーが重複したとき 順序 向いている場面
toMap(キー, 値)(2引数) Map<K, V>(Java 21では HashMap) IllegalStateException で止まる 保たれない キーが必ず一意(IDなど)
toMap(キー, 値, マージ関数)(3引数) Map<K, V>(Java 21では HashMap) マージ関数の戻り値を採用 保たれない 先勝ち・後勝ち・合算を決めたい
toMap(キー, 値, マージ関数, Mapの作り方)(4引数) 指定したMap(LinkedHashMap、TreeMapなど) マージ関数の戻り値を採用 LinkedHashMapなら出てきた順、TreeMapならキーの昇順 順序を保ちたい・並べたい
groupingBy(キー) Map<K, List<T>> 同じキーの要素をリストにまとめる 保たれない(3引数版でLinkedHashMapを指定可) 1つのキーに複数の要素
groupingBy(キー, 集計) Map<K, 集計結果> 件数・合計などに集計 同上 部署ごとの件数・合計
partitioningBy(条件) Map<Boolean, List<T>> true と false の2つに振り分け false、true の順 条件に合う/合わないで分ける
toUnmodifiableMap(キー, 値)(Java 10以降) 変更できないMap IllegalStateException で止まる 保たれない 作ったあとに変更させたくない
for文と put 自分で用意したMap 後から入れた値で上書き(後勝ち) 用意したMapしだい 値に null を入れたい・処理が複雑
つまずきやすい点 起きること 直し方
2引数の toMap でキーが重複 IllegalStateException: Duplicate key ... マージ関数を足す、または groupingBy にする
値を作る関数が null を返す NullPointerException(引数の数に関係なく発生) 既定値に置き換える・除外する・put で入れる
groupingBy のキーが null NullPointerException: element cannot be mapped to a null key null を別の値に置き換えてからグループ化
表示順がリストと違う HashMap は順序を保たない 4引数で LinkedHashMap::new を渡す

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java ListをMapに変換する方法 toMapのキー重複エラー対処」

基本:toMapでキーと値を指定する

toMap の最初の引数は「要素からキーを作る関数」、2つ目は「要素から値を作る関数」です。record(Java 16以降で使える、データを入れるための短いクラスの書き方)を使うと、Member::id のようなメソッド参照でそのまま書けます。

import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;

public class Basic {
    record Member(int id, String name, String dept, int score) {}

    public static void main(String[] args) {
        List<Member> members = List.of(
            new Member(103, "佐藤", "開発", 80),
            new Member(101, "鈴木", "営業", 65),
            new Member(102, "高橋", "開発", 92));

        // 1. キー=id、値=名前
        Map<Integer, String> nameById = members.stream()
            .collect(Collectors.toMap(Member::id, Member::name));
        System.out.println("1: " + nameById);

        // 2. キー=id、値=要素そのもの(Function.identity)
        Map<Integer, Member> memberById = members.stream()
            .collect(Collectors.toMap(Member::id, Function.identity()));
        System.out.println("2: " + memberById.get(102));

        // 3. 返ってくるMapの実装クラス
        System.out.println("3: " + nameById.getClass().getName());

        // 4. for文で書いた場合
        Map<Integer, String> byLoop = new HashMap<>();
        for (Member m : members) {
            byLoop.put(m.id(), m.name());
        }
        System.out.println("4: " + byLoop);
    }
}

実行結果:

1: {101=鈴木, 102=高橋, 103=佐藤}
2: Member[id=102, name=高橋, dept=開発, score=92]
3: java.util.HashMap
4: {101=鈴木, 102=高橋, 103=佐藤}

読み取れることは3つあります。

  • 値に要素そのものを入れたいときは Function.identity() を使います。 これは「受け取ったものをそのまま返す関数」で、m -> m と書いても同じ結果になります。IDから要素を引く辞書を作るときの定番です。
  • 2引数の toMap が返すMapは、Java 21 では java.util.HashMap でした。ただし公式ドキュメントは、返されるMapの型・変更できるかどうか・スレッド安全性について保証しないと書いています。型を決めたいときは4引数版でMapの作り方を渡します(後述)。
  • for文と put でも同じMapが作れます。Streamとの違いは、キーが重複したときの動きです。put は後から入れた値で静かに上書きしますが、2引数の toMap は例外で知らせます。

メソッド参照とラムダ式の対応

書き方 同じ意味のラムダ式 用途
Member::id m -> m.id() recordの項目をキーにする
Member::getId m -> m.getId() getterを持つ通常のクラス
Function.identity() m -> m 要素そのものを値(またはキー)にする
String::length s -> s.length() 文字列の長さを値にする

キー重複のエラーを再現する:IllegalStateException

キーにした項目に同じ値が2回出てくると、2引数の toMap は止まります。部署名をキーにして、同じ部署の人が2人いるリストで試します。

import java.util.*;
import java.util.stream.Collectors;

public class Dup {
    record Member(int id, String name, String dept, int score) {}

    public static void main(String[] args) {
        List<Member> members = List.of(
            new Member(101, "鈴木", "営業", 65),
            new Member(102, "高橋", "開発", 92),
            new Member(103, "佐藤", "開発", 80));

        // キーにした部署名「開発」が2件ある
        Map<String, String> nameByDept = members.stream()
            .collect(Collectors.toMap(Member::dept, Member::name));
        System.out.println(nameByDept);
    }
}

実行結果(Java 21):

Exception in thread "main" java.lang.IllegalStateException: Duplicate key 開発 (attempted merging values 高橋 and 佐藤)
    at java.base/java.util.stream.Collectors.duplicateKeyException(Collectors.java:135)
    at java.base/java.util.stream.Collectors.lambda$uniqKeysMapAccumulator$1(Collectors.java:182)
    at java.base/java.util.stream.ReduceOps$3ReducingSink.accept(ReduceOps.java:169)
    at java.base/java.util.AbstractList$RandomAccessSpliterator.forEachRemaining(AbstractList.java:722)
    at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
    at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
    at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:921)
    at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
    at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:682)
    at Dup.main(Dup.java:15)

メッセージの読み方は次のとおりです。

部分 意味
Duplicate key 開発 重複したキーは「開発」
attempted merging values 高橋 and 佐藤 すでに入っていた値「高橋」と、後から来た値「佐藤」をまとめようとした
at Dup.main(Dup.java:15) 自分のコードで collect を呼んだ行

スタックトレースは下から読むと、自分のコードの行(Dup.java:15)にすぐたどり着けます。 上のほうの java.base/ で始まる行はJDK内部の処理です。

Java 8とJava 9以降でエラー文が違う

同じ内容のコード(部署 dev に takahashi と sato がいるリスト)を、3つのバージョンで実行して例外を表示させました。

import java.util.*;
import java.util.stream.Collectors;

public class Dup8 {
    public static void main(String[] args) {
        System.out.println("java.version = " + System.getProperty("java.version"));
        List<String[]> rows = Arrays.asList(
            new String[] {"sales", "suzuki"},
            new String[] {"dev", "takahashi"},
            new String[] {"dev", "sato"});
        try {
            rows.stream().collect(Collectors.toMap(r -> r[0], r -> r[1]));
        } catch (IllegalStateException e) {
            System.out.println(e);
        }
    }
}
実行したJava 表示されたメッセージ
OpenJDK 8(1.8.0_504) java.lang.IllegalStateException: Duplicate key takahashi
OpenJDK 11.0.32 java.lang.IllegalStateException: Duplicate key dev (attempted merging values takahashi and sato)
OpenJDK 21.0.10 java.lang.IllegalStateException: Duplicate key dev (attempted merging values takahashi and sato)

Java 8 では「Duplicate key」の後ろに、キーではなく先に入っていた値(takahashi)が表示されます。 キーは dev なので、メッセージだけ見ると戸惑いやすいところです。OpenJDKの課題票 JDK-8040892 に「Duplicate key の後ろに出るのはキーではなく値のひとつ」という報告があり、修正済みになっています。Java 11 と 21 では、キーと2つの値がそろって表示されました。Java 8 で動いているシステムのログを読むときは、「Duplicate key の後ろは値」と覚えておくと原因のキーを探しやすくなります。

キー重複の直し方4つ:先勝ち・後勝ち・合算・リスト化

3つ目の引数(マージ関数)は、同じキーが出てきたときに「すでに入っている値 a」と「新しく来た値 b」を受け取り、残す値を返す関数です。

import java.util.*;
import java.util.stream.Collectors;

public class Fix {
    record Member(int id, String name, String dept, int score) {}

    public static void main(String[] args) {
        List<Member> members = List.of(
            new Member(101, "鈴木", "営業", 65),
            new Member(102, "高橋", "開発", 92),
            new Member(103, "佐藤", "開発", 80));

        // 直し方1: 先勝ち(先に出てきた値を残す)
        Map<String, String> first = members.stream()
            .collect(Collectors.toMap(Member::dept, Member::name, (a, b) -> a));
        System.out.println("先勝ち  : " + first);

        // 直し方2: 後勝ち(後から出てきた値で上書き)
        Map<String, String> last = members.stream()
            .collect(Collectors.toMap(Member::dept, Member::name, (a, b) -> b));
        System.out.println("後勝ち  : " + last);

        // 直し方3: 合算(同じキーの値を足す)
        Map<String, Integer> sum = members.stream()
            .collect(Collectors.toMap(Member::dept, Member::score, Integer::sum));
        System.out.println("合算    : " + sum);

        // 直し方4: リスト化(同じキーの値をすべて残す)
        Map<String, List<String>> list = members.stream()
            .collect(Collectors.groupingBy(Member::dept,
                Collectors.mapping(Member::name, Collectors.toList())));
        System.out.println("リスト化: " + list);

        // 参考: 文字列としてつなぐ
        Map<String, String> joined = members.stream()
            .collect(Collectors.toMap(Member::dept, Member::name, (a, b) -> a + "・" + b));
        System.out.println("連結    : " + joined);

        // 参考: 点数が高いほうを残す
        Map<String, Member> top = members.stream()
            .collect(Collectors.toMap(Member::dept, m -> m,
                (a, b) -> a.score() >= b.score() ? a : b));
        System.out.println("最高点  : " + top);
    }
}

実行結果:

先勝ち  : {開発=高橋, 営業=鈴木}
後勝ち  : {開発=佐藤, 営業=鈴木}
合算    : {開発=172, 営業=65}
リスト化: {開発=[高橋, 佐藤], 営業=[鈴木]}
連結    : {開発=高橋・佐藤, 営業=鈴木}
最高点  : {開発=Member[id=102, name=高橋, dept=開発, score=92], 営業=Member[id=101, name=鈴木, dept=営業, score=65]}
直し方 マージ関数・書き方 「開発」の結果 選ぶ目安
先勝ち (a, b) -> a 高橋 最初に登録されたものを正とする
後勝ち (a, b) -> b 佐藤 新しいデータで上書きしたい
合算 Integer::sum 172 数値を合計したい
リスト化 groupingBy と mapping [高橋, 佐藤] すべて残したい
連結 (a, b) -> a + "・" + b 高橋・佐藤 表示用に1つの文字列にしたい
条件で選ぶ (a, b) -> a.score() >= b.score() ? a : b 高橋(92点) 最大・最新などを残したい

どれを選ぶかは「重複が起きたとき、業務としてどれが正しいか」で決まります。 重複が起きないはずのデータ(IDなど)は、あえて2引数のままにして例外で気づけるようにするのも1つの考え方です。重複がありえるデータは、先勝ちか後勝ちかを明示しておくと、読む人に意図が伝わります。

toMapでリスト化する書き方との比較

リスト化は toMap でも書けますが、groupingBy のほうが短く読みやすくなります。

書き方 コード
groupingBy(要素のリスト) Collectors.groupingBy(Member::dept)
groupingBy(名前だけのリスト) Collectors.groupingBy(Member::dept, Collectors.mapping(Member::name, Collectors.toList()))
toMap で同じことをする 値を new ArrayList<>(List.of(m.name())) にし、マージ関数で a.addAll(b); return a; と書く

値がnullのときのNullPointerExceptionを再現する

もう1つつまずきやすいのが、値を作る関数が null を返すケースです。HashMap 自体は値に null を入れられますが、toMap は受け付けません。

import java.util.*;
import java.util.stream.Collectors;

public class NullVal {
    record Item(String code, String memo) {}

    public static void main(String[] args) {
        List<Item> items = List.of(
            new Item("A-1", "棚1"),
            new Item("A-2", null),   // メモが未入力
            new Item("A-3", "棚3"));

        Map<String, String> memoByCode = items.stream()
            .collect(Collectors.toMap(Item::code, Item::memo));
        System.out.println(memoByCode);
    }
}

実行結果(Java 21):

Exception in thread "main" java.lang.NullPointerException
    at java.base/java.util.Objects.requireNonNull(Objects.java:233)
    at java.base/java.util.stream.Collectors.lambda$uniqKeysMapAccumulator$1(Collectors.java:180)
    at java.base/java.util.stream.ReduceOps$3ReducingSink.accept(ReduceOps.java:169)
    at java.base/java.util.AbstractList$RandomAccessSpliterator.forEachRemaining(AbstractList.java:722)
    at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
    at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
    at java.base/java.util.stream.ReduceOps$ReduceOp.evaluateSequential(ReduceOps.java:921)
    at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
    at java.base/java.util.stream.ReferencePipeline.collect(ReferencePipeline.java:682)
    at NullVal.main(NullVal.java:14)

メッセージ本文がなく、自分のコードの行は最後の NullVal.java:14(collect の行)だけです。スタックトレースに Collectors と requireNonNull が並んでいたら、「toMap の値が null」と見当がつきます。 Java 8 で同じことをすると、例外が出る場所は java.util.HashMap.merge(HashMap.java:1226) でした(Java 11 と 21 は Objects.requireNonNull)。場所は違っても、原因は同じです。

なお、マージ関数を足した3引数・4引数でも、値が null なら同じように止まります。null は引数の数では解決しないので、次のいずれかで直します。

import java.util.*;
import java.util.stream.Collectors;

public class NullFix {
    record Item(String code, String memo) {}

    public static void main(String[] args) {
        List<Item> items = List.of(
            new Item("A-1", "棚1"),
            new Item("A-2", null),
            new Item("A-3", "棚3"));

        // 直し方1: nullを既定値に置き換える
        Map<String, String> a = items.stream()
            .collect(Collectors.toMap(Item::code,
                i -> Objects.requireNonNullElse(i.memo(), "")));
        System.out.println("既定値  : " + a);

        // 直し方2: nullの要素を除いてから集める
        Map<String, String> b = items.stream()
            .filter(i -> i.memo() != null)
            .collect(Collectors.toMap(Item::code, Item::memo));
        System.out.println("除外    : " + b);

        // 直し方3: nullのまま入れる(3引数のcollectでputを使う)
        Map<String, String> c = items.stream()
            .collect(HashMap::new, (m, i) -> m.put(i.code(), i.memo()), HashMap::putAll);
        System.out.println("nullのまま: " + c);

        // 直し方4: for文とputでnullのまま入れる
        Map<String, String> d = new LinkedHashMap<>();
        for (Item i : items) {
            d.put(i.code(), i.memo());
        }
        System.out.println("for文   : " + d);
    }
}

実行結果:

既定値  : {A-1=棚1, A-2=, A-3=棚3}
除外    : {A-1=棚1, A-3=棚3}
nullのまま: {A-1=棚1, A-2=null, A-3=棚3}
for文   : {A-1=棚1, A-2=null, A-3=棚3}
直し方 結果 選ぶ目安
既定値に置き換える(Objects.requireNonNullElse) A-2=(空文字) 後の処理で null を気にしたくない
filter で除く A-2 がMapに入らない 値がないものは不要
3引数の collect で put する A-2=null null のまま持ちたい・Streamで書きたい
for文と put A-2=null null のまま持ちたい・順序も決めたい

キーがnullのとき:toMapとgroupingByで動きが違う

import java.util.*;
import java.util.stream.Collectors;

public class NullKey {
    record Member(String name, String dept) {}

    public static void main(String[] args) {
        List<Member> members = List.of(
            new Member("sato", "dev"), new Member("ito", null));

        // toMapはキーがnullでも入る(HashMapはnullキーを1つ持てる)
        System.out.println(members.stream()
            .collect(Collectors.toMap(Member::dept, Member::name)));

        // groupingByはキーがnullだと止まる
        try {
            members.stream().collect(Collectors.groupingBy(Member::dept));
        } catch (NullPointerException e) {
            System.out.println(e);
        }

        // 直し方: nullを「未設定」に置き換えてからグループ化
        System.out.println(members.stream().collect(Collectors.groupingBy(
            m -> Objects.requireNonNullElse(m.dept(), "未設定"),
            Collectors.mapping(Member::name, Collectors.toList()))));
    }
}

実行結果:

{null=ito, dev=sato}
java.lang.NullPointerException: element cannot be mapped to a null key
{未設定=[ito], dev=[sato]}

toMap はキーが null でもMapに入りました(HashMap は null のキーを1つ持てます)。一方、groupingBy はキーが null だと「element cannot be mapped to a null key」というメッセージで止まります。 項目が空の可能性があるときは、先に「未設定」などの値に置き換えてからグループ化すると、空のデータも1つのグループとして扱えます。

順序を保つ:LinkedHashMapとTreeMapを4引数で指定する

HashMap は入れた順序を保ちません。リストの順序のままMapにしたいときは、4つ目の引数に LinkedHashMap::new を渡します。4引数版は3つ目のマージ関数を省略できないので、重複しない場合でも (a, b) -> a などを書きます。

import java.util.*;
import java.util.stream.Collectors;

public class Order {
    record Member(int id, String name, String dept, int score) {}

    public static void main(String[] args) {
        List<Member> members = List.of(
            new Member(3, "sato", "dev", 80),
            new Member(1, "suzuki", "sales", 65),
            new Member(2, "takahashi", "dev", 92),
            new Member(4, "ito", "admin", 58));

        // 2引数・3引数: HashMap(リストの順序は残らない)
        Map<String, Integer> hash = members.stream()
            .collect(Collectors.toMap(Member::name, Member::score));
        System.out.println("HashMap      : " + hash);

        // 4引数: LinkedHashMap(リストの順序のまま)
        Map<String, Integer> linked = members.stream()
            .collect(Collectors.toMap(Member::name, Member::score,
                (a, b) -> a, LinkedHashMap::new));
        System.out.println("LinkedHashMap: " + linked);

        // 4引数: TreeMap(キーの昇順)
        Map<String, Integer> tree = members.stream()
            .collect(Collectors.toMap(Member::name, Member::score,
                (a, b) -> a, TreeMap::new));
        System.out.println("TreeMap      : " + tree);

        // 先に並べ替えてからLinkedHashMapに入れる(点数の高い順)
        Map<String, Integer> sorted = members.stream()
            .sorted(Comparator.comparingInt(Member::score).reversed())
            .collect(Collectors.toMap(Member::name, Member::score,
                (a, b) -> a, LinkedHashMap::new));
        System.out.println("点数の高い順 : " + sorted);
    }
}

実行結果:

HashMap      : {takahashi=92, ito=58, sato=80, suzuki=65}
LinkedHashMap: {sato=80, suzuki=65, takahashi=92, ito=58}
TreeMap      : {ito=58, sato=80, suzuki=65, takahashi=92}
点数の高い順 : {takahashi=92, sato=80, suzuki=65, ito=58}
指定 並び 用途
指定なし(HashMap) 入れた順でもキーの順でもない 順序が不要で、取り出しだけ速ければよい
LinkedHashMap::new リストに出てきた順 画面表示・CSV出力など、元の順序を見せたい
TreeMap::new キーの昇順 キーで並べて見せたい・範囲で取り出したい
sorted の後に LinkedHashMap::new 並べ替えた順 値の大きい順など、キー以外の順序にしたい

値の順に並んだMapがほしいときは、「先に sorted で並べ替え、LinkedHashMap に入れる」の2段階にします。 TreeMap はキーで並べるMapなので、値の順には使えません。

groupingByとpartitioningBy:1つのキーに複数の要素をまとめる

「部署ごとのメンバー一覧」のように、1つのキーに複数の要素がぶら下がる形がほしいときは groupingBy を使います。条件で2つに分けたいだけなら partitioningBy が簡潔です。

import java.util.*;
import java.util.stream.Collectors;

public class Group {
    record Member(int id, String name, String dept, int score) {}

    public static void main(String[] args) {
        List<Member> members = List.of(
            new Member(3, "sato", "dev", 80),
            new Member(1, "suzuki", "sales", 65),
            new Member(2, "takahashi", "dev", 92),
            new Member(4, "ito", "admin", 58));

        // groupingBy(1引数): キーごとに要素のリストを作る
        Map<String, List<Member>> byDept = members.stream()
            .collect(Collectors.groupingBy(Member::dept));
        byDept.forEach((k, v) -> System.out.println("groupingBy  : " + k + " = "
            + v.stream().map(Member::name).toList()));
        System.out.println("実装クラス  : " + byDept.getClass().getSimpleName()
            + " / " + byDept.get("dev").getClass().getSimpleName());

        // groupingBy(2引数): 件数・合計
        System.out.println("件数        : " + members.stream()
            .collect(Collectors.groupingBy(Member::dept, Collectors.counting())));
        System.out.println("合計        : " + members.stream()
            .collect(Collectors.groupingBy(Member::dept, Collectors.summingInt(Member::score))));

        // groupingBy(3引数): 出てきた順を保ち、名前だけのリストにする
        Map<String, List<String>> ordered = members.stream()
            .collect(Collectors.groupingBy(Member::dept, LinkedHashMap::new,
                Collectors.mapping(Member::name, Collectors.toList())));
        System.out.println("順序を保つ  : " + ordered);

        // partitioningBy: 条件に合う/合わないの2つに分ける
        Map<Boolean, List<String>> pass = members.stream()
            .collect(Collectors.partitioningBy(m -> m.score() >= 70,
                Collectors.mapping(Member::name, Collectors.toList())));
        System.out.println("partitioning: " + pass);
        System.out.println("70点以上    : " + pass.get(true));

        // 該当が0件でも true と false の両方のキーが入る
        Map<Boolean, Long> none = members.stream()
            .collect(Collectors.partitioningBy(m -> m.score() >= 100, Collectors.counting()));
        System.out.println("該当なしでも: " + none);
    }
}

実行結果:

groupingBy  : dev = [sato, takahashi]
groupingBy  : admin = [ito]
groupingBy  : sales = [suzuki]
実装クラス  : HashMap / ArrayList
件数        : {dev=2, admin=1, sales=1}
合計        : {dev=172, admin=58, sales=65}
順序を保つ  : {dev=[sato, takahashi], sales=[suzuki], admin=[ito]}
partitioning: {false=[suzuki, ito], true=[sato, takahashi]}
70点以上    : [sato, takahashi]
該当なしでも: {false=4, true=0}
  • groupingBy(1引数)の結果は、Java 21 では HashMap と ArrayList でした。toMap と同じく、型は保証されていません。順序を決めたいときは3引数版で LinkedHashMap::new を渡します。
  • 2つ目の引数(下流のコレクター)を変えると、リスト以外の形に集計できます。 counting(件数)、summingInt(合計)、mapping(一部の項目だけ取り出す)などです。
  • partitioningBy は、該当が0件でも true と false の両方のキーが必ず入ります。 そのため pass.get(true) が null になる心配がありません。公式ドキュメントにも、返されるMapは常に false と true の両方を含むと書かれています。

toMapとgroupingByのどちらを使うか

flowchart TD
    A[ListをMapにしたい] --> B{1つのキーに<br>要素は1つ?}
    B -->|はい| C{キーは必ず一意?}
    B -->|いいえ 複数ある| D{分け方は?}
    C -->|はい| E[toMap 2引数]
    C -->|重複がありえる| F[toMap 3引数<br>先勝ち・後勝ち・合算]
    D -->|項目の値ごと| G[groupingBy]
    D -->|条件で2つ| H[partitioningBy]
    E --> I{順序を保つ?}
    F --> I
    I -->|はい| J[4引数で LinkedHashMap]
    I -->|いいえ| K[そのまま]
ほしい結果 使うもの
ID → 要素 toMap(Member::id, Function.identity())
部署 → メンバーのリスト groupingBy(Member::dept)
部署 → 人数 groupingBy(Member::dept, Collectors.counting())
部署 → 点数の合計 groupingBy(..., summingInt(...)) または toMap(Member::dept, Member::score, Integer::sum)
部署 → 最高点の1人 toMap(Member::dept, m -> m, (a, b) -> a.score() >= b.score() ? a : b)
合格/不合格 → リスト partitioningBy(m -> m.score() >= 70)

よく使う変換の例:添字・文字列・2つのリスト・変更できないMap

import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
import java.util.stream.IntStream;

public class Misc {
    record Member(int id, String name) {}

    public static void main(String[] args) {
        List<Member> members = List.of(new Member(1, "sato"), new Member(2, "suzuki"));
        List<String> names = List.of("sato", "suzuki", "ito");

        // 変更できないMapにする(Java 10以降)
        Map<Integer, String> fixed = members.stream()
            .collect(Collectors.toUnmodifiableMap(Member::id, Member::name));
        try {
            fixed.put(9, "x");
        } catch (UnsupportedOperationException e) {
            System.out.println("変更不可  : " + e);
        }

        // 添字をキーにする
        Map<Integer, String> byIndex = IntStream.range(0, names.size())
            .boxed()
            .collect(Collectors.toMap(Function.identity(), names::get));
        System.out.println("添字がキー: " + byIndex);

        // 文字列のリスト → 文字列と長さ
        Map<String, Integer> length = names.stream()
            .collect(Collectors.toMap(Function.identity(), String::length));
        System.out.println("長さ      : " + length);

        // 2つのリスト(キーのリストと値のリスト)を組み合わせる
        List<String> keys = List.of("a", "b", "c");
        List<Integer> values = List.of(10, 20, 30);
        Map<String, Integer> zipped = IntStream.range(0, keys.size())
            .boxed()
            .collect(Collectors.toMap(keys::get, values::get, (x, y) -> x, LinkedHashMap::new));
        System.out.println("2つを結合 : " + zipped);

        // 取り出すときは get と getOrDefault
        System.out.println("get       : " + length.get("sato") + " / " + length.get("abe")
            + " / " + length.getOrDefault("abe", 0));
    }
}

実行結果:

変更不可  : java.lang.UnsupportedOperationException
添字がキー: {0=sato, 1=suzuki, 2=ito}
長さ      : {ito=3, sato=4, suzuki=6}
2つを結合 : {a=10, b=20, c=30}
get       : 4 / null / 0
  • toUnmodifiableMap は Java 10 で追加されたコレクターで、変更できないMapを返します。 put を呼ぶと UnsupportedOperationException になります。公式ドキュメントによると、キーにも値にも null を受け付けず、キーが重複すると IllegalStateException になります(マージ関数を渡す3引数版もあります)。
  • 添字をキーにしたいときは、IntStream.range(0, 件数) で 0 からの番号を作り、boxed で Integer に変えてから toMap に渡します。
  • キーのリストと値のリストを組み合わせる場合も、同じく添字を使います。2つのリストの件数が違うと get で範囲外になるため、件数をそろえてから使います。
  • 作ったMapから値を取り出すとき、キーがなければ get は null を返します。getOrDefault を使うと、キーがないときの値を決められます。

チェックリスト:toMapを書くときに確かめること

確かめること はいの場合
キーにする項目は必ず一意か 2引数でよい。一意でないならマージ関数か groupingBy
値が null になる可能性はあるか 既定値に置き換える・除外する・put で入れる
キーが null になる可能性はあるか groupingBy を使うなら先に置き換える
結果の順序に意味があるか 4引数で LinkedHashMap か TreeMap
結果を後から変更するか 変更しないなら toUnmodifiableMap も選べる
Java 8 のログを読んでいるか Duplicate key の後ろはキーではなく値

よくある質問

Q. toMap と groupingBy はどう使い分けますか? A. 1つのキーに対して値が1つなら toMap、複数の要素をまとめたいなら groupingBy です。合計のように「まとめて1つの値にする」場合は、toMap のマージ関数(Integer::sum)でも、groupingBy と summingInt の組み合わせでも書けます。今回の例ではどちらも 開発=172 になりました。

Q. マージ関数の (a, b) はどちらが先に入っていた値ですか? A. a がすでにMapに入っている値、b が後から来た値です。今回の実行結果でも、(a, b) -> a はリストで先に出てくる「高橋」、(a, b) -> b は後に出てくる「佐藤」になりました。

Q. 重複しないとわかっているのに、4引数版でマージ関数を書く必要がありますか? A. 4引数版は3つ目の引数を省略できないため、何かしら書く必要があります。重複しない前提なら、(a, b) -> a と書くほか、重複したら例外を投げる関数((a, b) -> { throw new IllegalStateException("重複: " + a); })にしておくと、2引数版と同じように想定外の重複に気づけます。

Q. recordを使わない通常のクラスでも同じように書けますか? A. 書けます。Member::id の部分を Member::getId のようにgetterのメソッド参照へ置き換えるだけです。recordは Java 16 以降の機能なので、それより前のバージョンではgetterを持つクラスを使います。

まとめ

  • ListからMapへの変換は Collectors.toMap(キー, 値)が基本。値に要素そのものを入れるときは Function.identity() を使う。
  • キーが重複すると2引数版は IllegalStateException: Duplicate key で止まる。3つ目の引数で先勝ち (a, b) -> a、後勝ち (a, b) -> b、合算 Integer::sum を選ぶ。
  • すべて残したいときは groupingBy でリスト化する。条件で2つに分けるなら partitioningBy。
  • 値が null だと引数の数に関係なく NullPointerException になる。既定値への置き換え・除外・put のいずれかで直す。
  • 順序を保つには4引数版で LinkedHashMap::new を渡す。キーの昇順なら TreeMap::new。
  • Java 8 のエラー文は Duplicate key の後ろが値、Java 11・21 ではキーと2つの値が表示される。
  • groupingBy はキーが null だと止まるため、空の可能性がある項目は先に置き換える。

参考・出典

  • Java SE 21 API ドキュメント Collectors(toMap、toUnmodifiableMap、groupingBy、partitioningBy): https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/stream/Collectors.html
  • OpenJDK 課題票 JDK-8040892(Collectors.toMap の例外メッセージ): https://bugs.openjdk.org/browse/JDK-8040892

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Java ListをMapに変換する方法 toMapのキー重複エラー対処」

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?