← Java / Spring Boot
JAVA_SPRINGBOOTArticle 03 · 30 min read

Equals, hashCode and Immutability in Java

Stuck on a concept here? Ask the AI coach

A HashMap finds entries by hash code, so a key whose hash changes after insertion is lost without any error. Immutable keys make that bug impossible.

Week 1 at a glance

Item Plan
Track Java, then Spring Boot
Level S1
Topic equals, hashCode, immutability
What to learn The equals/hashCode contract, mutable keys, defensive copies, final, records
Why it matters Broken keys silently lose data in maps and caches
Practical task Mutate a key inside a HashMap and watch the entry vanish; fix it with a record

Learning objective. By the end you should be able to write a correct equals and hashCode, explain why a mutable key corrupts a HashMap, and make a class safe to use as a key by making it immutable.

Prerequisites: you can write a Java class, and you know what HashMap, HashSet and List do.

The problem

A service keeps an in-memory map of permissions, keyed by a UserKey object that holds a user id and a tenant. It works for weeks. Then someone adds a method that updates the tenant on the same UserKey object after it was inserted.

From that moment, the map misbehaves:

  • map.get(key) returns null, though the key was put in earlier.
  • map.containsKey(key) returns false.
  • map.size() still counts the entry, and iterating the map still shows it.

No exception is thrown, and nothing is logged. The entry is still in memory but can never be reached by lookup. In a cache this means silent misses and rebuilt data, and in a map of permissions it can mean a user suddenly has none.

The cause sits in two small methods most Java developers write without thinking: equals and hashCode, combined with a key that is allowed to change.

The equals and hashCode contract

By default, Object.equals compares identity: two references are equal only if they point to the same object. Most value-like classes override it to compare content instead, and when they do, they must also override hashCode.

The contract:

Rule Meaning
Reflexive x.equals(x) is true
Symmetric x.equals(y) is true exactly when y.equals(x) is true
Transitive If x equals y and y equals z, then x equals z
Consistent Repeated calls give the same answer while nothing changes
Null-safe x.equals(null) is false, never an exception
Hash agreement If x.equals(y), then x.hashCode() == y.hashCode()

The reverse is not required: two unequal objects may share a hash code. That is just a collision, and it only costs speed.

How HashMap uses them

  1. put(key, value): compute key.hashCode(), and use it to pick a bucket.
  2. Inside that bucket, compare existing keys with equals. If one matches, replace its value; otherwise add a new entry.
  3. get(key): compute the hash again, go to the same bucket, and search it with equals.

The hash decides where to look. equals decides whether it is the same key. If the hash code of a key changes after insertion, get looks in the wrong bucket and never finds it.

The classic mistake: equals without hashCode

class Money {
    final long amount;
    final String currency;

    Money(long amount, String currency) {
        this.amount = amount;
        this.currency = currency;
    }

    @Override
    public boolean equals(Object o) {
        return o instanceof Money m
            && amount == m.amount
            && currency.equals(m.currency);
    }
    // hashCode() not overridden
}

Set<Money> set = new HashSet<>();
set.add(new Money(10, "INR"));
set.contains(new Money(10, "INR"));   // usually false

The two objects are equal, but the inherited hashCode is identity-based, so they almost always land in different buckets and equals is never even asked.

The correct version

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (!(o instanceof Money m)) return false;
    return amount == m.amount && currency.equals(m.currency);
}

@Override
public int hashCode() {
    return Objects.hash(amount, currency);
}

Two habits keep this safe. Use exactly the same fields in both methods, and mark the class final (or think hard about subclasses), because instanceof plus inheritance makes symmetry easy to break.

Practical task: watch the entry vanish

Create VanishingKey.java and run it. The class has a correct equals and hashCode, but it is mutable.

import java.util.*;

public class VanishingKey {

    static class UserKey {
        private final String userId;
        private String tenant;                       // mutable on purpose

        UserKey(String userId, String tenant) {
            this.userId = userId;
            this.tenant = tenant;
        }

        void setTenant(String tenant) { this.tenant = tenant; }

