Skip to content
CalliCoder

Java HashSet Tutorial with Examples

Published Updated Java 11 min read

Set operations, the three implementations and when each is right — plus why a HashSet of your own type silently holds duplicates, and what happens to an element you mutate after adding it.

A Set holds no duplicates. HashSet implements that with a HashMap underneath. Each element is a key, all sharing one dummy value, which is worth knowing because everything that makes a HashMap key work or fail applies here identically.

Written against Java 17.

Creating one

Set<String> tags = new HashSet<>();
Set<String> sized = new HashSet<>(1000);                 // capacity hint
Set<String> copy = new HashSet<>(existingCollection);    // deduplicates on the way in

Set<String> fixed = Set.of("a", "b", "c");               // immutable, rejects null
Set<String> fromList = List.of("a", "b", "a").stream()
        .collect(Collectors.toSet());                    // {a, b}

new HashSet<>(collection) is the idiomatic deduplication: pass a list, get its distinct elements. It discards order, which is either fine or the reason to use LinkedHashSet.

Set.of is immutable and rejects nulls, and unlike List.of it also throws on duplicate arguments:

Set.of("a", "a");     // IllegalArgumentException: duplicate element: a

Deliberate: a duplicate in a literal is almost always a mistake rather than something to silently collapse.

The operations that make it a set

Set<String> a = new HashSet<>(List.of("x", "y", "z"));
Set<String> b = new HashSet<>(List.of("y", "z", "w"));

boolean added = a.add("x");        // false — already present, set unchanged
a.contains("y");                   // true, O(1)
a.remove("z");                     // true if it was there

add returning a boolean is more useful than it looks. It is an atomic test-and-insert, so if (seen.add(item)) processes each distinct item exactly once without a separate contains check.

The bulk operations are the set algebra, and they mutate the receiver:

Set<String> union = new HashSet<>(a);
union.addAll(b);                   // {x, y, z, w}

Set<String> intersection = new HashSet<>(a);
intersection.retainAll(b);         // {y, z}

Set<String> difference = new HashSet<>(a);
difference.removeAll(b);           // {x}

a.containsAll(b);                  // is b a subset of a?

Copy first unless you mean to modify the original. a.retainAll(b) changes a in place and returns a boolean, not the intersection: assigning Set<String> i = a.retainAll(b) does not compile, which at least fails loudly. a.addAll(b) silently modifying a when you wanted a union is the one that gets through review.

The contract, and where it breaks

A HashSet finds an element by hashing it, then comparing with equals inside the bucket. So a custom element type needs both methods, consistent with each other:

public record Tag(String name) { }              // both generated — use this

Without hashCode, two equal objects hash to different buckets and both are stored:

class Broken {
    final String name;
    Broken(String name) { this.name = name; }
    @Override public boolean equals(Object o) {
        return o instanceof Broken b && name.equals(b.name);
    }
    // no hashCode
}

Set<Broken> set = new HashSet<>();
set.add(new Broken("a"));
set.add(new Broken("a"));
set.size();                 // 2 — a Set containing a duplicate
set.contains(new Broken("a"));  // false, usually

A set with two equal elements, and contains that cannot find either. Nothing throws. This is the same failure as a HashMap key with a broken contract, described in more detail in the HashMap guide, and it is why a record is the right default for a set element.

A mutated element becomes unreachable

Set<List<String>> set = new HashSet<>();
List<String> key = new ArrayList<>(List.of("a"));
set.add(key);

key.add("b");                // the element's hashCode just changed

set.contains(key);           // false
set.remove(key);             // false — cannot remove it either
set.size();                  // 1 — it is still in there

The element was filed under its old hash and nothing rehashes on mutation. It is present, counted, and unreachable: you cannot even remove it except by clearing the set or iterating to find it.

Set elements must be effectively immutable. Strings, boxed primitives, enums, records of immutable components. Never a collection, and never an entity whose hashCode depends on a field that changes after insertion.

Iteration and removal

for (String t : tags) { ... }
tags.forEach(System.out::println);

Order is unspecified and is not insertion order. It is stable for an unchanged set within one run, and depends on hash values and capacity, so it can change when the set grows, and between JVM versions.

Modifying during a for-each throws ConcurrentModificationException. Use removeIf:

tags.removeIf(t -> t.startsWith("draft-"));

Choosing an implementation

OrderNullLookupUse when
HashSetnoneone allowedO(1)the default
LinkedHashSetinsertionone allowedO(1)order must be reproducible
TreeSetsortednoneO(log n)you need sorted or range access
EnumSetenum ordinalnoneO(1)elements are enum constants

