JavaのList・Set・Mapの使い分けは、「順番に並べたいならList、重複をなくしたいならSet、キーで値を引きたいならMap」です。 そのうえで実装クラス(ArrayList、HashSet、HashMapなど)を「順序が必要か」「並べ替えが必要か」「複数スレッドから使うか」で選びます。

この記事では、選び方のフローチャートと、実装クラス11種の比較表(順序・重複・null可否・計算量・スレッド安全性)を最初に示し、よく使う操作をコピペできるコードで紹介します。すべてのコードはJava 21.0.10で実行し、出力をそのまま掲載しました。List.ofが変更やnullで例外になる場面、Java 21で追加されたgetFirst/getLast/reversed、ループ中の削除で起きるConcurrentModificationException、そして「10万件のcontains」「先頭への挿入」をこのマシンで実測した結果まで、上位記事にない実例で確認します。

List・Set・Mapの早見表

種類 ひと言で 順序 重複 取り出し方 迷ったらこれ
List 順番つきの並び 追加した順(番号つき) できる get(番号) ArrayList
Set 重複のない集まり 実装による できない contains(値) HashSet(順序が要るならLinkedHashSet)
Map キーと値の対応表 実装による キーは重複不可 get(キー) HashMap(順序が要るならLinkedHashMap)

やりたいこと別の逆引きです。

やりたいこと 選ぶクラス 主なメソッド
追加した順に並べて、番号で取り出す ArrayList add get set remove
重複を除きたい(順序は不要) HashSet add contains remove
重複を除き、追加した順も保ちたい LinkedHashSet add getFirst reversed
重複を除き、並べ替えた状態で持ちたい TreeSet first last ceiling floor
IDやコードで値を引きたい HashMap put get getOrDefault merge
キーを追加順に保ちたい LinkedHashMap put firstEntry lastEntry
キーを並べ替えて持ちたい・範囲で引きたい TreeMap floorEntry headMap tailMap
キーがenum EnumMap put get
複数スレッドから更新する ConcurrentHashMap merge compute putIfAbsent
先頭・末尾から出し入れする(キュー・スタック) ArrayDeque offer poll push pop
変更しない固定のデータ List.of Set.of Map.of ―

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

(記載例)出典:株式会社RJC「Java List Set Map 使い分け|選び方フローと実測つき」

選び方のフローチャート

最初の分岐は「キーで値を引くか」、次に「重複を許すか」です。

flowchart TD
    A[データをまとめて持ちたい] --> B{キーで値を引きたい?}
    B -- はい --> M{キーの順序は?}
    M -- 不要 --> M1{複数スレッドで更新?}
    M1 -- いいえ --> HM[HashMap]
    M1 -- はい --> CHM[ConcurrentHashMap]
    M -- 追加した順 --> LHM[LinkedHashMap]
    M -- 並べ替えた順・範囲検索 --> TM[TreeMap]
    M -- キーがenum --> EM[EnumMap]
    B -- いいえ --> C{重複を除きたい?}
    C -- はい --> S{要素の順序は?}
    S -- 不要 --> HS[HashSet]
    S -- 追加した順 --> LHS[LinkedHashSet]
    S -- 並べ替えた順 --> TS[TreeSet]
    C -- いいえ --> L{主な操作は?}
    L -- 番号で取得・末尾に追加 --> AL[ArrayList]
    L -- 先頭と末尾から出し入れ --> AD[ArrayDeque]
    L -- 変更しない --> LO[List.of]

迷ったらArrayList・HashSet・HashMapから始め、順序が必要になったらLinked〜、並べ替えが必要になったらTree〜に替えると覚えておくと、ほとんどの場面に対応できます。変数の型をList・Set・Map(インターフェース)で宣言しておけば、実装クラスの差し替えは1か所で済みます。

実装クラスの比較表(順序・重複・null・計算量・スレッド安全性)

計算量は各クラスのJavadocの記載に基づきます。nは要素数です。

