The Bug That Every Java Developer Hits At Least Once
Let me show you something that looks like a bug but is actually Java working exactly as designed.
I have a simple Employee class and I want to store employees in a HashMap. Here's the code:
class Employee {
int id;
String name;
String salary;
public Employee(int id, String name, String salary) {
this.id = id;
this.name = name;
this.salary = salary;
}
@Override
public String toString() {
return id + " " + name + " " + salary;
}
}
class Main {
public static void main(String[] args) {
Employee e1 = new Employee(1, "vikas", "100");
Employee e2 = new Employee(1, "vikas", "100");
HashMap<Employee, String> hs = new HashMap<>();
hs.put(e1, "xyz");
hs.put(e2, "xyz");
System.out.println(hs);
}
}I'm creating two Employee objects with the exact same data — same id, same name, same salary. Then I put both into a HashMap with the same value.
Common sense says the HashMap should have one entry, right? Since both keys look identical.
But when I run this, I get:
{1 vikas 100=xyz, 1 vikas 100=xyz}Two entries. For what looks like the same key. Printed identically.
If you've ever run into this, you know how confusing it is. Let's break down exactly why this happens and how to fix it.
Meet the Two Heroes: hashCode() and equals()
Before we fix anything, you need to understand two methods that every Java object inherits from the Object class:
hashCode()— returns an integer that represents the objectequals()— returns true if two objects are considered "equal"
Every class in Java — including your Employee class — inherits these methods automatically. You get them for free without writing a single line of code.
The problem is: the default versions aren't smart enough for most use cases.
How HashMap Actually Works
To understand the bug, you need a basic mental model of HashMap.
A HashMap is like a chest of drawers. It has a bunch of buckets (drawers) — 16 by default. When you call map.put(key, value):
- HashMap calls
key.hashCode()to get a number - It uses that number to pick a bucket (drawer)
- It checks if the key already exists in that bucket using
equals() - If it exists → replace the value
- If it doesn't → add as a new entry
Here's the crucial insight:
hashCode()decides which bucket.equals()decides whether the key is already there.
Both steps are needed. If hashCode() sends two "equal" objects to different buckets, HashMap never even gets to step 3. And that's exactly what's happening in our code.
Why Our Code Fails
Let's trace through what actually happens.
When you write new Employee(...) twice, Java creates two separate objects in memory. They live at different memory locations — they're physically different things, even if their fields happen to match.
The default hashCode() inherited from Object is based on object identity, not field values. Each object gets its own unique hash code when it's created.
So when I add some debug prints to my code:
System.out.println("e1 hashCode: " + e1.hashCode());
System.out.println("e2 hashCode: " + e2.hashCode());
System.out.println("e1.equals(e2): " + e1.equals(e2));
System.out.println("e1 == e2: " + (e1 == e2));I get:
e1 hashCode: 1836019240
e2 hashCode: 325040804
e1.equals(e2): false
e1 == e2: falseThe hash codes are completely different numbers. And equals() — also inherited from Object — just compares memory references, so it returns false too.
Here's what HashMap does with that:
Step 1: put(e1, "xyz")
e1.hashCode()= 1836019240 → maps to bucket 8- Bucket 8 is empty → add Entry(e1, "xyz")
Step 2: put(e2, "xyz")
e2.hashCode()= 325040804 → maps to bucket 4- Bucket 4 is empty → add Entry(e2, "xyz")
Two entries, in two completely different buckets. HashMap never even attempted to compare them, because from its perspective, they were never candidates to be the same key.
Bucket 4 → Entry(e2, "xyz")
Bucket 8 → Entry(e1, "xyz")This is why we see two entries in the output.
Key insight: HashMap has no idea what "same data" means for your Employee class. It only knows what
hashCode()andequals()tell it. If you don't override these methods, HashMap treats everynew Employee()as a completely unique key — no matter what data it contains.
The Fix: Override Both Methods
Let's teach Employee how to recognize itself properly. We add two methods:
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Employee)) return false;
Employee other = (Employee) o;
return id == other.id
&& Objects.equals(name, other.name)
&& Objects.equals(salary, other.salary);
}
@Override
public int hashCode() {
return Objects.hash(id, name, salary);
}Let me explain what each line does.
The equals() method
if (this == o) return true;If you're comparing the object to itself, obviously equal. Quick shortcut.
if (!(o instanceof Employee)) return false;If the other thing isn't even an Employee, they can't be equal. (This also handles null because null instanceof Employee is false.)
Employee other = (Employee) o;
return id == other.id
&& Objects.equals(name, other.name)
&& Objects.equals(salary, other.salary);Compare field by field. Objects.equals() is a null-safe way to compare — it won't throw a NullPointerException if one of the strings is null.
The hashCode() method
return Objects.hash(id, name, salary);Objects.hash() takes any number of fields and combines them into a single hash code. Two employees with the same id, name, and salary will now produce the same hash code.
The Golden Rule
If two objects are equal according to
equals(), they MUST have the samehashCode().
That's why we always use the same fields in both methods. If equals() compares id, name, and salary, then hashCode() must be built from id, name, and salary.
Running the Fixed Code
With both methods overridden, here's the output:
e1 hashCode: 24864515
e2 hashCode: 24864515 ← SAME now!
e1.equals(e2): true ← considered equal
e1 == e2: false ← still different objects in memory
{1 vikas 100=xyz} ← ONE entry!Let's trace through HashMap again:
Step 1: put(e1, "xyz")
e1.hashCode()= 24864515 → bucket 3- Bucket 3 is empty → add Entry(e1, "xyz")
Step 2: put(e2, "xyz")
e2.hashCode()= 24864515 → bucket 3- Bucket 3 is not empty — it has Entry(e1, …)
- Walk the bucket: is
e2.equals(e1)? Yes! - Match found → replace the value, don't add a new entry
Size stays at 1. Exactly what we wanted.
One Detail That Often Trips People Up
Notice this line in the output:
e1 == e2: falseEven after overriding equals(), == still returns false. That's not a mistake.
== on objects compares memory references — it asks "are these literally the same object?" And they're not. e1 and e2 are two separate objects in memory; they just happen to contain the same data.
equals() answers a different question: "do these two objects represent the same thing logically?"
== is a language-level operator. You can't override it. equals() is the method you override to define logical equality for your class. Use equals() to compare content; use == only when you need to check reference identity.
What If You Only Override One Method?
This is worth understanding because it's a common mistake.
Only overriding equals()
equals() says e1 and e2 are equal, but hashCode() (still the default) gives different numbers → different buckets → HashMap never compares them → two entries. Bug stays.
Only overriding hashCode()
Both objects land in the same bucket, HashMap walks the bucket and calls equals() — but equals() is still the default, which compares references → returns false → HashMap treats them as a collision → two entries. Still broken.
Override one without the other and you've actually made things worse — you've violated Java's contract and created subtle, hard-to-debug behavior.
Always override them together. Or don't override them at all.
An Apartment Building Analogy
If the method talk feels abstract, here's a mental picture.
Imagine HashMap is an apartment building. You want to find your friend who lives there.
hashCode()tells you the floor. It narrows 500 apartments down to maybe 10 on one floor. Fast.equals()tells you the right apartment on that floor. You knock on each one and ask "is this my friend?"
Both steps matter. If hashCode() sends you to the wrong floor, it doesn't matter how good your equals() is — you'll never find your friend. And if equals() can't properly identify your friend once you're on the right floor, you'll miss them even though you're standing right outside their door.
A Shortcut: Use Lombok or Records
Writing equals() and hashCode() by hand every time is tedious. Here are two modern shortcuts:
Java Records (Java 14+)
If your class is mainly for holding data:
public record Employee(int id, String name, String salary) {}That's the entire class. Java automatically generates equals(), hashCode(), toString(), and accessor methods — all following the contract correctly.
Lombok
Add the @Data annotation:
@Data
class Employee {
private int id;
private String name;
private String salary;
}Lombok generates equals/hashCode/toString/getters/setters at compile time. One caveat: don't use @Data on JPA entities — it causes problems with lazy loading and bidirectional relationships. For entities, use @EqualsAndHashCode(onlyExplicitlyIncluded = true) on the ID field.
What's Really Happening With Default hashCode()
A quick note for the curious: the default Object.hashCode() is often explained as "the memory address of the object." That's a useful simplification, but it's not technically true in modern JVMs.
The JVM actually assigns each object an arbitrary integer on first access and stores it in the object's header, where it stays for the object's lifetime. It needs to be stable even when the garbage collector moves the object around in memory, so it can't literally be the address.
The practical behavior is the same either way: two new calls produce two different hash codes. That's the part that matters for understanding HashMap.
Try It Yourself
The best way to internalize this is to run the broken version and the fixed version side by side. Remove the overrides, run it, see the duplicate entries. Add them back, run it again, see the single entry. Add print statements to watch the hash codes change.
Once you've seen it happen in your own terminal, you'll never forget it.
If this cleared something up for you, give it a clap so other Java developers can find it. Questions or edge cases you've run into? Drop them in the comments — I read them all.