Advanced HashMap Series: The equals() and hashCode() Contract

Table of contents
- Let's Build a Broken System
- Fixing equals() the Right Way
- Prime Directives for equals()
- The HashMap Contract: Why equals() Alone is Not Enough
- What hashCode() does by default?
- The hashCode() Rule
- Prime Directives for hashCode()
- HashSet Behaves the Same Way
- Three Gotchas That Break Real Systems
- HashMap Internals: How Retrieval Works
- De-Duplication ≠ Just equals()
- Final Word

Most devs think they understand equals()
and hashCode()
.
They don’t.
They override one and forget the other. Furthermore, they assume a working HashMap
means a correct one. They let mutability creep into keys. And then act surprised when retrieval fails or duplicates sneak in.
Here's the truth:
If you override
equals()
, you must overridehashCode()
.If two objects are equal, they must return the same hash.
Break these, and your hash-based logic doesn’t throw errors — it just quietly goes wrong.
You don’t need 1000 lines of bad code to break a system.
One forgotten method is enough.
This post is not a tutorial. It’s a teardown — of every silent bug you invite by breaking the contract.
Let's Build a Broken System
We’ll use a basic Employee
class — a common domain model.
class Employee {
private final String id;
private final String department;
private final int yearOfJoining;
public Employee(String id, String department, int yearOfJoining) {
this.id = id;
this.department = department;
this.yearOfJoining = yearOfJoining;
}
}
Seems harmless.
Now add this test:
@Test
void shouldBeEqualWhenDataMatches() {
Employee e1 = new Employee("E100", "Finance", 2022);
Employee e2 = new Employee("E100", "Finance", 2022);
assertEquals(e1, e2); // ❌ Fails
}
Of course, it fails. Java’s default equals()
checks for reference equality:
public boolean equals(Object obj) {
return this == obj;
}
Since every Java class inherits from the Object class, and Object implements the equals() method – every Java class by default implements equals().
Two objects with the same data? Still not equal — unless they’re the same instance. The above method will only return true for objects stored in the same memory location.
Welcome to the first pitfall.
Fixing equals()
the Right Way
Here’s a proper override for equals()
:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Employee that = (Employee) o;
if (yearOfJoining != that.yearOfJoining) return false;
if (!Objects.equals(id, that.id)) return false;
return Objects.equals(department, that.department);
}
This makes value equality work.
Now the test shouldBeEqualWhenDataMatches
passes.
But the problem just got deeper — because this fix is incomplete.
Before moving on, let’s understand some prime directives for overriding the equals()
method.
Prime Directives for equals()
You don’t get to override equals()
casually. It must obey 5 rules:
Reflexive —
x.equals(x)
must be true.Symmetric — If
x.equals(y)
, theny.equals(x)
must be true.Transitive — If
x.equals(y)
andy.equals(z)
, thenx.equals(z)
.Consistent — It must return the same result unless the fields change.
Non-null —
x.equals(null)
must always return false.
This isn’t style. This is contract.
Break it and you’ll get unpredictable behavior in every collection that relies on equality — which includes almost all of them.
The HashMap Contract: Why equals()
Alone is Not Enough
Let’s test this:
Employee e1 = new Employee("E100", "Finance", 2022);
Employee e2 = new Employee("E100", "Finance", 2022);
Map<Employee, String> map = new HashMap<>();
map.put(e1, "Employee 1");
map.put(e2, "Employee 2");
System.out.println(map.size()); // ❌ Prints 2
Wait — same employee, but two entries?
Yes. Because equals()
was overridden — but hashCode()
wasn’t. The most important thing to remember about hashCode() is that each time we override equals()
implementation, we have to override hashCode() as well.
So, why did the above test fail? For that, let’s first understand the default behavior of hashCode()
What hashCode()
does by default?
The default hashCode() provided by Object
class:
Note: The
hashcode()
method is a native implementation in C\C++. It is not written in Java likeequals()
. There is a JNI (Java Native Interface) hook inObject
class forhashCode()
— it tells the JVM to execute a platform-specific native implementation.
Returns an integer derived from the memory address of the object (not the actual memory address, but a value typically derived from it).
Each object will have a unique
hashCode
unlessequals()
is overridden to say otherwise. So unless we overridehashCode()
in Employee class, two distinct object instances will have different hash codes, even if their contents are identical. Meaning - different employees.
The hashCode()
Rule
When you put a key into a HashMap
, this is what happens:
Compute the key’s
hashCode()
— find the bucketInside the bucket, use
equals()
to resolve key collisions
If hashCode()
returns different values, the objects land in different buckets. equals()
isn’t even called.
So, even if your data is identical — HashMap
treats them as separate keys.
Let’s fix this:
@Override
public int hashCode() {
int result = id != null ? id.hashCode() : 0;
result = 31 * result + (department != null ? department.hashCode() : 0);
result = 31 * result + yearOfJoining;
return result;
}
This implementation ensures:
Objects with same content → same
hashCode
.Hash function distributes well due to prime multiplier (31).
Now run the test again — size is 1.
Now the old value is replaced by the new one.
Prime Directives for hashCode()
Must return the same result across multiple invocations (unless the object mutates).
If
x.equals(y)
is true, thenx.hashCode() == y.hashCode()
must be true.If
x.equals(y)
is false, theirhashCodes
can be equal — but you should reduce that probability.Unequal
hashCodes
? Thenequals()
must return false.
These aren’t style guides. These are compiler-trusted invariants.
HashSet Behaves the Same Way
Set<Employee> set = new HashSet<>();
set.add(e1);
set.add(e2);
Even if e1.equals(e2)
is true — unless hashCode()
matches, both will be stored.
Sets rely on the same hashCode-then-equals
pipeline.
Three Gotchas That Break Real Systems
1. Mutable Fields Inside Keys
Employee emp = new Employee("E100", "Finance", 2022);
map.put(emp, "Finance");
emp.setDepartment("HR"); // mutation
map.get(emp); // ❌ Returns null
Why?
Because the mutated object now hashes to a different bucket. You’re holding the same reference, but it’s invisible to the map.
Lesson: Never mutate fields used in equals()
or hashCode()
after insertion. Either make them final, or never use the object as a key.
2. Constant HashCode
Destroys Performance
@Override
public int hashCode() {
return 1;
}
This satisfies the contract — but now every object lands in the same bucket.
Congratulations: your HashMap
is now a linked list. Lookups are now O(n).
This might not be visible in a dev environment. But in prod? On a hot path? You’ve silently killed performance.
Use a proper hash distribution. Combine multiple fields. Use primes like 31
.
3. False Duplicates or Misses
If:
You override
hashCode()
but notequals()
→ different objects are considered the same based on thehashCode()
implementation.You override
equals()
but nothashCode()
→ same objects live in different buckets
In both cases:
HashMap.put()
inserts duplicate keysHashSet.add()
doesn’t deduplicateget()
fails even with same dataPerformance degrades unpredictably
HashMap Internals: How Retrieval Works
Let’s break down a get()
call in a HashMap
:
map.get(someKey);
Here’s what actually happens:
Call
someKey.hashCode()
→ jump to a bucketIn that bucket, iterate entries → call
equals()
on each keyIf a match is found, return the value. Else, return null.
So:
Bad
hashCode()
→ wrong bucket → retrieval failsBad
equals()
→ right bucket, no match → retrieval fails
You can hold the same reference, and get()
will still return null
if the object was mutated or contracts were broken.
De-Duplication ≠ Just equals()
In hash-based collections:
hashCode()
determines where to lookequals()
decides whether it matches
Both are required. Without both:
Objects may duplicate
Entries may overwrite unexpectedly
Lookups may silently fail
No compiler will warn you. No exception will be thrown.
The system will just silently behave wrong.
Final Word
You can write a 100,000-line application and ignore equals()
and hashCode()
.
It’ll work — until it doesn’t.
And when it fails, it won’t break loudly. It will rot silently.
Your logs won’t help.
Your tests won’t catch it.
Your data will lie.
The only fix is to respect the contract.
And to write your classes like you're designing keys for a vault — not just objects for a list.
Up Next
We’ve now laid down the fundamentals of identity and hashing. Next up in the series — a deep comparison between HashMap
, LinkedHashMap
, and TreeMap
:
How they differ in internal structure
Performance tradeoffs across read/write-heavy workloads
Ordering guarantees and their cost
Real-world use cases where one breaks and another shines
Subscribe to my newsletter
Read articles from Harshavardhanan directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