クラス 種類 順序 重複 null 追加 検索(contains/get) 削除 スレッド安全
ArrayList List 追加順 可 可 末尾O(1)※償却、先頭・途中O(n) get(i) O(1)、contains O(n) O(n) いいえ
LinkedList List/Deque 追加順 可 可 先頭・末尾O(1) get(i) O(n)、contains O(n) 先頭・末尾O(1) いいえ
HashSet Set なし 不可 1つ可 O(1)平均 O(1)平均 O(1)平均 いいえ
LinkedHashSet Set 追加順 不可 1つ可 O(1)平均 O(1)平均 O(1)平均 いいえ
TreeSet Set 並べ替え順 不可 不可(自然順序の場合) O(log n) O(log n) O(log n) いいえ
HashMap Map なし キー不可 キー1つ・値可 O(1)平均 O(1)平均 O(1)平均 いいえ
LinkedHashMap Map 追加順 キー不可 キー1つ・値可 O(1)平均 O(1)平均 O(1)平均 いいえ
TreeMap Map キーの並べ替え順 キー不可 キー不可(自然順序の場合)・値可 O(log n) O(log n) O(log n) いいえ
EnumMap Map enumの定義順 キー不可 キー不可・値可 O(1) O(1) O(1) いいえ
ConcurrentHashMap Map なし キー不可 キー・値とも不可 O(1)平均 O(1)平均 O(1)平均 はい
ArrayDeque Deque 追加順 可 不可 先頭・末尾O(1)※償却 contains O(n) 先頭・末尾O(1) いいえ

「平均O(1)」のハッシュ系は、要素のhashCodeが適切に分散していることが前提です。後半でequals/hashCodeを実装しない場合の挙動を確認します。

Listの基本操作(ArrayList)

Listは「番号(インデックス)つきの並び」で、同じ値を何度でも入れられます。

import java.util.*;

public class ListBasics {
    public static void main(String[] args) {
        List<String> tasks = new ArrayList<>();
        tasks.add("design"); tasks.add("build"); tasks.add("test"); tasks.add("build");
        tasks.add(1, "review");                         // 1番目に挿入
        System.out.println(tasks + " size=" + tasks.size());
        System.out.println("get(0)=" + tasks.get(0) + " indexOf(build)=" + tasks.indexOf("build")
                + " contains(test)=" + tasks.contains("test"));
        tasks.set(0, "plan");                           // 置き換え
        tasks.remove("build");                          // 最初に見つかった1つを削除
        System.out.println("after set/remove: " + tasks);
    }
}
[design, review, build, test, build] size=5
get(0)=design indexOf(build)=2 contains(test)=true
after set/remove: [plan, review, test, build]

remove(Object)は最初に見つかった1つだけを削除します。すべて削除したいときは後述のremoveIfを使います。

Setの基本操作(HashSet・LinkedHashSet・TreeSet)

Setは重複を持たない集まりで、実装によって取り出す順序が違います。 同じ4つの文字列を入れて比べます。

import java.util.*;

public class SetBasics {
    public static void main(String[] args) {
        Set<String> hash   = new HashSet<>(List.of("pear", "apple", "banana", "cherry"));
        Set<String> linked = new LinkedHashSet<>(List.of("pear", "apple", "banana", "cherry"));
        Set<String> tree   = new TreeSet<>(List.of("pear", "apple", "banana", "cherry"));
        System.out.println("HashSet       " + hash);
        System.out.println("LinkedHashSet " + linked);
        System.out.println("TreeSet       " + tree);
        System.out.println("add duplicate apple -> " + linked.add("apple") + " " + linked);
    }
}
HashSet       [banana, apple, cherry, pear]
LinkedHashSet [pear, apple, banana, cherry]
TreeSet       [apple, banana, cherry, pear]
add duplicate apple -> false [pear, apple, banana, cherry]

HashSetの順序は保証されません(上の順序はこの実行でたまたまそうなったものです)。画面表示やファイル出力で順序を安定させたいときはLinkedHashSet(追加順)かTreeSet(並べ替え順)を選びます。addは追加できたかをbooleanで返すので、重複チェックと追加を1行で書けます。

Mapの基本操作(HashMap・TreeMap)

Mapはキーと値の対応表で、同じキーにputすると値が上書きされます。

import java.util.*;

public class MapBasics {
    public static void main(String[] args) {
        Map<String, Integer> stock = new HashMap<>();
        stock.put("pen", 10); stock.put("note", 5); stock.put("pen", 12);   // penは上書き
        System.out.println(stock + " get(pen)=" + stock.get("pen") + " get(eraser)=" + stock.get("eraser"));
        System.out.println("getOrDefault(eraser,0)=" + stock.getOrDefault("eraser", 0));
        System.out.println("containsKey(note)=" + stock.containsKey("note"));
        Map<String, Integer> sortedStock = new TreeMap<>(stock);
        System.out.println("TreeMap " + sortedStock);
        for (Map.Entry<String, Integer> e : sortedStock.entrySet()) {
            System.out.println("  " + e.getKey() + "=" + e.getValue());
        }
    }
}
{note=5, pen=12} get(pen)=12 get(eraser)=null
getOrDefault(eraser,0)=0
containsKey(note)=true
TreeMap {note=5, pen=12}
  note=5
  pen=12

