Skip to content
Back to blog
7 min read

equals, hashCode, and the Entity That Vanished From a HashSet

JavaJPAHibernateCollections

You put an object in a HashSet. You save it. You ask the set whether it contains that same object, holding the same reference, and it says no.

Set<Product> products = new HashSet<>();
Product p = new Product("Blue shirt");

products.add(p);
productRepository.save(p);

products.contains(p); // false

Nothing was removed. The object is still in there. The set just can't find it any more.

How a HashSet finds things

A HashSet is a HashMap wearing a hat, so this is really about HashMap.

A HashMap is an array of buckets. To store a key it calls hashCode(), spreads the bits a little, and reduces the result to an array index. Everything with the same index lands in the same bucket, as a small linked list — and if one bucket gets badly overloaded (8 entries, in a table of at least 64) Java converts that list to a balanced tree so lookups stay fast instead of degrading to a scan.

Lookup is the same trip in reverse:

  1. call hashCode() to find the bucket
  2. walk that bucket calling equals() until something matches

That's the whole mechanism, and it explains every bug below. hashCode picks the bucket. equals picks the item. If hashCode sends you to the wrong bucket, a perfect equals never runs.

The contract, in plain terms

  • If two objects are equal, they must have the same hashCode.
  • If two objects have the same hashCode, they need not be equal — collisions are normal and fine.
  • While an object sits in a hash collection, its hashCode must not change.

Rule one is why you override both or neither. Rule three is the one that bites JPA, and we'll get there.

Bug 1: overriding equals but not hashCode

public class Product {
    private String barcode;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Product other)) return false;
        return Objects.equals(barcode, other.barcode);
    }
    // no hashCode
}

Now two products with barcode "1234" are equal, but they inherit Object.hashCode(), which is derived from identity — so they get different values and land in different buckets. equals never gets a chance to run.

Set<Product> set = new HashSet<>();
set.add(new Product("1234"));
set.add(new Product("1234"));
set.size(); // 2 — a Set with a duplicate in it

Whenever you write equals, write hashCode from the same fields:

@Override
public int hashCode() {
    return Objects.hash(barcode);
}

Bug 2: mutating a key after you've stored it

Product p = new Product("1234");
Set<Product> set = new HashSet<>();
set.add(p);          // filed under hash("1234")

p.setBarcode("9999"); // hashCode is now different

set.contains(p);      // false — we look in the "9999" bucket, it's in the "1234" one
set.remove(p);        // does nothing

The object is stranded. It's in the set, it can't be found, and it can't be removed — you can only get to it by iterating. Keys in hash collections should be immutable, or at least the fields used by hashCode should be.

Bug 3: the JPA one

This is the version you'll actually hit, and it's bug 2 in disguise.

The obvious equals for an entity uses the primary key:

@Entity
public class Product {

    @Id
    @GeneratedValue
    private Long id;

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (!(o instanceof Product other)) return false;
        return Objects.equals(id, other.id);
    }

    @Override
    public int hashCode() {
        return Objects.hash(id);
    }
}

Reasonable. And it breaks, because @GeneratedValue means the id is null until the database assigns one.

Product p = new Product();          // id = null, hashCode = hash(null)
products.add(p);                    // filed under the null bucket
productRepository.save(p);          // id becomes 42, hashCode changes
products.contains(p);               // false

The save mutated a field that hashCode depends on, while the object was sitting in a hash collection. That's exactly rule three, violated by the framework rather than by you.

It also means two different unsaved entities are "equal" to each other — both ids are null — so adding three new products to a Set before saving leaves you with one.

Three ways to fix it

Assign the id yourself, before the database sees it. If the key is a UUID generated in the constructor, it never changes and none of this happens:

@Id
private UUID id = UUID.randomUUID();

This is why I use client-assigned UUIDs for primary keys in Raaqib. It removes the problem instead of managing it, and it means an object has a stable identity from the moment it's created rather than from the moment it's persisted.

Use a business key. If the entity has something naturally unique and immutable — a barcode, an invoice number, a tenant id plus a SKU — base equals and hashCode on that. It's stable across the whole lifecycle. The catch is that genuinely immutable business keys are rarer than they look.

Or make hashCode constant. If you're stuck with a generated Long id:

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || Hibernate.getClass(this) != Hibernate.getClass(o)) return false;
    Product other = (Product) o;
    return id != null && id.equals(other.getId());
}

@Override
public int hashCode() {
    return getClass().hashCode();
}

Two things going on. hashCode returns the same value for every instance of the class, which is legal — collisions are allowed — and it can't change, which is what we needed. Every entity of that type lands in one bucket, so lookups degrade to a scan; that's fine for the dozens of entities in a typical collection, and not fine for thousands.

And id != null && ... means an unsaved entity is equal only to itself, which is what you want.

Hibernate.getClass() is there because Hibernate hands you proxies for lazily-loaded associations. A proxy is a generated subclass, so getClass() returns Product$HibernateProxy$xY7 and a naive getClass() != o.getClass() check reports two references to the same row as different objects. Hibernate.getClass() unwraps it.

While we're here: don't put @Data on an entity

@Data          // generates equals/hashCode over every field
@Entity
public class Product {
    @OneToMany(mappedBy = "product")
    private List<Variant> variants;
}

Lombok's @Data builds equals and hashCode from all fields, including associations. Calling hashCode() on that product loads every variant from the database, and if Variant also has @Data with a reference back to Product, the two call each other until the stack runs out.

Use @Getter/@Setter on entities and write equals/hashCode deliberately. Records are attractive for this but can't be entities — JPA needs a no-arg constructor and non-final fields.

The summary I'd give in an interview

hashCode chooses the bucket and equals chooses the item within it, so an object whose hashCode changes while it's in a HashSet becomes unreachable — still there, never found. That's why the natural equals on a JPA entity is a trap: @GeneratedValue leaves the id null until save, and saving mutates the very field hashCode was built on. I use client-assigned UUIDs so identity is stable from construction; the alternatives are a genuine business key, or an id-based equals paired with a constant hashCode. And never @Data on an entity — it drags lazy associations into hashCode.

ShareLinkedInPost

Have a question about this?

Get in touch