unkletayo
2

Why You Must Always Override equals() and hashCode() Together in Java

The difference between identity and value equality, how hashes spread across buckets, and how to write keys that behave.

Adetayo Akinsanya 2026-10-01 12 min read

  • Java
  • Data Structures
  • HashMap
  • JVM
  • Software Engineering

Part 2 of the series. Read How the JVM Actually Lays Out Arrays in Memory (Part 1) first if you have not.

In this article (7 sections)
  1. Identity (==) vs Value Equality (equals)
  2. The Rules equals() Must Follow
  3. The Hash Code Integer
  4. The Unbreakable Contract Relationship
  5. Production Horror Story: The Missing hashCode() Bug
  6. Correct Implementation Pattern
  7. Quick Summary

Before you start: this article assumes you know what a class, an object, a field and a method are in Java, and how to create an object with new. Everything else is explained as it comes up.

Suppose it’s 2:00 AM on a Friday. Your phone alerts you to a production incident: logged-in users cannot access their subscription accounts. (“Production” means the live system that real users are using right now.)

Your authentication service uses a custom UserSession object as a key inside a HashMap to fetch user roles.

A HashMap is a Java lookup table that stores pairs: you hand it a key and it gives back the value saved for that key, like looking up a name in a phone book to get a number. A key is the item you look something up by (here, a session), and a value is the thing stored under it (here, the user’s role). Putting a pair in is called put, and looking a value up by its key is called get. In plain terms, when a user logs in the program stores their role in the map, and later requests ask the map for it.

The logs show that when a user logs in, the service creates a UserSession(userId=981) key and stores their role. One millisecond later, when the user requests their profile page, the server constructs another UserSession(userId=981) key and queries the map.

The result? map.get(session) returns null. The user gets logged out immediately.

Why does HashMap claim the session key does not exist when both key objects contain identical data?

The answer lies in the contract between equals() and hashCode().


Identity (==) vs Value Equality (equals)

Picture two printed copies of the same form. They say the same thing, but they are still two separate sheets of paper. Asking “is this the very same sheet?” is what == does. Asking “do these say the same thing?” is what equals does.

In Java, a variable that holds an object really holds a reference, which is the address of where the object lives in memory. The heap is the area of memory where Java stores objects. The == operator compares memory addresses when applied to object references. It returns true only if both variables point to the exact same heap allocation (the same block of memory).

User userA = new User(101, "Alex");
User userB = new User(101, "Alex");

boolean sameAddress = (userA == userB); // false! Separate heap allocations.
HEAP
+-----------------------------------+
| User Object 0x1A8F                |
| id: 101, name: "Alex"             |  <--- userA
+-----------------------------------+

+-----------------------------------+
| User Object 0x2B90                |
| id: 101, name: "Alex"             |  <--- userB
+-----------------------------------+

To compare the fields you care about (here the user ID and name) rather than memory locations, Java provides the equals(Object obj) method on java.lang.Object.

Every Java class automatically inherits from the built-in class java.lang.Object. Inheriting means your class gets the methods Object already has, including equals, without you writing them. To override a method means to write your own version of it in your class, which replaces the inherited one.

By default, Object.equals() simply performs this == obj. You must override it to enforce value-based equality, meaning two objects count as equal when their fields match.


The Rules equals() Must Follow

Think of “equals” the way you think of “is the same age as”: it has to behave sensibly. If Ana is the same age as Ben, then Ben is the same age as Ana.

The Java standard library requires any equals() override to fulfill five mathematical properties. A contract here means a rule that the Java libraries rely on and trust you to follow. The compiler does not check it.

  1. Reflexive: x.equals(x) must return true. (Everything equals itself.)
  2. Symmetric: x.equals(y) must return true if and only if y.equals(x) returns true. (If A says it equals B, B must say it equals A.)
  3. Transitive: If x.equals(y) is true and y.equals(z) is true, then x.equals(z) must return true. (If A equals B and B equals C, then A equals C.)
  4. Consistent: Multiple invocations return identical results unless fields mutated. (Mutated means changed after creation. The answer may change only if a field used in the comparison changed.)
  5. Null comparison: x.equals(null) must always return false. (null means “no object”, and a real object is never equal to it.)