存在しないキーのgetはnullです。そのままintに代入するとNullPointerExceptionになるため、既定値が決まっているならgetOrDefaultを使います。キーと値の両方を使うループはentrySet()が基本です。

merge・computeIfAbsent・putIfAbsentで集計を短く書く

「件数を数える」「グループごとにリストを作る」は、Mapの便利メソッドで1行にできます。

import java.util.*;

public class MapAggregate {
    public static void main(String[] args) {
        // 件数を数える
        List<String> words = List.of("pen", "note", "pen", "clip", "pen", "note");
        Map<String, Integer> count = new TreeMap<>();
        for (String w : words) count.merge(w, 1, Integer::sum);
        System.out.println("count=" + count);

        // カテゴリごとにリストへ振り分ける
        Map<String, List<String>> byCategory = new TreeMap<>();
        String[][] items = {{"stationery", "pen"}, {"furniture", "desk"}, {"stationery", "note"}};
        for (String[] it : items) byCategory.computeIfAbsent(it[0], k -> new ArrayList<>()).add(it[1]);
        System.out.println("byCategory=" + byCategory);

        // なければ入れる
        Map<String, Integer> m = new HashMap<>();
        System.out.println("putIfAbsent first=" + m.putIfAbsent("a", 1)
                + " second=" + m.putIfAbsent("a", 2) + " map=" + m);
    }
}
count={clip=1, note=2, pen=3}
byCategory={furniture=[desk], stationery=[pen, note]}
putIfAbsent first=null second=1 map={a=1}
メソッド 動き 向いている処理
getOrDefault(k, d) なければ既定値を返す(Mapは変えない) 参照だけしたい
putIfAbsent(k, v) なければ入れる。戻り値は以前の値 初期値を入れる
computeIfAbsent(k, f) なければfで作って入れ、その値を返す グループ化(値がList)
merge(k, v, f) なければv、あればf(旧値, v) 件数・合計の集計

if (map.containsKey(k)) {...} else {...}の分岐は、ほとんどこの4つで置き換えられます。

List.of・Set.of・Map.ofは変更できない(不変コレクション)

List.ofなどで作ったコレクションは変更できず、nullも入れられません。 実行して例外を確認します。

import java.util.*;

public class ImmutableDemo {
    static void tryRun(String label, Runnable r) {
        try { r.run(); System.out.println(label + " -> OK"); }
        catch (Exception e) {
            System.out.println(label + " -> " + e.getClass().getName()
                    + (e.getMessage() == null ? "" : ": " + e.getMessage()));
        }
    }

    public static void main(String[] args) {
        List<String> fixed = List.of("a", "b");
        tryRun("List.of(...).add", () -> fixed.add("c"));
        tryRun("List.of(\"a\", null)", () -> List.of("a", null));
        tryRun("List.of(...).contains(null)", () -> fixed.contains(null));
        tryRun("Set.of(\"a\", \"a\")", () -> Set.of("a", "a"));
        tryRun("Map.of(\"k\",1,\"k\",2)", () -> Map.of("k", 1, "k", 2));

        List<String> copy = new ArrayList<>(fixed);       // 変更したいならコピーする
        copy.add("c");
        System.out.println("new ArrayList<>(List.of(...)) then add -> " + copy);

        Map<String, Integer> many = Map.ofEntries(Map.entry("pen", 1), Map.entry("note", 2));
        System.out.println("Map.ofEntries get(note)=" + many.get("note"));

        List<String> tasks = new ArrayList<>(List.of("x"));
        tryRun("Stream.toList().add", () -> tasks.stream().toList().add("x"));

        List<String> src = new ArrayList<>(List.of("x"));
        List<String> view = Collections.unmodifiableList(src);   // 読み取り専用の「窓」
        List<String> snap = List.copyOf(src);                     // その時点のコピー
        src.add("y");
        System.out.println("unmodifiableList view=" + view + " List.copyOf=" + snap);
    }
}
List.of(...).add -> java.lang.UnsupportedOperationException
List.of("a", null) -> java.lang.NullPointerException
List.of(...).contains(null) -> java.lang.NullPointerException
Set.of("a", "a") -> java.lang.IllegalArgumentException: duplicate element: a
Map.of("k",1,"k",2) -> java.lang.IllegalArgumentException: duplicate key: k
new ArrayList<>(List.of(...)) then add -> [a, b, c]
Map.ofEntries get(note)=2
Stream.toList().add -> java.lang.UnsupportedOperationException
unmodifiableList view=[x, y] List.copyOf=[x]

