HashSetとは、同じ値を1つしか持たないコレクション(Set)の代表的な実装で、追加・検索・削除が要素数にほぼ左右されない速さで行えるクラスです。 並び順が必要なら LinkedHashSet(追加した順)、ソートされた状態で持ちたいなら TreeSet(小さい順)を選びます。この3つの違いさえ押さえれば、Set で迷う場面はほとんどなくなります。

この記事では、すべてのコードを OpenJDK 21.0.10 で実行し、出力をそのまま掲載しています(JDK 25.0.4.1 でも同じコードを実行して差分を確認しました)。List の重複削除コピペ3種、集合演算、equals/hashCode を書いていないクラスで重複が残る例、Set.of の例外、TreeSet の ClassCastException、Java 21 で加わった SequencedSet、contains の速度比較まで、つまずきやすい箇所を実際のエラー文とともに確認していきます。

Java Setの早見表

変数の型は Set<String> のようにインターフェースで受け、new する実装クラスで性質を決めるのが基本の書き方です。

やりたいこと 書き方 ポイント
空の Set を作る Set<String> set = new HashSet<>(); 順序は保証されない
追加した順を保つ new LinkedHashSet<>() 追加順で取り出せる
ソートされた状態で持つ new TreeSet<>() 小さい順。null は入らない
追加する set.add("a") 新しく入れば true、既にあれば false
含まれるか調べる set.contains("a") HashSet なら要素数によらず速い
削除する set.remove("a") 消せたら true
条件で削除する set.removeIf(s -> s.isEmpty()) ループ中の削除はこれを使う
件数 / 空か set.size() / set.isEmpty()
List の重複を消す(順序維持) new ArrayList<>(new LinkedHashSet<>(list)) 最初に出た順を保つ
List の重複を消す(ソート) new ArrayList<>(new TreeSet<>(list)) 重複削除と並べ替えを同時に
List の重複を消す(Stream) list.stream().distinct().toList() 結果は変更できない List
和集合 a.addAll(b) a が書き換わる
積集合(共通部分) a.retainAll(b) a が書き換わる
差集合 a.removeAll(b) a が書き換わる
変更しない Set を作る Set.of("a", "b") 重複・null は例外
先頭・末尾を取る(Java 21) seq.getFirst() / seq.getLast() LinkedHashSet・TreeSet で使える
逆順で見る(Java 21) seq.reversed() コピーではなくビュー

HashSet・LinkedHashSet・TreeSetの比較表

項目 HashSet LinkedHashSet TreeSet
取り出す順序 保証なし 追加した順 ソート順(小さい順)
null を入れられるか 入る(1つだけ) 入る(1つだけ) 入らない(NullPointerException)
add / contains / remove の計算量 平均 O(1) 平均 O(1) O(log n)
内部のしくみ HashMap LinkedHashMap(ハッシュ表+連結リスト) TreeMap(赤黒木)
重複の判定に使うもの hashCode() と equals() hashCode() と equals() compareTo() または Comparator
要素に必要な条件 equals/hashCode が正しいこと equals/hashCode が正しいこと Comparable か Comparator を渡すこと
SequencedSet(Java 21) 対象外 実装している 実装している
範囲検索(〜以上、〜未満) できない できない headSet / tailSet / floor など
向いている用途 存在チェック、重複削除 順序を保った重複削除 ランキング、範囲で取り出す処理

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

(記載例)出典:株式会社RJC「Java HashSetの使い方|重複削除とTreeSetとの違い」

HashSetの基本的な使い方(add・contains・remove)

Set は「同じ値を2回入れても1つしか残らない」入れ物です。add の戻り値が boolean になっていて、「新しく入ったか」を教えてくれるのが List との大きな違いです。

import java.util.HashSet;
import java.util.Set;

public class SetBasic {
    public static void main(String[] args) {
        Set<String> set = new HashSet<>();
        System.out.println(set.add("banana"));   // 新しく入った
        System.out.println(set.add("apple"));
        System.out.println(set.add("banana"));   // 既にあるので入らない
        System.out.println(set);
        System.out.println(set.size());
        System.out.println(set.contains("apple"));
        System.out.println(set.remove("apple")); // 消せた
        System.out.println(set.remove("grape")); // 元々ない
        for (String s : set) {
            System.out.println("item=" + s);
        }
        set.clear();
        System.out.println(set + " " + set.isEmpty());
    }
}

