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
| Order | Null | Lookup | Use when | |
|---|---|---|---|---|
HashSet | none | one allowed | O(1) | the default |
LinkedHashSet | insertion | one allowed | O(1) | order must be reproducible |
TreeSet | sorted | none | O(log n) | you need sorted or range access |
EnumSet | enum ordinal | none | O(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.