Why can mutating a field used in hashCode() make an object disappear from a HashSet, given the consistency clause?
answer
- Bucket chosen at insert from the THEN hashCode
- Mutate field -> new code -> wrong bucket -> 'gone'
- Stranded: iterable but not contains/remove-able
- Fix: immutable keys / final fields
- Or remove, mutate, re-insert
basics
~20 sHashSet remembers the bucket it put the object in, chosen from the hashCode at insert time. If you change a field that hashCode uses, the new hashCode points to a different bucket — so contains() looks in the wrong place and the object seems gone.
solid answer
~50 sThe consistency clause says hashCode must be stable *only while the equals-relevant fields are unchanged*. A HashSet computes the key's hashCode once, at insertion, to choose a bucket. If you then mutate a field that hashCode derives from, the object now hashes to a *different* bucket, but it physically still sits in the old one. A subsequent contains() or remove() computes the new hashCode, probes the new bucket, finds nothing, and reports the element absent — even though iterating the set still reveals it. The object is effectively stranded. This is why mutable fields should be kept out of hashCode, and why immutable keys are strongly preferred. If a key must change, remove it from the collection first, mutate, then re-insert. Records and immutable value classes sidestep the hazard entirely because their hashCode inputs cannot change after construction.
code
java · 17 linesimport java.util.*;
class Tag { // BAD: mutable field used in hashCode
String name;
Tag(String n) { name = n; }
@Override public boolean equals(Object o) {
return o instanceof Tag t && Objects.equals(name, t.name);
}
@Override public int hashCode() { return Objects.hashCode(name); }
}
var set = new HashSet<Tag>();
var t = new Tag("red");
set.add(t); // filed in bucket for hash("red")
t.name = "blue"; // mutate -> hashCode now differs
System.out.println(set.contains(t)); // false (probes the 'blue' bucket)
System.out.println(set.iterator().next() == t); // true (still physically present)go deeper
Recognizes that changing a field can make a key 'lost' in a HashSet and that immutable keys avoid the problem.
Explains the bucket-chosen-at-insert mechanism and the stranded-element symptom (iterable but not findable); knows the remove-mutate-reinsert and immutable-key fixes.
Stresses that no contract is violated — only the collection's permanence assumption — and prescribes making hash-relevant fields final; notes the parallel hazard in TreeSet ordering and the duplicate-entry consequence.
Sets a codebase policy that hash/sort keys be effectively immutable, weighs excluding mutable fields vs. defensive copying, and considers the API design where value objects double as keys.
## The setup A `HashSet` (and `HashMap`) is backed by an **array of buckets**. To store a key it: (1) calls `key.hashCode()`, (2) mixes/masks that int into a **bucket index** `i = hash & (table.length - 1)`, and (3) places the entry in bucket `i`. Crucially, the entry **physically stays in bucket `i`** until the entry is removed or the table is resized — the collection does *not* continuously re-file entries. ## The consistency clause, read carefully The hashCode contract requires consistency *"provided no information used in equals comparisons on the object is modified."* This is a conditional guarantee. The moment you mutate a field that `equals`/`hashCode` reads, you are *permitted* — and a correctly written hashCode is *expected* — to return a different int. So no rule is broken by the object itself. The breakage happens at the **collection** level, which assumed the bucket assignment was permanent. ## The failure step by step ``` Set<Tag> set = new HashSet<>(); Tag t = new Tag("red"); // hashCode derived from name set.add(t); // hashCode("red") -> bucket 3, stored in bucket 3 t.setName("blue"); // mutate the hashCode-relevant field set.contains(t); // hashCode("blue") -> bucket 7; bucket 7 is empty -> false! ``` `contains(t)` recomputes the hashCode from the *new* state, lands in bucket 7, scans it, finds nothing, and returns `false`. Yet `for (Tag x : set)` still yields `t`, because iteration walks the underlying array and bucket 3 still holds it. The element is **stranded**: present but unreachable by lookup, and often un-removable by `remove(t)` for the same reason. This can also create a **duplicate**: re-adding `t` may succeed because the set looks in bucket 7 and sees no equal element. ## Why this is a key-hazard, not a hashCode bug The hashCode implementation is *correct* — it faithfully reflects current state. The defect is **using a mutable object as a hash key**. The general rule: an object's hash-relevant state must remain frozen for as long as it lives in a hash collection. ## Remedies 1. **Prefer immutable keys.** Make hash-relevant fields `final` and set them only in the constructor — then they can never change while the key is in the set. Records and well-built value classes do this for you. 2. **Exclude mutable fields from hashCode/equals.** If a field can change and is not part of the object's identity, leave it out of both methods. (Keep equals and hashCode in lockstep on whichever fields you do use.) 3. **Remove-mutate-reinsert.** If you genuinely must change a key, `remove` it from the collection, mutate, then `add` it back so it is re-filed into the correct bucket. ## Related contrast This is distinct from a `TreeSet`/`TreeMap`, which order by `compareTo`/`Comparator` rather than hashing — but the same hazard exists there too: mutating a field used in the ordering corrupts the tree's invariants. The unifying principle is *keys must be effectively immutable with respect to whatever the collection uses to locate them*.
- Does this violate the hashCode contract?No. The contract only requires consistency while equals-relevant fields are unchanged; once you mutate such a field, returning a different code is allowed and expected. The bug is using a mutable object as a hash key, not the hashCode method itself.
- How do records help here?A record's components are final, so its generated hashCode inputs cannot change after construction. As long as the components themselves are immutable, a record is a safe hash key by design.
It's like filing a folder in a drawer chosen by its label, then relabeling the folder without moving it. Next time you go to the drawer the new label points to, the folder isn't there — yet flipping through every drawer would still find it sitting under the old label.
saying these in an interview costs you the question
- Saying mutation 'breaks the hashCode contract' (it doesn't — the collection's assumption breaks)
- Claiming HashSet re-files entries automatically when state changes
- Thinking the object is truly deleted rather than stranded
- Including volatile/mutable fields in hashCode without realizing the key hazard