実行結果(java SetBasic.java、OpenJDK 21.0.10):

true
true
false
[banana, apple]
2
true
true
false
item=banana
[] true

重複を入れても例外にはならず、静かに無視されて false が返ります。

なお Set には get(0) のような番号で取り出すメソッドがありません。取り出すときは拡張 for 文か iterator()、Stream を使います。番号で扱いたいときは new ArrayList<>(set) で List に変換します。

HashSet・LinkedHashSet・TreeSetの順序の違いを実行して確認

同じデータを3つの実装に入れて、出力される順序を比べます。

import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.TreeSet;

public class SetOrder {
    public static void main(String[] args) {
        List<String> input = List.of("melon", "apple", "cherry", "banana", "apple", "kiwi");
        System.out.println("HashSet       : " + new HashSet<>(input));
        System.out.println("LinkedHashSet : " + new LinkedHashSet<>(input));
        System.out.println("TreeSet       : " + new TreeSet<>(input));

        List<Integer> nums = List.of(300, 5, 42, 1000, 17, 5);
        System.out.println("HashSet       : " + new HashSet<>(nums));
        System.out.println("LinkedHashSet : " + new LinkedHashSet<>(nums));
        System.out.println("TreeSet       : " + new TreeSet<>(nums));
    }
}

実行結果:

HashSet       : [banana, apple, cherry, kiwi, melon]
LinkedHashSet : [melon, apple, cherry, banana, kiwi]
TreeSet       : [apple, banana, cherry, kiwi, melon]
HashSet       : [17, 5, 1000, 42, 300]
LinkedHashSet : [300, 5, 42, 1000, 17]
TreeSet       : [5, 17, 42, 300, 1000]
  • HashSet は追加順でもソート順でもない並びになります。これはハッシュ値で格納場所を決めているためで、容量や JDK の版によって変わりうる順序です。「たまたまこの順で出た」ことに頼らないのがポイントです。
  • LinkedHashSet は最初に追加された順を保ちます。2回目の apple は無視され、位置は最初のままです。
  • TreeSet は常にソートされた状態で持ちます。String は辞書順、Integer は数値の小さい順です。

nullを入れたときの違い

import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.Set;
import java.util.TreeSet;

public class SetNull {
    public static void main(String[] args) {
        Set<String> hs = new HashSet<>();
        hs.add(null);
        hs.add(null);
        System.out.println("HashSet " + hs);

        Set<String> lhs = new LinkedHashSet<>();
        lhs.add(null);
        System.out.println("LinkedHashSet " + lhs);

        try {
            new TreeSet<String>().add(null);
        } catch (Exception e) {
            System.out.println("TreeSet " + e);
        }
        try {
            Set.of("a", null);
        } catch (Exception e) {
            System.out.println("Set.of " + e);
        }
        try {
            System.out.println(Set.of("a").contains(null));
        } catch (Exception e) {
            System.out.println("Set.of contains(null) " + e);
        }
    }
}

実行結果:

HashSet [null]
LinkedHashSet [null]
TreeSet java.lang.NullPointerException: Cannot invoke "java.lang.Comparable.compareTo(Object)" because "k1" is null
Set.of java.lang.NullPointerException
Set.of contains(null) java.lang.NullPointerException: Cannot invoke "Object.equals(Object)" because "o" is null

HashSet と LinkedHashSet は null を1つだけ持てますが、TreeSet と Set.of は null を受け付けません。 TreeSet は大小比較のために compareTo を呼ぶので、null では比較できず例外になります。Set.of で作った Set は contains(null) を呼ぶだけでも NullPointerException になる点も覚えておくと、原因の特定が早くなります。

Setの主要メソッド一覧と実行結果