        @Override
        public boolean equals(Object o) {
            return o instanceof UserKey k
                && userId.equals(k.userId)
                && tenant.equals(k.tenant);
        }

        @Override
        public int hashCode() { return Objects.hash(userId, tenant); }

        @Override
        public String toString() { return userId + "@" + tenant; }
    }

    public static void main(String[] args) {
        Map<UserKey, String> permissions = new HashMap<>();
        UserKey key = new UserKey("u1", "acme");

        permissions.put(key, "ADMIN");
        System.out.println(permissions.get(key));          // ADMIN

        key.setTenant("globex");                           // mutate the key

        System.out.println(permissions.get(key));          // null
        System.out.println(permissions.containsKey(key));  // false
        System.out.println(permissions.size());            // 1
        System.out.println(permissions);                   // {u1@globex=ADMIN}

        permissions.put(key, "VIEWER");
        System.out.println(permissions.size());            // 2: a duplicate key

        key.setTenant("acme");                             // mutate it back
        System.out.println(permissions.get(key));          // ADMIN, found again
    }
}

What to observe:

  1. After setTenant, get and containsKey fail, yet size() and toString() prove the entry is still there.
  2. Putting the mutated key again creates a second entry for what is logically one key.
  3. Mutating the key back makes the original entry reachable again. This shows nothing was deleted: the map stored the old hash, and get was looking for the new one.

Why it happens. HashMap stores each entry's hash when it is inserted and never recomputes it. After the mutation, hashCode() returns a different number, so get searches a different bucket (and even in the same bucket it compares the stored hash first). The entry sits where the old hash put it, unreachable, and cannot even be removed.

Write down the answer to this before moving on: which single change to UserKey makes this whole class of bug impossible?

The fix: immutability

The answer is to stop the key from changing. An immutable object has the same hash for its whole life, so the bug cannot occur.

Step 1: replace the class with a record

record UserKey(String userId, String tenant) {
    UserKey {
        Objects.requireNonNull(userId, "userId");
        Objects.requireNonNull(tenant, "tenant");
    }
}

A record gives you, for free: private final fields, a constructor, accessors (userId(), tenant()), a content-based equals and hashCode over all components, and a toString. There is no setter to call.

Run the task again with this record. To "change the tenant" you must now create a different key:

UserKey acme = new UserKey("u1", "acme");
permissions.put(acme, "ADMIN");

UserKey globex = new UserKey(acme.userId(), "globex");   // a new, separate key
permissions.get(acme);    // ADMIN, still reachable
permissions.get(globex);  // null, a different key

The old entry can never be lost, because nobody can alter the object it is stored under.

Step 2: know what final does not do

final stops a reference from being reassigned. It does not stop the object it points to from changing.

class Route {
    private final List<String> stops;       // final, but the list is mutable

    Route(List<String> stops) { this.stops = stops; }
    List<String> stops() { return stops; }
}

List<String> list = new ArrayList<>(List.of("Pune", "Mumbai"));
Route r = new Route(list);
list.add("Goa");              // changes r from outside
r.stops().clear();            // changes r from outside

If Route is used as a map key, both lines above break it exactly as before.

Step 3: defensive copies

Copy mutable data on the way in and on the way out:

record Route(List<String> stops) {
    Route {
        stops = List.copyOf(stops);     // immutable copy, also rejects nulls
    }
}

List.copyOf takes an unmodifiable snapshot. The caller's original list can change freely and Route is unaffected, and the accessor now returns a list that cannot be modified. For a hand-written class, do the same in the constructor and return List.copyOf(stops) or an unmodifiable view from the getter.

Immutability checklist

  • Class is final (or a record).
  • All fields are private final.
  • No setters, and no method that changes state.
  • Mutable inputs are copied in the constructor.
  • Mutable fields are never returned directly.
  • Prefer immutable field types: String, Instant, LocalDate, BigDecimal, List.of. Avoid Date and arrays inside keys.