The Hash Code Integer

Think of a coat check. You hand over your coat and get a numbered ticket, and the attendant hangs the coat on the rack that matches the number. When you come back, the ticket tells the attendant which rack to look at, so nobody searches every rack. A hash code works like that ticket: it is a number that acts as a short label for an object, and it tells the map which shelf to look on.

Formally, a hash code is a 32-bit signed integer (int, a whole number that can be negative) computed from an object’s internal fields.

Hash-based collections use this integer to pick a spot in an array. HashMap stores key and value pairs, HashSet stores a set of unique items, and LinkedHashMap is a HashMap variant that also remembers the order items were added. An array here is a numbered row of slots, and each slot is called a bucket. Because the map jumps straight to one bucket instead of checking every entry, a lookup takes about the same time no matter how many entries the map holds. This is written O(1)O(1) and read as “constant time”.

The real HashMap source code (OpenJDK, java.util.HashMap) computes the bucket index in two steps. First it mixes the high bits of the hash code into the low bits, then it keeps only the low bits that fit the array:

int h = key.hashCode();
int hash = h ^ (h >>> 16);          // spread: mix the top 16 bits into the bottom 16
int bucketIndex = (n - 1) & hash;   // n is the array length, always a power of two

Here >>> shifts the bits of a number to the right, ^ combines two numbers bit by bit (XOR), and & keeps only the bits set in both numbers. Because n is a power of two (the default is 16), (n - 1) & hash gives a number from 0 up to n - 1, so it always lands inside the array.

If two objects have different hash codes, a hash collection immediately knows they cannot be equal. It skips the expensive field-by-field equals() check. This works because of the contract described below: equal objects must have equal hash codes, so different hash codes prove the objects are not equal.

How put and get use the hash code, step by step

Putting an entry in with put(key, value):

  1. The map calls key.hashCode() to get a number.
  2. It spreads that number with h ^ (h >>> 16) and keeps the low bits with (n - 1) & hash to get a bucket index.
  3. If that bucket is empty, the map stores the entry there and is done.
  4. If the bucket already holds entries, the map compares the new key with each stored key using equals(). If one is equal, it replaces that entry’s value. If none is equal, it adds the new entry to the bucket.

Looking a value up with get(key):

  1. The map calls key.hashCode() and computes the bucket index the same way.
  2. It goes straight to that bucket. Nothing else is searched.
  3. Inside the bucket, it checks the stored keys with equals() (the real code also compares the stored hash first as a quick filter). When one matches, it returns that entry’s value. If none matches, it returns null.

A tiny worked example with made-up numbers, using a map with 16 buckets:

  • Suppose a key has hash code 1012. Since 1012 >>> 16 is 0, spreading changes nothing: 1012 ^ 0 is 1012. In binary, 1012 ends in 0100, so 15 & 1012 is 4. The entry goes in bucket 4.
  • Later, get is called with a second key that equals() the first and so, following the rule, also has hash code 1012. It computes bucket 4, finds the stored key there, equals() says yes, and the value comes back.
  • Now suppose the second key broke the rule and had hash code 100000. Here 100000 >>> 16 is 1, spreading gives 100000 ^ 1, which is 100001, and 15 & 100001 is 1. The map looks in bucket 1, finds nothing, and returns null. It never gets to call equals(), because it never looked in bucket 4.

That last case is exactly the bug in this article.


The Unbreakable Contract Relationship

Besides the five rules above, equals() and hashCode() share one more rule. This is the contract that HashMap depends on:

If two objects are equal according to equals(Object), they MUST produce the exact same integer result from hashCode().