メソッド 戻り値 動き
add(e) boolean 追加。新しく入れば true
addAll(c) boolean まとめて追加(和集合)。変化があれば true
contains(o) boolean 含まれていれば true
containsAll(c) boolean c の要素をすべて含めば true(部分集合の判定)
remove(o) boolean 削除。消せたら true
removeAll(c) boolean c に含まれる要素を削除(差集合)
retainAll(c) boolean c に含まれる要素だけ残す(積集合)
removeIf(条件) boolean 条件に合う要素を削除
size() / isEmpty() int / boolean 件数 / 空かどうか
clear() void 全削除
iterator() Iterator 1つずつ取り出す。it.remove() で安全に削除できる
stream() Stream Stream API で処理する
toArray(new T[0]) T[] 配列に変換
equals(o) boolean 同じ要素を持つ Set なら true(順序・実装クラスは問わない)
Set.of(...) Set 変更できない Set を作る(Java 9)
Set.copyOf(c) Set 変更できないコピーを作る(Java 10)
import java.util.Arrays;
import java.util.Iterator;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;

public class SetMethods {
    public static void main(String[] args) {
        Set<Integer> a = new TreeSet<>(List.of(1, 2, 3, 4, 5));
        System.out.println(a.containsAll(List.of(1, 5)));
        System.out.println(a.removeIf(n -> n % 2 == 0) + " " + a);

        Integer[] arr = a.toArray(new Integer[0]);
        System.out.println(Arrays.toString(arr));
        System.out.println(a.stream().mapToInt(Integer::intValue).sum());
        System.out.println(a.equals(Set.of(5, 3, 1)));

        Iterator<Integer> it = a.iterator();
        while (it.hasNext()) {
            if (it.next() == 3) {
                it.remove();
            }
        }
        System.out.println(a);
    }
}

実行結果:

true
true [1, 3, 5]
[1, 3, 5]
9
true
[1, 5]

5行目に注目してください。TreeSet の [1, 3, 5] と Set.of(5, 3, 1) は、実装クラスも順序も違いますが equals は true です。Set 同士の比較は「同じ要素を持っているか」だけで決まります。

Listの重複削除コピペ集(順序維持・ソート・Stream distinct)

「Set を使う理由」でいちばん多いのが List の重複削除です。元の順序を保ちたいかどうかで書き方を選びます。

import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;
import java.util.stream.Collectors;

public class Dedupe {
    public static void main(String[] args) {
        List<String> list = List.of("melon", "apple", "cherry", "banana", "apple", "melon", "kiwi");

        // 1. 順序維持版:最初に出てきた順を保つ
        List<String> keepOrder = new ArrayList<>(new LinkedHashSet<>(list));
        // 2. ソート版:重複削除と並べ替えを同時に行う
        List<String> sorted = new ArrayList<>(new TreeSet<>(list));
        // 3. Stream distinct 版:順序を保ち、結果は変更できない List
        List<String> viaStream = list.stream().distinct().toList();
        // 4. 順序を問わない版
        List<String> anyOrder = new ArrayList<>(new HashSet<>(list));

        System.out.println("order kept : " + keepOrder);
        System.out.println("sorted     : " + sorted);
        System.out.println("distinct   : " + viaStream);
        System.out.println("HashSet    : " + anyOrder);

        // 重複があるかどうかだけ知りたい
        System.out.println("has dup?   : " + (list.size() != new HashSet<>(list).size()));

        // 重複している値だけを取り出す(add が false を返す=2回目以降)
        Set<String> seen = new HashSet<>();
        Set<String> dups = list.stream()
                .filter(s -> !seen.add(s))
                .collect(Collectors.toCollection(LinkedHashSet::new));
        System.out.println("duplicates : " + dups);

        // 大文字小文字を区別せずに重複削除
        Set<String> ignoreCase = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
        ignoreCase.addAll(List.of("Apple", "apple", "APPLE", "Kiwi"));
        System.out.println("ignore case: " + ignoreCase);
    }
}

実行結果:

order kept : [melon, apple, cherry, banana, kiwi]
sorted     : [apple, banana, cherry, kiwi, melon]
distinct   : [melon, apple, cherry, banana, kiwi]
HashSet    : [banana, apple, cherry, kiwi, melon]
has dup?   : true
duplicates : [apple, melon]
ignore case: [Apple, Kiwi]
書き方 順序 結果の List 選ぶ場面
new ArrayList<>(new LinkedHashSet<>(list)) 最初に出た順 変更できる 画面表示など、元の並びを保ちたい
new ArrayList<>(new TreeSet<>(list)) ソート順 変更できる 一覧をソートして出したい
list.stream().distinct().toList() 最初に出た順 変更できない Stream の処理の途中で重複を除きたい
new ArrayList<>(new HashSet<>(list)) 保証なし 変更できる 順序が不要で件数が多い

