JAVA / COLLECTIONS
HashSet, ordering guarantees and duplicate rules
Predict exactly which additions a HashSet rejects, keep equals and hashCode in sync, and stop depending on the order a HashSet hands elements back.
What you will learn
- Predict which add calls HashSet rejects and read the boolean it returns
- Keep hashCode consistent with equals so equal elements can never both be stored
- Explain why HashSet iteration order is unspecified and shifts when capacity grows
- Spot elements made unreachable by mutating a field used in hashCode
Understanding HashSet, ordering guarantees and duplicate rules
HashSet's contract covers membership only: it answers "is this element in here" in near constant time and says nothing about arrangement. The mental model is an array of slots, where each element is filed into the slot picked from its hash code, and iteration walks those slots from index 0 upward, emitting whatever it finds. So the order you observe is a by-product of hash values and the array's current length, not of insertion and not of natural ordering, and it changes as soon as the array grows or the element set differs. That order is reproducible on a given run and a given JDK, which is precisely why people mistake it for a promise.
A duplicate is not "the same object" and not "prints the same text"; it is an object for which equals returns true against something already in the set. HashSet only calls equals on candidates filed in the same slot, and the slot comes from hashCode, so two equal objects with different hash codes are usually never compared and both end up stored. That is the whole reason hashCode must agree with equals: equal objects must produce equal hash codes, and that is the direction that matters. add tells you the outcome, returning true when the set changed and false when an equal element was already present, in which case the newcomer is discarded and the first instance stays.
The hash is read once, at insert time, and the set never refiles an element on its own. Mutate a field that hashCode uses and the element is stranded in the slot for its old hash: contains and remove compute the new slot, find it empty, and report the element as absent while it sits in the set forever, which is how caches leak and dedup silently doubles up. Keep set elements immutable, with final fields or a record, and if any behaviour depends on the order elements come out, pick an implementation that specifies one (LinkedHashSet, TreeSet) rather than hoping HashSet preserves yours.
import java.util.HashSet;
import java.util.Set;
public class HashSetDuplicates {
static final class Tag {
private final String name;
private final int addedBy;
Tag(String name, int addedBy) {
this.name = name;
this.addedBy = addedBy;
}
@Override
public boolean equals(Object o) {
return o instanceof Tag other && name.equals(other.name);
}
@Override
public int hashCode() {
return name.hashCode();
}
@Override
public String toString() {
return name + "/user" + addedBy;
}
}
public static void main(String[] args) {
Set<Tag> tags = new HashSet<>();
System.out.println(tags.add(new Tag("java", 7)));
System.out.println(tags.add(new Tag("java", 42)));
System.out.println(tags.contains(new Tag("java", 999)));
System.out.println(tags.size());
System.out.println(tags);
}
}
Membership in a HashSet is decided by hashCode plus equals, and the price of that lookup speed is that no iteration order is promised.
Worked examples
The near-sorted illusion
Shows that HashSet output order comes from hash-to-slot placement, not from insertion or from sorting.
import java.util.HashSet;
import java.util.Set;
public class HashSetOrder {
public static void main(String[] args) {
Set<Integer> ids = new HashSet<>();
for (int id : new int[] {5, 3, 20, 17, 1}) {
ids.add(id);
}
System.out.println(ids);
System.out.println(ids.equals(Set.of(1, 3, 5, 17, 20)));
}
}
Example explained
Line 1For small non-negative Integers the hash is the value itself, so each one is filed in slot value & 15 of the default 16-slot table, which makes the output look almost sorted.
Line 217 and 1 share slot 1 and therefore appear together, 17 first because it was inserted first; that pair is what breaks the "HashSet sorts" illusion.
Line 35 went in first and prints last, because iteration walks slots by index and slot 5 is the highest occupied one.
Line 4The equals check is true regardless of the differing iteration orders: set equality means "same elements", so order plays no part in it.
An element you can no longer find
Demonstrates what happens when a field used by hashCode changes after the element is already in the set.
import java.util.HashSet;
import java.util.Set;
public class MutatedElement {
static final class Box {
int value;
Box(int value) {
this.value = value;
}
@Override
public boolean equals(Object o) {
return o instanceof Box other && value == other.value;
}
@Override
public int hashCode() {
return value;
}
@Override
public String toString() {
return "Box(" + value + ")";
}
}
public static void main(String[] args) {
Box box = new Box(1);
Set<Box> boxes = new HashSet<>();
boxes.add(box);
System.out.println(boxes.contains(box));
box.value = 2;
System.out.println(boxes.contains(box));
System.out.println(boxes.remove(box));
System.out.println(boxes.add(new Box(2)));
System.out.println(boxes);
}
}
Example explained
Line 1The slot was chosen from hashCode() == 1 at insert time, and the set never revisits that decision afterwards.
Line 2After box.value = 2 the lookup hashes to slot 2, finds it empty, and reports the element as absent even though the very same reference is inside the set.
Line 3remove(box) fails for the identical reason, so a mutated element can no longer be taken out through its own reference.
Line 4The final line prints two equal elements in one Set: the no-duplicates rule only holds while hash codes stay put.
add as the duplicate test
Uses the boolean returned by add to find the first repeated word in a single pass.
import java.util.HashSet;
import java.util.Set;
public class FirstRepeat {
public static void main(String[] args) {
String[] words = {"the", "cat", "sat", "on", "the", "mat"};
Set<String> seen = new HashSet<>();
for (String word : words) {
if (!seen.add(word)) {
System.out.println("first repeat: " + word);
break;
}
}
System.out.println("distinct words before it: " + seen.size());
}
}
Example explained
Line 1add returns false only when an equal element was already present, so the failed insert is itself the duplicate check.
Line 2A failed add leaves the set untouched, which is why size() still reports the four words seen earlier.
Line 3One call hashes and probes once; contains(word) followed by add(word) would do that work twice for every word.
Line 4String's equals and hashCode are value based, so a "the" built at runtime would be rejected too, not just the repeated literal.
Important notes
HashSet accepts a single null element and contains(null) is legal; what breaks is a hashCode() that dereferences a possibly-null field, so build hashes with Objects.hash and compare with Objects.equals.
Iteration order depends on insertion history as well as on the elements, because elements sharing a slot come out in the order they were added, so two sets that are equal to each other can still iterate differently.
Common mistakes
Overriding equals and forgetting hashCode: the two equal objects land in different slots, so size() reports 2 and contains(a fresh copy) returns false, and the deduplication silently does nothing.
Treating a first run's near-sorted output of small Integers or short Strings as a guarantee: one collision or one resize rearranges it, so the ordering-dependent code only fails once real data shows up.
Calling add with a fresher copy of an element to refresh it: add returns false, the stale instance is kept and the new one thrown away, so you have to remove() before add().
Try it yourself
Change, predict, then run
Write a Product class with fields sku and price whose equals treats two Products as the same when the sku matches, then add Product("A1", 10) and Product("A1", 99) to a HashSet and print size() plus the set. Run it once with hashCode() returning sku.hashCode() and once with no hashCode override, and account for the two different sizes.
Open the Java workspaceCheck your understanding
You add the integers 1, 17 and 3 to a HashSet<Integer> in that order and print it, and the output is [1, 17, 3]. What does that output entitle you to rely on?
- Nothing: the order fell out of hash-to-slot placement for these values and this table size, and no part of HashSet's contract promises it
- That HashSet preserves insertion order as long as you never remove an element
- That HashSet iterates numbers in ascending order because Integer implements Comparable
- That the order is randomized per JVM run, so the same program may well print a different order tomorrow
Show answer
1 and 17 both hash into slot 1 and come out in the order they were added, while 3 sits in a later slot, so the printed order is a coincidence of these hash values and the current table length; adding a value like 33, or enough elements to grow the table, rearranges it. Option 3 is the tempting wrong answer: unspecified is not random, and with value-based hash codes like Integer's the order is perfectly reproducible from run to run, which is exactly why the illusion of a guarantee survives testing. Option 2 also fails because HashSet never calls compareTo at all.