The reverse is not required: two unequal objects CAN produce identical hash codes. That scenario is called a hash collision. Two unequal objects that collide end up in the same bucket, and the map then uses equals() to tell them apart.

               +----------------------------------+
               |         Equal Objects            |
               |     (userA.equals(userB) == true)|
               +----------------------------------+
                                |
                                | MUST HAVE
                                v
               +----------------------------------+
               |       Identical Hash Codes       |
               | (userA.hashCode() == userB.hashCode())
               +----------------------------------+

Production Horror Story: The Missing hashCode() Bug

A bug like this can slip through because the code compiles, runs without errors, and only returns the wrong answer. Here is code that reproduces the problem from the opening scenario. Read the notes after the listing if any line is unfamiliar:

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

class UserSession {
    private final int userId;

    public UserSession(int userId) {
        this.userId = userId;
    }

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        UserSession session = (UserSession) o;
        return userId == session.userId;
    }

    // MISSING HASHCODE OVERRIDE!
}

public class IncidentDemo {
    public static void main(String[] args) {
        Map<UserSession, String> roles = new HashMap<>();
        
        UserSession loginSession = new UserSession(981);
        roles.put(loginSession, "ADMIN");

        UserSession requestSession = new UserSession(981);
        
        // loginSession.equals(requestSession) is TRUE, but hashCodes almost certainly differ!
        System.out.println("Role found? " + roles.get(requestSession)); // Prints NULL!
    }
}

How to read the listing:

  • private final int userId declares a field that only this class can see (private) and that cannot be reassigned after the constructor sets it (final).
  • @Override is a note to the compiler that the method below replaces one inherited from Object. If the method name or parameters do not match an inherited method, the compiler reports an error.
  • getClass() != o.getClass() compares the exact types of the two objects. getClass() returns the class an object was created from, so the check returns false for an object of a different type.
  • (UserSession) o is a cast. It tells Java to treat o, which is declared as a plain Object, as a UserSession, so its userId can be read. The type check on the line above makes that safe.
  • Map<UserSession, String> uses generics, the angle-bracket notation that says what a collection holds. Here the keys are UserSession objects and the values are Strings. The new HashMap<>() on the right lets Java fill in the same types.

What happens when main runs:

  1. roles.put(loginSession, "ADMIN") hashes loginSession and stores “ADMIN” in the bucket that hash code picks.
  2. requestSession is a second, separate object with the same userId. equals() would say it matches loginSession.
  3. roles.get(requestSession) hashes requestSession. Without a hashCode() override, that number is almost certainly different, so the map looks in a different bucket, finds nothing, and returns null.

Because hashCode() was omitted, loginSession and requestSession inherited the default identity hash code from java.lang.Object. Identity means it is tied to the object itself, not to its field values. The Java documentation says only that Object.hashCode() returns distinct integers for distinct objects as far as is reasonably practical. It does not promise that the number is the memory address, so do not rely on that. The result is that two separate objects get different hash codes in practice.

roles.get(requestSession) hashed requestSession, computed a completely different bucket index (very likely, though not guaranteed), inspected an empty array slot, and returned null. It never reached equals().


Correct Implementation Pattern

Always override both methods together using the exact same fields. If equals() compares userId, then hashCode() must be built from userId too, so any two objects that equals() calls the same get the same number. Objects.hash(...) is a ready-made helper that combines the fields you give it into one hash code:

@Override
public boolean equals(Object o) {
    if (this == o) return true;
    if (o == null || getClass() != o.getClass()) return false;
    UserSession session = (UserSession) o;
    return userId == session.userId;
}

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

Quick Summary

  • == checks reference addresses. equals() checks business value equality (equal in the sense your program cares about, such as the same user ID).
  • Equal objects must return identical hashCode() values.
  • Omitting hashCode() causes hash collections to inspect wrong bucket indices, triggering silent lookup failures in production.

Questions or corrections

Found a mistake or have a question about this breakdown? Reach out directly.

Reply on X (@unkletayor) Email feedback