意外に見落としやすいのはList.of(...).contains(null)もNullPointerExceptionになる点です。外部から受け取った値でcontainsするときは、nullを先に除いておきます。

作り方 変更 null 元データの変更の影響
List.of / Set.of / Map.of 不可 不可 ―
List.copyOf(src) 不可 不可 受けない(コピー)
Collections.unmodifiableList(src) 不可 元に従う 受ける(読み取り専用の窓)
stream().toList()(Java 16以降) 不可 可 ―
new ArrayList<>(...) 可 可 受けない

Map.ofは10組までなので、それより多いときはMap.ofEntries(Map.entry(...), ...)を使います。Set.ofやMap.ofの反復順序は実行ごとに変わることがあるため、順序に依存しないようにします。

SequencedCollectionで先頭・末尾を扱う(Java 21)

Java 21でSequencedCollection・SequencedMapが追加され、List・LinkedHashSet・LinkedHashMapなどで先頭と末尾を同じ書き方で扱えるようになりました。

import java.util.*;

public class SequencedDemo {
    public static void main(String[] args) {
        List<String> seq = new ArrayList<>(List.of("first", "middle", "last"));
        System.out.println("getFirst=" + seq.getFirst() + " getLast=" + seq.getLast()
                + " reversed=" + seq.reversed());
        seq.addFirst("zero"); seq.addLast("end");
        System.out.println("after addFirst/addLast: " + seq);

        LinkedHashSet<String> lhs = new LinkedHashSet<>(List.of("b", "a", "c"));
        System.out.println("LinkedHashSet getFirst=" + lhs.getFirst() + " reversed=" + lhs.reversed());

        LinkedHashMap<String, Integer> lhm = new LinkedHashMap<>();
        lhm.put("one", 1); lhm.put("two", 2); lhm.put("three", 3);
        System.out.println("LinkedHashMap firstEntry=" + lhm.firstEntry() + " lastEntry=" + lhm.lastEntry()
                + " reversed=" + lhm.reversed());
        lhm.putFirst("zero", 0);
        System.out.println("after putFirst: " + lhm);

        try { List.of().getFirst(); }
        catch (Exception e) { System.out.println("List.of().getFirst() -> " + e.getClass().getName()); }
    }
}
getFirst=first getLast=last reversed=[last, middle, first]
after addFirst/addLast: [zero, first, middle, last, end]
LinkedHashSet getFirst=b reversed=[c, a, b]
LinkedHashMap firstEntry=one=1 lastEntry=three=3 reversed={three=3, two=2, one=1}
after putFirst: {zero=0, one=1, two=2, three=3}
List.of().getFirst() -> java.util.NoSuchElementException
以前の書き方 Java 21以降
list.get(0) list.getFirst()
list.get(list.size() - 1) list.getLast()
Collections.reverse(copy)でコピーを反転 list.reversed()(逆順の「窓」を返す)
linkedHashSet.iterator().next() linkedHashSet.getFirst()
linkedHashMap.entrySet().iterator().next() linkedHashMap.firstEntry()

空のコレクションにgetFirst()を呼ぶとNoSuchElementExceptionになります(get(0)はIndexOutOfBoundsException)。空の可能性があるならisEmpty()で先に確認します。

ループ中の削除はremoveIfで(ConcurrentModificationException)

拡張for文で回しながらlist.removeするとConcurrentModificationExceptionになります。 条件に合う要素を消すならremoveIfが簡潔です。

import java.util.*;

public class RemoveDemo {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>(List.of("pen", "note", "pen", "clip"));
        try {
            for (String s : list) if (s.equals("note")) list.remove(s);   // 直す前
        } catch (Exception e) {
            System.out.println("for-each + remove -> " + e.getClass().getName());
        }

        List<String> list2 = new ArrayList<>(List.of("pen", "note", "pen", "clip"));
        list2.removeIf(s -> s.equals("pen"));                              // 直した後
        System.out.println("removeIf -> " + list2);

