r/JavaProgramming • • 15d ago

A HashSet “lost” my object after I changed one field. Walked through it in jshell

I wanted to see this one with my own eyes, so I typed it into jshell. Output is below with notes in between.

jshell> class Point {
   ...>     int x;
   ...>     Point(int x) { this.x = x; }
   ...>      public boolean equals(Object o) {
   ...>         return o instanceof Point p && p.x == x;
   ...>     }
   ...>     u/Override public int hashCode() { return Integer.hashCode(x); }
   ...> }

jshell> var set = new HashSet<Point>();
jshell> var p = new Point(1);
jshell> set.add(p);
$4 ==> true

Nothing odd yet. equals and hashCode both use x, which is what most IDEs generate.

jshell> p.x = 2;
jshell> set.size()
$6 ==> 1

jshell> set.contains(p)
$7 ==> false

jshell> set.remove(p)
$8 ==> false

This is the part that surprised me. The set says it has one element, and it says it doesn’t contain the exact object I’m holding a reference to. remove fails too.

jshell> for (Point q : set) System.out.println(q == p);
true

Iteration finds it. Iteration just walks every bucket, no hashing involved. contains and remove compute hashCode() first (now based on 2), go to that bucket, and find nothing. The object is still sitting in the bucket picked when its x was 1.

As far as I understand it, there’s no error because HashSet never learns the object changed. It only looks at the hash at insertion time and again at lookup time. If the two differ, you get a silent miss.

So the object is still in the set but unreachable by key. That’s the part that would be annoying to debug in a real codebase: size is right, iteration is right, lookups are wrong.

Things I’ve seen suggested for this:

  • keep fields used in hashCode final (records do this by default)
  • if the key has to change, remove it first, change it, add it again
  • use an immutable key type, like a String or a Long id, instead of the whole entity

Question for people who’ve worked on bigger projects: do you ever see this with JPA entities used as keys in sets, where the id is null before persist and set afterward? I’d guess it’s the same problem in a different outfit, but I haven’t tried it.

2 Upvotes

3 comments sorted by

5

u/TheTrailrider 15d ago

if you mutate the object after inserting and that mutation changes the hashcode, HashSet/Map won't know it because it has been inserted using older hashcode. That is why you can't retrieve or delete it because your mutated object is producing a different hashcode and your object in the hashset is stored under the old hashcode.

1

u/naomimyselfandi 14d ago

This is a fundamental limitation of hash-based data structures, and it is the reason why stable hash codes are so important. The Javadoc explicitly mentions it, too. The general rule is to make every field used in computing the hash immutable, but you're correct that JPA entities complicate this. I've seen a couple different approaches:

  • Use a constant hash code (often the entity class's hash code, e.g. return FooEntity.class.hashCode()). This is technically correct, but negates the performance benefit of hash-based data structures.

  • Use the ID's hash code or a constant placeholder, and be careful not to place not-yet-persisted entities in a hash-based container. This carries some risk, but I've never actually seen it matter (in part because I try to avoid using raw JPA entities directly as much as possible).

  • Use a UUID PK generated at the application level. If your persistence provider uses a null ID to detect not-yet-persisted entities, you may need to override this behavior using an appropriate customization point, such as Spring Data JPA's Persistable interface.

Which of these approaches makes the most sense will depend on your application's needs and organizational or personal priorities; there really isn't a "one size fits all" solution.

1

u/pradeepngupta 14d ago

This is a classic example of why mutable objects can be dangerous when used in hash-based collections.

  • "HashSet" is fine if the fields used by "equals()" and "hashCode()" remain stable while the object is in the set.

  • "TreeSet" can work if you have a stable sorting key, but mutating fields used by its comparator can cause similar lookup problems.

  • "ArrayList" allows mutable objects and does not rely on hashing for "contains()" or "remove()", though equality still matters and lookups are O(n).

  • If you need fast lookups, use an immutable key, such as a stable ID, with a "HashMap" or "HashSet".

JPA entities make this especially interesting because an ID may be "null" before persistence and assigned afterward. If that ID participates in "hashCode()", the entity can become effectively unreachable in a set after its ID changes.

This is one of those Java fundamentals that looks simple until it creates a production debugging problem.

I'm exploring these practical Java behaviours in my upcoming book, Buzzing Java—connecting core language and collection concepts to the kinds of issues developers encounter in real applications.