Adetayo Akinsanya unkletayo.dev
Engineering / Java Collections From Scratch • Part 2 of 26 Published

Java equals() and hashCode() Contract: Avoiding Silent HashMap Bugs

Identity vs value equality, hash distribution math, and custom key overrides.

Part 2 in Series — Catch up on the previous article: Java Memory Model Explained: Stack vs Heap Allocation for Arrays (Part 1) before diving into this post.

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.

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

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)

The == operator compares memory addresses when applied to object references. It returns true only if both variables point to the exact same heap allocation.

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 domain values (user ID and name) rather than memory locations, Java provides the equals(Object obj) method on java.lang.Object.

By default, Object.equals() simply performs this == obj. You must override it to enforce value-based equality.


The Immutable Contract Rules

The Java standard library requires any equals() override to fulfill five mathematical properties:

  1. Reflexive: x.equals(x) must return true.
  2. Symmetric: x.equals(y) must return true if and only if y.equals(x) returns true.
  3. Transitive: If x.equals(y) is true and y.equals(z) is true, then x.equals(z) must return true.
  4. Consistent: Multiple invocations return identical results unless fields mutated.
  5. Null comparison: x.equals(null) must always return false.

The Hash Code Integer

A hash code is a 32-bit signed integer (int) computed from an object’s internal fields.

Hash-based collections (HashSet, HashMap, LinkedHashMap) use this integer to calculate array bucket indices in O(1)O(1) time.

int bucketIndex = (hashCode & 0x7FFFFFFF) % arrayCapacity;

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.


The Unbreakable Contract Relationship

The contract between equals() and hashCode() consists of one rule:

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.

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

Production Horror Story: The Missing hashCode() Bug

Here is the exact code that caused the 2:00 AM production incident:

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 differ!
        System.out.println("Role found? " + roles.get(requestSession)); // Prints NULL!
    }
}

Because hashCode() was omitted, loginSession and requestSession inherited the default identity hash code from java.lang.Object. The JVM assigned different hash codes based on memory addresses.

roles.get(requestSession) hashed requestSession, computed a completely different bucket index, inspected an empty array slot, and returned null.


Correct Implementation Pattern

Always override both methods together using the exact same fields:

@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 objects must return identical hashCode() values.
  • Omitting hashCode() causes hash collections to inspect wrong bucket indices, triggering silent lookup failures in production.

References & Further Reading

  1. Bloch, J. (2018). Effective Java (3rd Edition) — Items 10 & 11: Obey equals and hashCode Contracts. Addison-Wesley.
  2. Oracle Corporation. Java SE 21 API Docs: Object#equals and Object#hashCode. Oracle Docs.
  3. Goetz, B. (2006). Java Concurrency in Practice — Appendix A: Value Objects and Immutability. Addison-Wesley.

Up Next in Series →

Part 3: How Java ArrayList Works Internally: Building a Dynamic Array from Scratch

Continue to Part 3 →