        List<String> list3 = new ArrayList<>(List.of("pen", "note", "pen", "clip"));
        for (Iterator<String> it = list3.iterator(); it.hasNext();) {
            if (it.next().equals("pen")) it.remove();                      // Iteratorで消す方法
        }
        System.out.println("Iterator.remove -> " + list3);

        Map<String, Integer> stock = new HashMap<>(Map.of("pen", 0, "note", 3, "clip", 0));
        stock.entrySet().removeIf(e -> e.getValue() == 0);                 // Mapも同様
        System.out.println("map entrySet().removeIf -> " + stock);
    }
}
for-each + remove -> java.util.ConcurrentModificationException
removeIf -> [note, clip]
Iterator.remove -> [note, clip]
map entrySet().removeIf -> {note=3}

拡張for文は内部でIteratorを使っており、Iteratorを通さずに構造を変えると次のnext()で変更を検知して例外になります。Mapで値が0の要素を消すならentrySet().removeIf、ループ中に追加もしたいなら新しいリストに集めてから差し替えます。

equals/hashCodeを実装しないとSetで重複する

HashSetやHashMapのキーはequalsとhashCodeで「同じ」を判定します。 実装していないクラスは、中身が同じでも別物として扱われます。

import java.util.*;

public class EqualsDemo {
    static class Member {                        // equals/hashCodeなし
        final int id; final String name;
        Member(int id, String name) { this.id = id; this.name = name; }
    }

    static class MemberEq {                      // idで同一とみなす
        final int id; final String name;
        MemberEq(int id, String name) { this.id = id; this.name = name; }
        @Override public boolean equals(Object o) { return o instanceof MemberEq m && m.id == id; }
        @Override public int hashCode() { return Integer.hashCode(id); }
    }

    record Item(String code, String name) {}     // recordは自動で実装される

    public static void main(String[] args) {
        Set<Member> s1 = new HashSet<>();
        s1.add(new Member(1, "Sato")); s1.add(new Member(1, "Sato"));
        System.out.println("without equals/hashCode size=" + s1.size());

        Set<MemberEq> s2 = new HashSet<>();
        s2.add(new MemberEq(1, "Sato")); s2.add(new MemberEq(1, "Sato"));
        System.out.println("with equals/hashCode size=" + s2.size());

        Set<Item> s3 = new HashSet<>();
        s3.add(new Item("A1", "pen")); s3.add(new Item("A1", "pen"));
        System.out.println("record size=" + s3.size() + " contains=" + s3.contains(new Item("A1", "pen")));
    }
}
without equals/hashCode size=2
with equals/hashCode size=1
record size=1 contains=true

Setの要素やMapのキーにする独自クラスは、equalsとhashCodeを必ず対で実装します。recordなら全項目で比較するequals/hashCodeが自動で作られるので、値をまとめるだけのクラスには手軽です。TreeSet・TreeMapはequalsではなくcompareTo(またはComparator)で同一性を判定する点も押さえておきます。

Arrays.asListとremoveの落とし穴

Arrays.asListは配列を「固定長のList」として見せるもので、追加や削除はできません。

import java.util.*;

public class AsListDemo {
    public static void main(String[] args) {
        String[] arr = {"a", "b", "c"};
        List<String> asList = Arrays.asList(arr);
        try { asList.add("d"); }
        catch (Exception e) { System.out.println("Arrays.asList(...).add -> " + e.getClass().getName()); }
        asList.set(0, "Z");
        System.out.println("after asList.set(0,Z): array[0]=" + arr[0]);   // 元の配列も変わる

        int[] nums = {1, 2, 3};
        System.out.println("Arrays.asList(int[]).size()=" + Arrays.asList(nums).size());
        System.out.println("Arrays.stream(int[]).boxed().toList()=" + Arrays.stream(nums).boxed().toList());

        List<Integer> ids = new ArrayList<>(List.of(10, 20, 1, 30));
        ids.remove(1);                                 // 番号1(2番目)を削除
        System.out.println("remove(1) -> " + ids);
        ids.remove(Integer.valueOf(1));                // 値1を削除
        System.out.println("remove(Integer.valueOf(1)) -> " + ids);
    }
}
Arrays.asList(...).add -> java.lang.UnsupportedOperationException
after asList.set(0,Z): array[0]=Z
Arrays.asList(int[]).size()=1
Arrays.stream(int[]).boxed().toList()=[1, 2, 3]
remove(1) -> [10, 1, 30]
remove(Integer.valueOf(1)) -> [10, 30]
つまずき 起きること こうするとよい
Arrays.asList(...).add UnsupportedOperationException new ArrayList<>(Arrays.asList(...))
asList.set 元の配列も書き換わる 独立させたいならコピーする
Arrays.asList(int[]) 要素1つ(配列そのもの)のListになる Arrays.stream(nums).boxed().toList()
List<Integer>のremove(1) 番号1が消える 値を消すならremove(Integer.valueOf(1))