distinct() も内部では equals / hashCode で重複を判定します。つまり自作クラスの List を重複削除したいときは、次の章の equals/hashCode が前提になります。

集合演算(addAll・retainAll・removeAll)で和・積・差を求める

Set には和集合・積集合・差集合のメソッドがあります。どれも呼び出した側の Set を書き換えるので、元の Set を残したいときは先にコピーを作ります。

import java.util.Collections;
import java.util.Set;
import java.util.TreeSet;

public class SetOps {
    public static void main(String[] args) {
        Set<Integer> x = Set.of(1, 2, 3, 4);
        Set<Integer> y = Set.of(3, 4, 5, 6);

        Set<Integer> union = new TreeSet<>(x);   // コピーしてから操作する
        union.addAll(y);
        Set<Integer> inter = new TreeSet<>(x);
        inter.retainAll(y);
        Set<Integer> diff = new TreeSet<>(x);
        diff.removeAll(y);
        Set<Integer> sym = new TreeSet<>(union); // 対称差=和集合−積集合
        sym.removeAll(inter);

        System.out.println("union        : " + union);
        System.out.println("intersection : " + inter);
        System.out.println("difference   : " + diff);
        System.out.println("symmetric    : " + sym);
        System.out.println("subset?      : " + union.containsAll(x));
        System.out.println("disjoint?    : " + Collections.disjoint(x, Set.of(7, 8)));

        try {
            x.retainAll(y); // Set.of で作った Set は書き換えられない
        } catch (Exception e) {
            System.out.println(e);
        }
    }
}

実行結果:

union        : [1, 2, 3, 4, 5, 6]
intersection : [3, 4]
difference   : [1, 2]
symmetric    : [1, 2, 5, 6]
subset?      : true
disjoint?    : true
java.lang.UnsupportedOperationException

最後の例外は、Set.of で作った Set に retainAll を呼んだためです。集合演算は「変更できる Set のコピー」に対して行う、と覚えておくと安心です。

equals/hashCode未実装のクラスは重複が消えない

自作クラスを HashSet に入れたのに重複が消えない。これは Set で最もよく出会うつまずきです。原因と直し方を実行して確認します。

import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

public class SetEquals {
    // equals/hashCode を書いていないクラス
    static class ItemNoEq {
        final String code;
        ItemNoEq(String code) { this.code = code; }
        @Override public String toString() { return "Item(" + code + ")"; }
    }

    // equals/hashCode を書いたクラス
    static class ItemEq {
        final String code;
        ItemEq(String code) { this.code = code; }
        @Override public boolean equals(Object o) {
            return o instanceof ItemEq other && code.equals(other.code);
        }
        @Override public int hashCode() { return Objects.hash(code); }
        @Override public String toString() { return "Item(" + code + ")"; }
    }

    // record は equals/hashCode が自動で用意される
    record Tag(String name) {}

    public static void main(String[] args) {
        Set<ItemNoEq> s1 = new HashSet<>();
        s1.add(new ItemNoEq("A-1"));
        s1.add(new ItemNoEq("A-1"));
        System.out.println(s1.size() + " " + s1);
        System.out.println(s1.contains(new ItemNoEq("A-1")));

        Set<ItemEq> s2 = new HashSet<>();
        s2.add(new ItemEq("A-1"));
        s2.add(new ItemEq("A-1"));
        System.out.println(s2.size() + " " + s2);
        System.out.println(s2.contains(new ItemEq("A-1")));

        Set<Tag> s3 = new HashSet<>();
        s3.add(new Tag("java"));
        s3.add(new Tag("java"));
        System.out.println(s3.size() + " " + s3);
    }
}

実行結果:

2 [Item(A-1), Item(A-1)]
false
1 [Item(A-1)]
true
1 [Tag[name=java]]

equals/hashCode がないクラスは、Object の既定の動き(別々のインスタンスは別物)で比べるため2件残ります。

HashSet はまず hashCode() で入れる場所を決め、同じ場所にある要素と equals() で比べて重複を判定します。 どちらか片方だけでは足りず、2つをセットでオーバーライドする必要があります。値を表すだけのクラスなら、record にするのがいちばん手軽です。オーバーライドの書き方の詳細や IDE での自動生成は、「equals・hashCode のオーバーライド方法」の記事で扱っているので、ここでは Set との関係に絞りました。