Record caveats

  • A record's equals and hashCode use all components. If equality should depend on only some fields, a record is the wrong tool.
  • A record is shallowly immutable: a mutable component (a plain ArrayList) can still change, so add the compact-constructor copy shown above.
  • An array component compares by identity in equals, so use a List instead.

When NOT to make something immutable

Immutability is the right default for keys, value objects and anything shared across threads. Objects that genuinely model changing things, such as an Account with a balance, should stay mutable and protect their own rules (see the object-thinking chapter). The thing to avoid is using a changing object as a key: if the key is an Account, key it by its accountId, not by the object.

Where this bites in Spring Boot

You have not written a Spring application yet, but the same mechanics sit underneath it. Four places to recognize them later.

1. Cache keys. With @Cacheable, Spring builds the cache key from the method arguments, and the cache looks entries up with equals and hashCode.

@Cacheable("permissions")
public Permissions find(UserKey key) { ... }

If UserKey is mutable, or has no equals/hashCode, you get the silent misses from this lesson: the cache never hits, and the database is hit every time. A record makes it correct by construction.

2. Request and response DTOs. Use records for objects that only carry data across the web layer. They are immutable, have a content-based equals (handy in tests), and a readable toString.

record CreateUserRequest(String name, String email) {}

3. Configuration properties. @ConfigurationProperties classes can be records too, so configuration is immutable once bound.

4. JPA entities, the hard case. An entity's id is often null until the database assigns it on save. If hashCode uses the id, it changes at the moment of saving:

Set<Order> set = new HashSet<>();
Order order = new Order();       // id is null here
set.add(order);
orderRepository.save(order);     // id assigned, hashCode changes
set.contains(order);             // can now be false: the mutable-key bug again

There is no single answer, and this is one of the most debated corners of JPA, but the usual safe options are:

  • Base equals and hashCode on a natural business key that never changes (an email, an order number).
  • Or compare by id only when it is non-null, and return a constant hashCode for the class.
  • Avoid generating equals/hashCode with Lombok's @Data on entities, because it includes every field, including lazy relationships.

The principle is always the lesson's: a hash must not change while the object sits in a hash-based collection.

Thread safety bonus. Spring beans are singletons handled by many request threads at once. Immutable objects shared between them need no locking, which is one more reason to prefer them.

Check yourself

Can you explain?

  1. Why must you override hashCode whenever you override equals? What goes wrong with a HashSet if you do not?
  2. What are the two jobs in HashMap.get, and which method does each?
  3. A key's field changes after it is put in a map. Why does get return null while size() still counts the entry?
  4. A class has a private final List<String> items field. Is it immutable? Why or why not?
  5. Which equals/hashCode fields does a record use, and when is a record the wrong choice?
  6. Why is an auto-generated database id a risky field to put in hashCode?

Can you do?

  1. Run VanishingKey and reproduce all five observations. Then change UserKey to a record and show that each one disappears.
  2. Write an immutable DateRange(LocalDate from, LocalDate to) record that rejects from after to in its compact constructor, and use it as a HashMap key.
  3. Write a record Basket(List<String> products), then prove with code that changing the original list afterwards does not change the record or its hashCode().
  4. Break it on purpose: write equals without hashCode for a class, put two equal instances in a HashSet, and print the set's size.

Completion criteria

You are done with this lesson when you can:

  • Write a correct equals and hashCode pair without looking anything up.
  • Explain, using buckets, why a mutated key cannot be found.
  • Make a class safe to use as a key, using a record or the immutability checklist.
  • Say why final on a field does not make an object immutable.
  • Recognize the mutable-key risk in cache keys and JPA entities.

Recall

Revisit this lesson after 1 day, 3 days and 10 days. Without scrolling up, answer:

  1. What does the hash decide, and what does equals decide?
  2. What must never change while an object is in a hash-based collection?
  3. What are the three steps that make a class immutable?

This comes back later in the roadmap, whenever you use a cache, a Set of objects, or a JPA entity.

Your notes

Notes & links

Saved automatically.

Equals, hashCode and Immutability in Java | Java / Spring Boot | ProgVale