nullを入れられるか(クラスごとの違い)

nullの扱いはクラスによって違います。 実行結果です。

import java.util.*;
import java.util.concurrent.ConcurrentHashMap;

public class NullDemo {
    static void tryRun(String label, Runnable r) {
        try { r.run(); System.out.println(label + " -> OK"); }
        catch (Exception e) {
            System.out.println(label + " -> " + e.getClass().getName()
                    + (e.getMessage() == null ? "" : ": " + e.getMessage()));
        }
    }
    public static void main(String[] args) {
        tryRun("HashMap.put(null, 1)", () -> new HashMap<String, Integer>().put(null, 1));
        tryRun("TreeMap.put(null, 1)", () -> new TreeMap<String, Integer>().put(null, 1));
        tryRun("ConcurrentHashMap.put(\"k\", null)", () -> new ConcurrentHashMap<String, Integer>().put("k", null));
        tryRun("HashSet.add(null)", () -> new HashSet<String>().add(null));
        tryRun("TreeSet.add(null)", () -> new TreeSet<String>().add(null));
        tryRun("ArrayDeque.add(null)", () -> new ArrayDeque<String>().add(null));
    }
}
HashMap.put(null, 1) -> OK
TreeMap.put(null, 1) -> java.lang.NullPointerException: Cannot invoke "java.lang.Comparable.compareTo(Object)" because "k1" is null
ConcurrentHashMap.put("k", null) -> java.lang.NullPointerException
HashSet.add(null) -> OK
TreeSet.add(null) -> java.lang.NullPointerException: Cannot invoke "java.lang.Comparable.compareTo(Object)" because "k1" is null
ArrayDeque.add(null) -> java.lang.NullPointerException

ConcurrentHashMapがnullを受け付けないのは、getの結果がnullのとき「キーがない」と一意に判断できるようにするためです。HashMapでもgetのnullが「キーがない」のか「値がnull」なのか区別しにくいので、値にnullを入れない設計にしておくと扱いやすくなります。

EnumMap・ArrayDeque・TreeMapの便利な使い方

import java.util.*;

public class MoreDemo {
    enum Status { TODO, DOING, DONE }

    public static void main(String[] args) {
        EnumMap<Status, Integer> byStatus = new EnumMap<>(Status.class);
        byStatus.put(Status.DONE, 4); byStatus.put(Status.TODO, 7); byStatus.put(Status.DOING, 2);
        System.out.println("EnumMap " + byStatus);                 // enumの定義順

        Deque<String> stack = new ArrayDeque<>();                  // スタック(後入れ先出し)
        stack.push("a"); stack.push("b"); stack.push("c");
        System.out.println("stack pop=" + stack.pop() + " rest=" + stack);

        Deque<String> queue = new ArrayDeque<>();                  // キュー(先入れ先出し)
        queue.offer("a"); queue.offer("b"); queue.offer("c");
        System.out.println("queue poll=" + queue.poll() + " rest=" + queue);

        TreeMap<Integer, String> rank = new TreeMap<>(Map.of(0, "C", 60, "B", 80, "A"));
        System.out.println("score 75 -> floorEntry=" + rank.floorEntry(75)
                + " headMap(60)=" + rank.headMap(60) + " tailMap(60)=" + rank.tailMap(60));
    }
}
EnumMap {TODO=7, DOING=2, DONE=4}
stack pop=c rest=[b, a]
queue poll=a rest=[b, c]
score 75 -> floorEntry=60=B headMap(60)={0=C} tailMap(60)={60=B, 80=A}
  • EnumMapはenumの定義順で並び、内部は配列なので高速です。ステータス別の件数集計に向いています。
  • スタックやキューにはArrayDequeが使えます(StackクラスよりDequeの利用がJavadocで推奨されています)。
  • TreeMap.floorEntryは「指定値以下で最大のキー」を返すので、点数から評価ランクを引くような「区間の検索」が1行で書けます。