入れたあとに中身を変えると見つからなくなる

もう1つ、知っておくと役立つ動きがあります。Set に入れた要素の中身(hashCode に関わる値)をあとから変えると、contains で見つからなくなります。

import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;

public class SetMutable {
    public static void main(String[] args) {
        Set<List<String>> set = new HashSet<>();
        List<String> key = new ArrayList<>(List.of("a"));
        set.add(key);
        key.add("b"); // 入れたあとで中身を変える
        System.out.println(set.contains(key) + " " + set);
    }
}

実行結果:

false [[a, b]]

表示すると入っているのに、contains は false です。追加時の hashCode で決めた場所に置かれたまま、検索は新しい hashCode の場所を見に行くためです。Set の要素には、変更されない値(String、Integer、record、List.of で作った List など)を使うと、この動きを避けられます。

Set.ofの例外:重複でIllegalArgumentException、変更でUnsupportedOperationException

Java 9 で入った Set.of は、変更できない Set を1行で作れる便利なメソッドです。new HashSet<>() とは性質が異なるので、例外を実際に出して確認します。

import java.util.Collections;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;

public class SetOfErrors {
    public static void main(String[] args) {
        try {
            Set.of("a", "b", "a"); // 重複した値を渡す
        } catch (Exception e) {
            System.out.println(e);
        }

        Set<String> im = Set.of("a", "b", "c");
        try {
            im.add("d"); // 変更しようとする
        } catch (Exception e) {
            System.out.println(e);
        }
        try {
            im.remove("a");
        } catch (Exception e) {
            System.out.println(e);
        }

        // Set.copyOf は重複があっても例外にならず、まとめてくれる
        System.out.println(Set.copyOf(List.of("a", "b", "a")).size());

        // Collections.unmodifiableSet も変更できない
        Set<String> um = Collections.unmodifiableSet(new HashSet<>(List.of("a")));
        try {
            um.add("z");
        } catch (Exception e) {
            System.out.println(e);
        }

        // 変更したいときは、変更できる Set にコピーする
        Set<String> mutable = new HashSet<>(im);
        mutable.add("d");
        System.out.println(new TreeSet<>(mutable));
    }
}

実行結果:

java.lang.IllegalArgumentException: duplicate element: a
java.lang.UnsupportedOperationException
java.lang.UnsupportedOperationException
2
java.lang.UnsupportedOperationException
[a, b, c, d]
作り方 重複した値 null 変更 順序
new HashSet<>(List.of(...)) 1つにまとまる 入る できる 保証なし
Set.of(...) IllegalArgumentException NullPointerException UnsupportedOperationException 保証なし(実行のたびに変わることがある)
Set.copyOf(c) 1つにまとまる NullPointerException UnsupportedOperationException 保証なし
Collections.unmodifiableSet(s) 元の Set に従う 元の Set に従う UnsupportedOperationException 元の Set に従う

Set.of は「重複を取り除く道具」ではなく、「重複のない定数の集まりを宣言する道具」です。 重複を含むかもしれないデータには new HashSet<>(list) か Set.copyOf(list) を使います。また Set.of の表示順は実行のたびに変わることがあるので、表示順が必要なら new TreeSet<>(Set.of(...)) のようにソートしてから出力します。

TreeSetにComparableでないクラスを入れるとClassCastException

TreeSet は要素同士を大小比較して並べるので、比較方法がわからないクラスは入れられません。

import java.util.Comparator;
import java.util.List;
import java.util.Set;
import java.util.TreeSet;

public class TreeSetDemo {
    static class Box {
        final int size;
        Box(int size) { this.size = size; }
        @Override public String toString() { return "Box(" + size + ")"; }
    }

