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 inObjectchecks for identity, not equality. It behaves exactly like the==operator for objects, returningtrueonly 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:
- Reflexive: For any non-null reference
x,x.equals(x)must returntrue. (An object must equal itself). - Symmetric: For any non-null references
xandy,x.equals(y)must returntrueif and only ify.equals(x)returnstrue. - Transitive: For any non-null references
x,y, andz, ifx.equals(y)istrueandy.equals(z)istrue, thenx.equals(z)must betrue. - Consistent: For any non-null references
xandy, multiple invocations ofx.equals(y)must consistently returntrueor consistently returnfalse, provided no information used in the comparison on the objects is modified. - Non-nullity: For any non-null reference
x,x.equals(null)must returnfalse.
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:
- Calculates the
hashCode()of the key you're looking for. - Uses this hash code to compute an index into its internal array of "buckets."
- Goes directly to that bucket.
- 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 thehashCode()method on each of the two objects must produce the same integer result.
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:
-
map.put(b1, "value1"):- The
hashCode()ofb1is calculated. It is42. - The
HashMapuses this hash code to determine the bucket index (e.g., index10if42 % 16 = 10). - The bucket is empty, so a new
Node(b1, "value1")is created and placed in this bucket.
- The
-
map.put(b2, "value2"):- The
hashCode()ofb2is calculated. It is also42. - The
HashMapdetermines the same bucket index (e.g.,10). - The bucket is not empty. It contains the node for
b1. This is a hash collision. - The
HashMapnow checks if the new keyb2is equal to the existing keyb1by callingb2.equals(b1). - The
equals()method is hardcoded to returnfalse. - Since the keys are not considered equal, the
HashMapdoes not overwrite the existing entry. Instead, it adds a newNode(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.
- The
-
map.size(): The map contains two distinct entries, so the size is2. -
map.get(b1):- The
hashCode()ofb1is calculated (42), leading to the correct bucket (e.g., index10). - The map iterates through the entries in this bucket.
- It first checks the node containing
b2. Isb1.equals(b2)? No, it'sfalse. - It then checks the node containing
b1. Isb1.equals(b1)? No, it's alsofalsebecause ourequalsis broken! However, thegetlogic inHashMapalso includes an identity check (k == key). Since we are passing the exact same object referenceb1, this==check passes. - The value associated with
b1, which is"value1", is returned.
- The
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 overridehashCode(). - The Contract: If
a.equals(b)is true, thena.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()andjava.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.