性能を実測する(contains・先頭挿入・get)

計算量の違いが実際どれくらいの差になるかを、10万件で測りました。 各処理を2回の予熱のあと5回計測した中央値です。

項目 内容
Java 21.0.10(java CollectionsBench.javaで単一ファイル実行)
マシン この記事の検証用コンテナ(Intel Xeon 2.10GHz、2コア、メモリ7GB)
注意 値は環境によって変わります。クラス間の桁の違いを見る目安としてご覧ください
import java.util.*;
import java.util.function.Supplier;

public class CollectionsBench {
    static final int N = 100_000;
    static long sink;

    static double medianMs(int rounds, Supplier<Long> task) {
        for (int i = 0; i < 2; i++) sink += task.get();          // 予熱
        double[] t = new double[rounds];
        for (int i = 0; i < rounds; i++) {
            long s = System.nanoTime();
            sink += task.get();
            t[i] = (System.nanoTime() - s) / 1_000_000.0;
        }
        Arrays.sort(t);
        return t[rounds / 2];
    }

    public static void main(String[] args) {
        System.out.println("Java " + System.getProperty("java.version") + ", N=" + N);
        List<Integer> arrayList = new ArrayList<>();
        for (int i = 0; i < N; i++) arrayList.add(i);
        Set<Integer> hashSet = new HashSet<>(arrayList);
        Set<Integer> treeSet = new TreeSet<>(arrayList);
        Random r = new Random(42);
        int[] keys = new int[10_000];
        for (int i = 0; i < keys.length; i++) keys[i] = r.nextInt(N * 2);   // 約半分が見つかる

        System.out.printf("contains x10,000  ArrayList : %.2f ms%n", medianMs(5, () -> { long c = 0; for (int k : keys) if (arrayList.contains(k)) c++; return c; }));
        System.out.printf("contains x10,000  HashSet   : %.2f ms%n", medianMs(5, () -> { long c = 0; for (int k : keys) if (hashSet.contains(k)) c++; return c; }));
        System.out.printf("contains x10,000  TreeSet   : %.2f ms%n", medianMs(5, () -> { long c = 0; for (int k : keys) if (treeSet.contains(k)) c++; return c; }));

        System.out.printf("add at head x%d  ArrayList.add(0,x) : %.2f ms%n", N, medianMs(5, () -> { List<Integer> l = new ArrayList<>(); for (int i = 0; i < N; i++) l.add(0, i); return (long) l.size(); }));
        System.out.printf("add at head x%d  LinkedList.addFirst: %.2f ms%n", N, medianMs(5, () -> { LinkedList<Integer> l = new LinkedList<>(); for (int i = 0; i < N; i++) l.addFirst(i); return (long) l.size(); }));
        System.out.printf("add at head x%d  ArrayDeque.addFirst: %.2f ms%n", N, medianMs(5, () -> { ArrayDeque<Integer> d = new ArrayDeque<>(); for (int i = 0; i < N; i++) d.addFirst(i); return (long) d.size(); }));

        System.out.printf("add at tail x%d  ArrayList.add      : %.2f ms%n", N, medianMs(5, () -> { List<Integer> l = new ArrayList<>(); for (int i = 0; i < N; i++) l.add(i); return (long) l.size(); }));
        System.out.printf("add at tail x%d  LinkedList.add     : %.2f ms%n", N, medianMs(5, () -> { List<Integer> l = new LinkedList<>(); for (int i = 0; i < N; i++) l.add(i); return (long) l.size(); }));

        List<Integer> linkedList = new LinkedList<>(arrayList);
        int[] idx = new int[10_000];
        for (int i = 0; i < idx.length; i++) idx[i] = r.nextInt(N);
        System.out.printf("get(i) x10,000    ArrayList : %.2f ms%n", medianMs(5, () -> { long c = 0; for (int i : idx) c += arrayList.get(i); return c; }));
        System.out.printf("get(i) x10,000    LinkedList: %.2f ms%n", medianMs(5, () -> { long c = 0; for (int i : idx) c += linkedList.get(i); return c; }));
    }
}
Java 21.0.10, N=100000
contains x10,000  ArrayList : 565.63 ms
contains x10,000  HashSet   : 1.16 ms
contains x10,000  TreeSet   : 2.62 ms
add at head x100000  ArrayList.add(0,x) : 403.91 ms
add at head x100000  LinkedList.addFirst: 1.44 ms
add at head x100000  ArrayDeque.addFirst: 1.33 ms
add at tail x100000  ArrayList.add      : 1.63 ms
add at tail x100000  LinkedList.add     : 0.93 ms
get(i) x10,000    ArrayList : 0.57 ms
get(i) x10,000    LinkedList: 434.35 ms
操作(このマシンでの中央値) 速い側 遅い側 差
10万件からcontainsを1万回 HashSet 1.16 ms ArrayList 565.63 ms 約490倍
先頭への挿入を10万回 ArrayDeque 1.33 ms ArrayList.add(0, x) 403.91 ms 約300倍
10万件からget(i)を1万回 ArrayList 0.57 ms LinkedList 434.35 ms 約760倍
末尾への追加を10万回 どちらも1〜2 ms程度 ― 差は小さい