    public static void main(String[] args) {
        // 直す前:Comparable でないクラスをそのまま入れる
        try {
            Set<Box> boxes = new TreeSet<>();
            boxes.add(new Box(1));
        } catch (Exception e) {
            System.out.println(e.getClass().getName());
            System.out.println(e.getMessage().substring(0, 68));
        }

        // 直した後:Comparator を渡す
        Set<Box> boxes = new TreeSet<>(Comparator.comparingInt(b -> b.size));
        boxes.add(new Box(3));
        boxes.add(new Box(1));
        boxes.add(new Box(3));
        System.out.println(boxes);

        // TreeSet ならではのメソッド
        TreeSet<Integer> t = new TreeSet<>(List.of(10, 20, 30, 40, 50));
        System.out.println(t.first() + " " + t.last());
        System.out.println(t.floor(25) + " " + t.ceiling(25) + " " + t.lower(30) + " " + t.higher(30));
        System.out.println(t.headSet(30) + " " + t.tailSet(30) + " " + t.subSet(20, 40));
        System.out.println(t.descendingSet());
        System.out.println(t.pollFirst() + " " + t);

        // 降順で持つ
        TreeSet<String> rev = new TreeSet<>(Comparator.reverseOrder());
        rev.addAll(List.of("b", "a", "c"));
        System.out.println(rev);

        // 文字数だけで比べると、同じ長さの要素は重複扱いになる
        TreeSet<String> byLen = new TreeSet<>(Comparator.comparingInt(String::length));
        byLen.addAll(List.of("kiwi", "plum", "fig"));
        System.out.println(byLen);
    }
}

実行結果:

java.lang.ClassCastException
class TreeSetDemo$Box cannot be cast to class java.lang.Comparable (
[Box(1), Box(3)]
10 50
20 30 20 40
[10, 20] [30, 40, 50] [20, 30]
[50, 40, 30, 20, 10]
10 [20, 30, 40, 50]
[c, b, a]
[fig, kiwi]

例外メッセージの全文は class TreeSetDemo$Box cannot be cast to class java.lang.Comparable (TreeSetDemo$Box is in unnamed module of loader ...) と続きます。コンパイルは通り、最初の add で実行時に例外になるのが特徴です。直し方は2つあります。

直し方 書き方 向いている場面
Comparator を渡す new TreeSet<>(Comparator.comparingInt(b -> b.size)) クラスを変えられない、並び順を場面ごとに変えたい
クラスに Comparable を実装する class Box implements Comparable<Box> そのクラスに自然な順序が1つある

最終行の [fig, kiwi] も大事なポイントです。plum が消えたのは、TreeSet が equals ではなく比較結果(0 かどうか)で重複を判定するためです。「文字数で比べる」Comparator では kiwi と plum が同じ扱いになります。文字数順に並べつつ両方残したいときは、Comparator.comparingInt(String::length).thenComparing(Comparator.naturalOrder()) のように、最後まで区別がつく比較にします。Comparator の組み立て方は「Comparator で複数キーソート」の記事にまとめています。

Java 21のSequencedSet(getFirst・getLast・reversed)

Java 21 で SequencedSet というインターフェースが加わり、順序を持つ Set の先頭・末尾を共通のメソッドで扱えるようになりました。公式の API ドキュメントによると、実装しているのは LinkedHashSet・TreeSet・ConcurrentSkipListSet で、HashSet は対象外です。

import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.SequencedSet;
import java.util.TreeSet;

public class SeqDemo {
    public static void main(String[] args) {
        SequencedSet<String> seq = new LinkedHashSet<>(List.of("b", "c", "d"));
        System.out.println(seq.getFirst() + " " + seq.getLast());

        seq.addFirst("a");
        seq.addLast("e");
        System.out.println(seq);

        seq.addLast("a"); // 既にある要素は末尾へ移動する
        System.out.println(seq);
        System.out.println(seq.reversed());
        System.out.println(seq.removeFirst() + " " + seq.removeLast() + " " + seq);

        SequencedSet<Integer> ts = new TreeSet<>(List.of(3, 1, 2));
        System.out.println(ts.getFirst() + " " + ts.getLast() + " " + ts.reversed());
        try {
            ts.addFirst(0); // TreeSet は位置を指定して追加できない
        } catch (Exception e) {
            System.out.println(e);
        }
        try {
            new LinkedHashSet<String>().getFirst(); // 空の Set
        } catch (Exception e) {
            System.out.println(e);
        }
        System.out.println(new HashSet<>(List.of(1)) instanceof SequencedSet);
    }
}

実行結果:

b d
[a, b, c, d, e]
[b, c, d, e, a]
[a, e, d, c, b]
b a [c, d, e]
1 3 [3, 2, 1]
java.lang.UnsupportedOperationException
java.util.NoSuchElementException
false
メソッド LinkedHashSet TreeSet 補足
getFirst() / getLast() 使える 使える 空なら NoSuchElementException
removeFirst() / removeLast() 使える 使える 取り出して削除
addFirst(e) / addLast(e) 使える(既存なら移動) UnsupportedOperationException TreeSet の位置は比較で決まるため
reversed() 使える 使える 逆順のビュー。元を変えると反映される

Java 21 以降は、最後の要素も getLast() の1行で取れます。

containsの速度をListと比較(10万件で実測)

「存在チェックは List より Set が速い」と言われる理由を、数値で確認します。10万件のデータに対して contains を1万回呼びます。

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.HashSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Map;
import java.util.Random;
import java.util.Set;
import java.util.TreeSet;

public class SetBench {
    public static void main(String[] args) {
        int n = 100_000, q = 10_000;
        List<Integer> src = new ArrayList<>();
        for (int i = 0; i < n; i++) src.add(i);
        Collections.shuffle(src, new Random(1));

        List<Integer> al = new ArrayList<>(src);
        Set<Integer> hs = new HashSet<>(src), lhs = new LinkedHashSet<>(src), ts = new TreeSet<>(src);
        int[] qs = new Random(2).ints(q, 0, n * 2).toArray(); // 半分は存在しない値

        System.out.println(System.getProperty("java.version"));
        for (int round = 0; round < 3; round++) { // 3周して最後の周を表示
            for (var e : List.of(
                    Map.entry("ArrayList", (Collection<Integer>) al),
                    Map.entry("HashSet", (Collection<Integer>) hs),
                    Map.entry("LinkedHashSet", (Collection<Integer>) lhs),
                    Map.entry("TreeSet", (Collection<Integer>) ts))) {
                long t0 = System.nanoTime();
                int hit = 0;
                for (int v : qs) if (e.getValue().contains(v)) hit++;
                long us = (System.nanoTime() - t0) / 1000;
                if (round == 2) {
                    System.out.printf("%-14s contains x %,d : %,10.3f ms (hit=%d)%n",
                            e.getKey(), q, us / 1000.0, hit);
                }
            }
        }
    }
}

実行結果(OpenJDK 21.0.10、Linux コンテナ上での1回の計測値):

21.0.10
ArrayList      contains x 10,000 :  1,367.069 ms (hit=4987)
HashSet        contains x 10,000 :      2.105 ms (hit=4987)
LinkedHashSet  contains x 10,000 :      1.960 ms (hit=4987)
TreeSet        contains x 10,000 :      3.000 ms (hit=4987)

ArrayList が約1.4秒かかる処理が、Set では数ミリ秒で終わりました。 ArrayList の contains は先頭から順に比べる O(n) なので、件数に比例して時間が伸びます。HashSet はハッシュ値で場所を直接引く O(1)、TreeSet は木をたどる O(log n) です。

数値は計測のたびに揺れる簡易計測です。読み取ってほしいのは 「List と Set では桁が違う」 という点で、厳密に比べたいときは JMH のような計測用ツールを使います。

ループの中で「含まれるか」を何度も調べるなら Set に入れてから contains、数件を1回調べるだけなら List のままで十分です。List・Set・Map 全体の選び方は「Java List Set Map 使い分け」の記事で扱っているので、ここでは contains に絞りました。

用途別:どのSetを選ぶかの判断表

やりたいこと 選ぶ実装 理由
存在チェック・重複削除(順序は不要) HashSet 最も軽く速い
重複を除きつつ、追加した順で表示したい LinkedHashSet 追加順を保つ
常にソートされた状態で持ちたい TreeSet add のたびに正しい位置に入る
「〜以上」「〜未満」で取り出したい TreeSet headSet / tailSet / floor がある
定数として宣言したい(変更しない) Set.of(...) 変更できず、意図が伝わる
enum の集まり EnumSet.of(...) enum 専用で省メモリ・高速
複数スレッドから同時に追加する ConcurrentHashMap.newKeySet() スレッドセーフ

EnumSet は追加した順ではなく enum の宣言順で並びます(EnumSet.of(Day.WED, Day.MON) の表示は [MON, WED] でした)。

つまずきやすい点と対処のまとめ

起きること 原因 対処
自作クラスの重複が消えない equals/hashCode を書いていない 両方をオーバーライドする。または record にする
入っているのに contains が false 追加後に要素の中身を変えた 変更されない値を要素にする
表示順が毎回違う・環境で違う HashSet や Set.of の順序に頼っている LinkedHashSet か TreeSet を使う
IllegalArgumentException: duplicate element Set.of に重複した値を渡した new HashSet<>(list) か Set.copyOf(list)
UnsupportedOperationException Set.of などの変更できない Set を変更した new HashSet<>(set) にコピーしてから変更
ClassCastException ... Comparable TreeSet に比較できないクラスを入れた Comparator を渡すか Comparable を実装する
TreeSet で要素が減った Comparator が 0 を返す要素は重複扱い thenComparing で最後まで区別する
NullPointerException TreeSet / Set.of に null null を除いてから入れる。null が必要なら HashSet
ConcurrentModificationException(実行して確認) 拡張 for 文の中で set.remove(n) を呼んだ set.removeIf(n -> n % 2 == 0) か Iterator.remove()
set.get(0) が書けない Set には番号がない getFirst()(Java 21)、または List に変換

Java 25との差分

同じコードを JDK 25.0.4.1 でも実行しました。この記事の出力は Java 21 と Java 25 ですべて一致し、違いは次の2点だけでした。

  • ClassCastException のメッセージに含まれるクラスローダー名(Java 21 は ...launcher.Main$MemoryClassLoader、Java 25 は ...launcher.MemoryClassLoader)。単一ファイル実行のしくみに由来する部分で、Set の動きとは関係ありません。
  • contains の計測値(Java 25 では ArrayList 868.595 ms / HashSet 2.573 ms / LinkedHashSet 1.364 ms / TreeSet 3.227 ms)。1回ずつの簡易計測なので、「どちらの版でも List と Set で桁が違う」と読むのが適切です。

SequencedSet は Java 21 で加わった機能なので、Java 17 以前では getFirst() などは使えません。

よくある質問

Q. HashSet の順序は毎回同じに見えます。順序に頼ってもよいですか? A. 同じデータ・同じ JDK なら同じ順で出ることが多いですが、仕様として保証されていません。容量の変化や JDK の版で変わりうるので、順序が必要なら LinkedHashSet(追加順)か TreeSet(ソート順)を選びます。

Q. Set から1件だけ取り出すにはどうしますか? A. Java 21 以降で LinkedHashSet / TreeSet なら getFirst() が使えます。HashSet の場合は set.iterator().next() や set.stream().findFirst() で「どれか1件」を取れます。番号で取りたいときは new ArrayList<>(set) で List に変換します。

Q. List の重複削除は distinct() と LinkedHashSet のどちらがよいですか? A. 結果はどちらも「最初に出た順」で同じです。Stream の処理の途中なら distinct()、List をそのまま変換するだけなら new ArrayList<>(new LinkedHashSet<>(list)) が読みやすいです。toList() の結果は変更できないので、あとで追加・削除するなら後者を選びます。

まとめ

  • HashSet は順序なしで最速、LinkedHashSet は追加順、TreeSet はソート順。変数は Set<T> で受け、実装クラスで性質を選ぶ
  • List の重複削除は、順序維持なら LinkedHashSet、ソートなら TreeSet、Stream の途中なら distinct()
  • 集合演算(addAll / retainAll / removeAll)は呼び出した側を書き換えるので、コピーしてから使う
  • 自作クラスは equals と hashCode の両方が必要。record なら自動で用意される
  • Set.of は重複で IllegalArgumentException、変更で UnsupportedOperationException、null で NullPointerException
  • TreeSet は Comparable か Comparator が必要で、比較結果が 0 の要素は重複として扱われる
  • Java 21 の SequencedSet で getFirst() / getLast() / reversed() が使える(LinkedHashSet・TreeSet)。contains を繰り返すなら List より Set が桁違いに速い

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

(記載例)出典:株式会社RJC「Java HashSetの使い方|重複削除とTreeSetとの違い」

株式会社RJCでは、Javaのコレクションの使い分けを仲間と学び合っています。こうした知識を共有しながら、日々技術を磨いています。

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