What goes wrong when you put mutable objects into a HashSet and then change them, and how do you avoid it?
answer
- hashCode fixed at insertion -> element stuck in old bucket
- Mutate equality field => contains/remove miss it
- Symptoms: lost element, leak, logical duplicates
- Fix: immutable elements / records
- Else remove -> mutate -> re-add
basics
~20 sIf you change a field that affects an element's hashCode/equals after adding it, the HashSet may no longer find it — contains and remove can fail even though the object is still inside. Use immutable elements.
solid answer
~50 sA HashSet places each element in a bucket chosen by its hashCode at insertion time. If you later mutate a field that participates in hashCode/equals, the element's hash changes, but it physically stays in the old bucket. Now contains(x) and remove(x) compute the new hash, look in the new bucket, and miss it — so the element becomes effectively unreachable yet still occupies memory (a subtle leak), and the set can even appear to hold logical duplicates. The fix is to use immutable objects as Set elements, or at least never mutate the equality-defining fields after insertion. Records and final value classes are ideal. If you genuinely must change such state, remove the element first, mutate it, then re-add it so it lands in the correct bucket. This is the same reasoning behind not using mutable keys in a HashMap.
code
java · 7 lines// Safe pattern when an equality field must change:
set.remove(person); // pull it out of its current bucket
person.setName("Bob"); // now mutate the equality-defining field
set.add(person); // re-insert -> hashed into the correct bucket
// Better: make the element immutable so this can never happen
record Person(String name) {} // name is final; hashCode/equals are stablego deeper
Knows you should use immutable objects in a HashSet and that changing elements can cause weird behavior.
Explains that hashCode is fixed at insertion, that mutating an equality field strands the element in the wrong bucket, and names the remove-mutate-re-add fix.
Connects the failure to the hashCode consistency clause of the contract, distinguishes equality vs non-equality field mutation, and generalizes to HashMap keys and comparator-based sets.
Drives immutability as a design policy for collection elements/keys, anticipates the leak/duplicate failure in long-lived shared state, and weighs defensive copying vs immutable value types at API boundaries.
## How a HashSet stores an element When you `add(x)`, `HashSet` computes `x.hashCode()` and uses it to choose a **bucket** — one slot in an internal array. The element is stored there. `contains`/`remove` later recompute the hash to jump straight to the right bucket; that's what makes them fast. ## The trap Suppose your element's `hashCode` (and `equals`) depend on a field, say `name`: ```java class Person { String name; /* hashCode/equals use name */ } Set<Person> set = new HashSet<>(); Person p = new Person("Ann"); set.add(p); // hashed by "Ann" -> bucket A p.name = "Bob"; // hash now corresponds to bucket B, but p is physically in bucket A set.contains(p); // false! looks in bucket B, finds nothing set.remove(p); // fails to remove; p stays in bucket A ``` The object is **still inside** the set (iterate and you'll see it), but **lookup goes to the wrong bucket**, so the set behaves as if it isn't there. ## The consequences - **Lost lookups**: `contains`/`remove` return false/no-op for an element that is physically present. - **Memory leak**: the unreachable-by-key element lingers; in long-lived sets this accumulates. - **Apparent duplicates**: you can `add` a *new* `Person("Bob")` and it goes to bucket B, so the set now contains two people both named "Bob" — a logical duplicate, violating the Set's contract. - **Non-deterministic bugs**: depending on resize/rehash timing, behavior can vary, making these hard to reproduce. ## Why this is fundamental, not a bug Hash-based collections **cache placement by hash at insertion**. The contract ("`hashCode` must be consistent while equals-relevant state is unchanged") is precisely what mutation breaks. The collection has no way to know an element silently changed. ## How to avoid it 1. **Prefer immutable elements** — the cleanest fix. Use `final` fields, value classes, or `record`s whose components never change. If the object can't change, its hash can't change. 2. **Never mutate equality fields after insertion** — if a field isn't part of `equals`/`hashCode`, mutating it is safe. 3. **Remove-mutate-re-add** — if you truly must change an equality field: `set.remove(x); x.mutate(); set.add(x);` so it is re-bucketed correctly. 4. **Same rule for HashMap keys** — mutable keys cause the identical failure; this is why immutable keys (`String`, boxed numbers, records) are recommended. ## Note on TreeSet The analog applies to ordered sets: if a field used by the `Comparator`/`compareTo` changes after insertion, `TreeSet` invariants break and lookups/iteration can behave incorrectly. Immutability is the safe default across all Set/Map types.
- Is it ever safe to mutate an object that is inside a HashSet?Yes — if you only change fields that are NOT used by equals() or hashCode(). Those mutations don't affect the bucket or equality, so lookups keep working. Only equality-relevant state is dangerous to change after insertion.
- Does the same problem affect HashMap?Yes, identically — for HashMap keys. A key whose hashCode/equals fields are mutated after put() ends up in the wrong bucket, so get()/remove() miss it. This is the main reason immutable keys (String, records, boxed numbers) are the standard recommendation.
It's like filing a folder under 'Ann' then renaming the folder to 'Bob' without re-filing it. Search 'Bob' and you find nothing — the folder is still in the 'Ann' drawer.
saying these in an interview costs you the question
- Claiming HashSet re-buckets elements automatically when they change — it cannot detect silent mutation.
- Saying iteration will also miss the element — iteration walks all buckets and still sees it; only hash lookups (contains/remove) fail.
- Assuming any mutation is dangerous — only changes to equals/hashCode fields cause the problem.
- Believing TreeSet is immune — mutating comparator-relevant fields breaks it too.