「存在チェックが多いならSet」「先頭から出し入れするならArrayDeque」「番号で取り出すならArrayList」という比較表の計算量が、そのまま数百倍の差として表れました。LinkedListは先頭挿入は速いもののget(i)が遅いため、キューやスタックの用途ならArrayDequeを選ぶとどちらの操作も速く動きます。

よくある失敗と直し方のチェックリスト

よくある書き方 起きること こうするとよい
大きなListでcontainsを繰り返す 件数に比例して遅くなる HashSetに入れてからcontains
ArrayListの先頭に挿入を繰り返す 毎回全要素がずれる ArrayDeque.addFirst
LinkedListをget(i)で回す 毎回先頭からたどる ArrayListか拡張for文
拡張for文の中でremove ConcurrentModificationException removeIfまたはIterator.remove
List.ofの結果にadd UnsupportedOperationException new ArrayList<>(List.of(...))
独自クラスをSetに入れる 同じ内容が重複する equals/hashCodeを実装、またはrecord
HashSetの順序に依存する 表示順が安定しない LinkedHashSet/TreeSet
複数スレッドからHashMapを更新 更新が失われるなど結果が不定 ConcurrentHashMapのmerge/compute

よくある質問

Q. ListとSetはどちらを使えばよいですか? A. 重複を許さない・存在チェックが多いならSet、順番や重複に意味があるならListです。実測では10万件のcontainsを1万回行うと、ArrayListが565.63 ms、HashSetが1.16 msでした。

Q. HashMapとTreeMapとLinkedHashMapの違いは何ですか? A. キーの順序が違います。HashMapは順序なしで最も手軽、LinkedHashMapは追加した順、TreeMapはキーの並べ替え順でfloorEntryなどの範囲検索ができます。TreeMapの操作はO(log n)です。

Q. LinkedListはどんなときに使いますか? A. 先頭・末尾の追加削除はO(1)ですが、キューやスタックの用途ならArrayDequeでも同等以上に速く動きます(実測で先頭挿入10万回:ArrayDeque 1.33 ms、LinkedList 1.44 ms)。途中の要素をIteratorで頻繁に挿入・削除するような特殊な場面以外は、ArrayListかArrayDequeで足ります。

Q. List.ofとArrays.asListの違いは何ですか? A. List.ofは完全に変更不可でnullも不可、Arrays.asListは長さ固定だがsetで書き換えでき、元の配列とつながっている点が違います。変更したい場合はどちらもnew ArrayList<>(...)でコピーします。

まとめ

  • 順番ならList、重複なしならSet、キーで引くならMap。迷ったらArrayList・HashSet・HashMap
  • 順序が要るならLinked〜、並べ替えや範囲検索ならTree〜、enumのキーならEnumMap
  • List.of/Set.of/Map.ofは変更不可・null不可。contains(null)でも例外になる
  • Java 21のgetFirst/getLast/reversedで先頭・末尾を統一した書き方で扱える
  • ループ中の削除はremoveIf、集計はmerge、グループ化はcomputeIfAbsent
  • Setの要素・Mapのキーにはequals/hashCode(またはrecord)
  • 実測でcontains・先頭挿入・get(i)はクラス選びで数百倍の差が出た

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

(記載例)出典:株式会社RJC「Java List Set Map 使い分け|選び方フローと実測つき」

株式会社RJCでは、コードレビューや日々の開発の中で、データ構造の選び方のような基本を仲間と一緒に確かめ合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社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で一緒に開発しながら、 成長を楽しみませんか?