LinkedHashSet costs a little memory for a linked list through the entries and gives deterministic iteration. Worth defaulting to whenever a set’s contents end up in output a human reads or a test asserts on — an unstable order makes a test flaky in a way that looks like a real bug.

TreeSet sorts by natural order or a comparator, and adds the navigation methods:

TreeSet<Integer> t = new TreeSet<>(List.of(10, 20, 30, 40));
t.first();          // 10
t.ceiling(25);      // 30 — smallest element >= 25
t.headSet(30);      // [10, 20]
t.subSet(15, 35);   // [20, 30]
t.descendingSet();  // [40, 30, 20, 10]

It carries the trap that comes with every sorted collection: TreeSet decides equality by comparison, not by equals. Anything your comparator calls equal is one element:

Set<String> ci = new TreeSet<>(String.CASE_INSENSITIVE_ORDER);
ci.add("Java");
ci.add("java");
ci.size();          // 1 — the second was silently dropped

That is sometimes exactly what you want — a case-insensitive set in one line — and sometimes silent data loss. It is the same mechanism described under Comparable and Comparator, where an inconsistent compareTo loses elements.

EnumSet is the one most people never reach for. For enum elements it is a bit vector — one or two longs — so it is dramatically faster and smaller than a HashSet:

EnumSet<Day> weekend = EnumSet.of(Day.SATURDAY, Day.SUNDAY);
EnumSet<Day> weekdays = EnumSet.complementOf(weekend);
EnumSet<Day> all = EnumSet.allOf(Day.class);
EnumSet<Day> none = EnumSet.noneOf(Day.class);

contains is a bit test. If your elements are enum constants, this is the right type.

Sets from streams

Set<String> distinct = notes.stream()
        .map(Note::category)
        .collect(Collectors.toSet());                        // HashSet, unspecified type

Set<String> ordered = notes.stream()
        .map(Note::category)
        .collect(Collectors.toCollection(LinkedHashSet::new)); // order preserved

Set<String> immutable = notes.stream()
        .map(Note::category)
        .collect(Collectors.toUnmodifiableSet());

Collectors.toSet() makes no guarantee about which Set implementation you get, or that it is mutable. Adding to the result happens to work today and is not promised. When the type matters, say so with toCollection.

Thread safety

HashSet is not synchronised.

Set<String> sync = Collections.synchronizedSet(new HashSet<>());
Set<String> conc = ConcurrentHashMap.newKeySet();
Set<String> cow  = new CopyOnWriteArraySet<>();

ConcurrentHashMap.newKeySet() is the concurrent set — there is no ConcurrentHashSet class, which is why people do not find it. It has the same per-bin locking and weakly consistent iterators as the map it wraps, and add remains an atomic test-and-insert, which is what you usually want.

CopyOnWriteArraySet copies on every write and its contains is O(n) — right only for a small set read constantly and written almost never, such as a listener registry.

Frequently asked questions

Why does my HashSet contain duplicates?

The element type implements equals without hashCode, so equal objects land in different buckets. Implement both, or use a record.

Why can I not find or remove an element I definitely added?

It was mutated after insertion, so its hash changed and it is filed under the old one. Set elements must be effectively immutable.

Is iteration order guaranteed?

No. It depends on hash values and capacity and can change as the set grows. Use LinkedHashSet for insertion order or TreeSet for sorted order.

How do I compute a union or intersection?

addAll and retainAll — but they modify the receiver. Copy the set first: new HashSet<>(a) then retainAll(b).

What does add() returning false mean?

The element was already present and the set is unchanged. if (seen.add(x)) is an atomic test-and-insert, cheaper than contains followed by add.

Why did my TreeSet lose an element?

It defines equality by the comparator, not equals. Anything comparing equal is one element — useful for a case-insensitive set, silent loss otherwise.

Can a HashSet contain null?

One, yes. TreeSet and EnumSet reject null, and Set.of rejects both null and duplicate arguments.

When should I use EnumSet?

Whenever the elements are enum constants. It is a bit vector — far faster and smaller than a HashSet.

Where is ConcurrentHashSet?

There is no such class. Use ConcurrentHashMap.newKeySet(), or CopyOnWriteArraySet for read-heavy, write-rare cases.

Is Collectors.toSet() a HashSet?

Unspecified. Neither the implementation nor mutability is guaranteed. Use toCollection(LinkedHashSet::new) when it matters.

Where should I go next?

HashMap covers the structure underneath and the equals/hashCode contract in depth, and ArrayList covers the ordered collection.