Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 4

Implementing equals() and hashCode()

Recommended resources

Hello! Welcome to your next lesson in the Core Java & JVM Internals module.

In our previous session, we discussed String immutability. One of the key reasons we identified for this design choice was to ensure hashcode consistency, making String objects reliable keys in collections like HashMap. Today, we're going to dive deep into that concept.

Our learning outcome is to implement the equals() and hashCode() contract correctly and explain its importance for collections. This is a fundamental topic and a frequent source of questions in mid-to-senior level Java interviews. Mastering this contract demonstrates a solid understanding of object identity, equality, and the inner workings of some of the most used data structures in Java.

1. Default Behavior: Identity vs. Equality

Every object in Java inherits the equals() and hashCode() methods from the java.lang.Object class. Let's first understand what they do by default.

  • public boolean equals(Object obj): The default implementation in Object checks for identity, not equality. It behaves exactly like the == operator for objects, returning true only if two references point to the exact same object in memory.
  • public int hashCode(): The default implementation typically converts the internal memory address of the object into an integer.

This means that, by default, two different objects are never equal, even if they hold the exact same data.

HashCode and Equals - Easy and comprehensive including coding samples

The following video segment provides a clear, concise demonstration of this default behavior and why it's often not what we want for our custom classes.

Watch from 06:51 to 10:51. The presenter uses a Car class to show that two distinct Car objects with identical attributes are not considered equal by the default .equals() method. This highlights why we need to provide our own implementation.

As you saw, if you create a custom class like Employee with id and name fields, two separate Employee objects with the same id and name will fail an equals() check. For collections to work correctly and for our business logic to make sense, we need to define our own notion of "logical equality."


2. Overriding equals(): The Contract

To define logical equality, you must override the equals() method. When you do, you are bound by a strict contract defined in the Java language specification. Adhering to this contract is non-negotiable.

The equals() method must be:

  1. Reflexive: For any non-null reference x, x.equals(x) must return true. (An object must equal itself).
  2. Symmetric: For any non-null references x and y, x.equals(y) must return true if and only if y.equals(x) returns true.
  3. Transitive: For any non-null references x, y, and z, if x.equals(y) is true and y.equals(z) is true, then x.equals(z) must be true.
  4. Consistent: For any non-null references x and y, multiple invocations of x.equals(y) must consistently return true or consistently return false, provided no information used in the comparison on the objects is modified.
  5. Non-nullity: For any non-null reference x, x.equals(null) must return false.

Java equals() and hashCode() Contracts

The Baeldung article 'Java equals() and hashCode() Contracts' provides an excellent summary of this contract and a template for a correct implementation. Pay close attention to the recipe for overriding equals().

Read Section 2: The .equals() Method, including subsections 2.1 and 2.2. Focus on the formal contract rules and the standard implementation pattern shown in the code example.

A Standard Recipe for equals()

Based on the article, here is a robust recipe for implementing equals() that you should be able to write from memory in an interview:

@Override
public boolean equals(Object o) {
    // 1. Reflexive check: are the references identical?
    if (this == o) return true;

    // 2. Null and type check: is it null or not the same class?
    if (o == null || getClass() != o.getClass()) return false;

    // 3. Cast the object to the correct type
    Employee employee = (Employee) o;

    // 4. Compare significant fields for equality
    // For primitives, use ==
    // For objects, use Objects.equals() (it's null-safe)
    return id == employee.id && 
           Objects.equals(name, employee.name);
}

3. The All-Important hashCode() and Its Contract

Now we get to the core of the issue. The hashCode() method provides an integer representation of an object. Hash-based collections like HashMap, HashSet, and ConcurrentHashMap use this hash code as a first-pass filter to dramatically speed up lookups.

Instead of iterating through every element and calling equals(), a HashMap does this:

  1. Calculates the hashCode() of the key you're looking for.
  2. Uses this hash code to compute an index into its internal array of "buckets."
  3. Goes directly to that bucket.
  4. Only then does it call equals() on the (usually few) elements within that bucket.

This system relies on one fundamental rule:

The hashCode() Contract:

If two objects are equal according to the equals(Object) method, then calling the hashCode() method on each of the two objects must produce the same integer result.

equals() & hashCode() in Java: Same Content Implies Equality and Same Hash Code
This image illustrates the core contract: two distinct objects that are considered equal must return the same hash code.

What happens if you break the contract?

This is a classic interview question. The most common way to break the contract is to override equals() but forget to override hashCode(). In this case, you are using your custom logic for equality but the default identity-based hashCode() from Object.

Let's see the disastrous consequences.

02. Equals and HashCode Contract & Different Variations

The following video demonstrates exactly what goes wrong when you use an object with a broken equals/hashCode contract as a key in a HashMap.

Watch the segment from 04:53 to 07:28. The presenter creates two Student objects that are logically equal but have different hash codes. Notice how the HashMap fails to find an object that has been put into it.

As the video showed, if you put an object in a HashMap and then try to retrieve it using a different but "equal" object, the lookup will fail. The put operation will use the first object's hash code to find a bucket, and the get operation will use the second object's (different) hash code, looking in a completely different bucket and finding nothing. The map "loses" your entry.

This is why IDEs will warn you if you override equals() without hashCode().


4. Correct Implementation & Interview Scenarios

To fix this, you must override hashCode() using the same set of fields you used in your equals() method.

Since Java 7, the easiest and best way to do this is with the java.util.Objects.hash() utility method.

import java.util.Objects;

public class Employee {
    private int id;
    private String name;

    // constructor, getters, setters...

    @Override
    public boolean equals(Object o) {
        if (this == o) return true;
        if (o == null || getClass() != o.getClass()) return false;
        Employee employee = (Employee) o;
        return id == employee.id && Objects.equals(name, employee.name);
    }

    @Override
    public int hashCode() {
        // Use the same fields as in equals()!
        return Objects.hash(id, name);
    }
}

This modern approach is simple, correct, and what interviewers expect to see.

Now, let's tackle a common interview problem designed to test your deep understanding of how HashMap uses these methods.

Test your understanding!

An interviewer presents you with this Broken class and asks: "What happens when you execute the main method? How many elements will be in the map at the end, and what will the final get() call return? Explain the process step-by-step."

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

class Broken {
    @Override
    public boolean equals(Object obj) {
        // Deliberately broken for this problem
        return false;
    }

    @Override
    public int hashCode() {
        // Deliberately broken for this problem
        return 42;
    }
}

public class TestMap {
    public static void main(String[] args) {
        Map<Broken, String> map = new HashMap<>();
        Broken b1 = new Broken();
        Broken b2 = new Broken();

        map.put(b1, "value1");
        map.put(b2, "value2");

        System.out.println("Map size: " + map.size());
        System.out.println("Value for b1: " + map.get(b1));
    }
}
Show answer

The output will be:

Map size: 2
Value for b1: value1

Step-by-step explanation:

  1. map.put(b1, "value1"):

    • The hashCode() of b1 is calculated. It is 42.
    • The HashMap uses this hash code to determine the bucket index (e.g., index 10 if 42 % 16 = 10).
    • The bucket is empty, so a new Node(b1, "value1") is created and placed in this bucket.
  2. map.put(b2, "value2"):

    • The hashCode() of b2 is calculated. It is also 42.
    • The HashMap determines the same bucket index (e.g., 10).
    • The bucket is not empty. It contains the node for b1. This is a hash collision.
    • The HashMap now checks if the new key b2 is equal to the existing key b1 by calling b2.equals(b1).
    • The equals() method is hardcoded to return false.
    • Since the keys are not considered equal, the HashMap does not overwrite the existing entry. Instead, it adds a new Node(b2, "value2") to the same bucket, typically by creating a linked list (or a tree, if the list gets long). The bucket now contains two entries.
  3. map.size(): The map contains two distinct entries, so the size is 2.

  4. map.get(b1):

    • The hashCode() of b1 is calculated (42), leading to the correct bucket (e.g., index 10).
    • The map iterates through the entries in this bucket.
    • It first checks the node containing b2. Is b1.equals(b2)? No, it's false.
    • It then checks the node containing b1. Is b1.equals(b1)? No, it's also false because our equals is broken! However, the get logic in HashMap also includes an identity check (k == key). Since we are passing the exact same object reference b1, this == check passes.
    • The value associated with b1, which is "value1", is returned.

This problem reveals that even with a broken equals method, get() can still work if you use the exact same key object, due to the identity check optimization.


Conclusion

You have now dissected one of the most important contracts in the Java language. A candidate who can fluently explain these concepts is demonstrating a command of core language principles.

Key Takeaways:

  • The Golden Rule: When you override equals(), you must also override hashCode().
  • The Contract: If a.equals(b) is true, then a.hashCode() == b.hashCode() must be true. The reverse is not required.
  • The Consequence of Failure: Breaking the contract leads to unpredictable and incorrect behavior in all hash-based collections (HashMap, HashSet, etc.), often manifesting as "lost" objects.
  • The Goal of equals(): To define logical equality based on an object's state (its fields).
  • The Goal of hashCode(): To provide a quick way for collections to narrow down searches by grouping objects into buckets.
  • Modern Implementation: Use java.util.Objects.equals() and java.util.Objects.hash() for null-safe and correct implementations.

In our next lesson, we will broaden our focus on memory again to explore the Java Memory Model (JMM), the structure of the heap, and the goals of modern garbage collectors. This will build on our discussions of the heap, string pool, and object allocation.