On This Page
Start with the finished app
Here's the output of the program you'll have at the end. Three of the five sections are bugs I forced on purpose. The fourth is the class that fixes them.
--- 1. equals without hashCode ---
a.equals(b): true
set contains an equal parcel? false
set size after adding an 'equal' one: 2
--- 2. mutable key ---
before change: In transit
after change: null
map still holds it: 1 entry, containsKey=false
--- 3. subclass breaks symmetry ---
plain.equals(express): true
express.equals(plain): false
--- 4. the fixed Parcel ---
a.equals(b): true
hash codes equal: true
set contains b: true
after reweigh, set still contains a: true
different carrier equal? false
equals(null): false
--- 5. record ---
true
Read the first block twice. a.equals(b) is true, and the HashSet still says it doesn't contain an equal parcel. That's the most common equals() and hashCode() bug in Java, and it doesn't throw anything. The parcel just vanishes.
This is for you if you've written equals before and weren't sure it was right, or if you've let the IDE generate it and never looked at the result. By the end you'll know how to implement equals and hashCode in Java so that HashMap, HashSet and List.contains all agree, and you'll have 16 runnable checks to copy into your own project.
The domain is a parcel tracker. A parcel is identified by its carrier and tracking number; its weight and recipient can change. That split, identity versus state, is the whole design decision.
Versions I used
| Tool | Version I used | Check it with |
|---|---|---|
| JDK | OpenJDK 21.0.12.1 (Ubuntu build) | java -version |
| javac | 21.0.12.1 | javac -version |
| Build tool | none, plain javac |
No libraries, no framework, nothing to download. Anything from Java 16 up will compile the code as written (it uses instanceof pattern matching and a record). On Java 11 or older, replace the pattern variables with a cast.
I built and ran everything from the command line this run. IntelliJ IDEA and Eclipse steps below come from each IDE's documentation; I didn't click through them. The whole thing took me about 40 minutes, and most of that was provoking the bugs in a way that printed something clear rather than crashed.
You should already know what a HashMap is and what == does on objects. That's it.
Create the project
The primary path here is the command line. Make a folder and the package directories:
mkdir -p parcels/src/main/java/com/example/parcels
cd parcels
The finished tree:
parcels
└── src
└── main
└── java
└── com
└── example
└── parcels
├── App.java
├── BrokenParcel.java
├── ExpressParcel.java
├── LooseParcel.java
├── MutableKeyParcel.java
├── Parcel.java
├── ParcelChecks.java
└── ParcelRecord.java
In IntelliJ IDEA: File → New → Project, choose Java as the generator, pick a JDK 21, and leave the build system on IntelliJ. Right-click src → New → Package → com.example.parcels, then add each class below. IDEA can generate equals and hashCode through Code → Generate → equals() and hashCode(); I'll say what to watch for in that wizard later.
In Eclipse: File → New → Java Project, select a JavaSE-21 execution environment, and create the package under src. Eclipse's version of the generator is Source → Generate hashCode() and equals().
With a build tool: The code has no dependencies, so any mvn archetype:generate or gradle init skeleton works. Put the files under src/main/java.
Write the code
Two rules before the code. First, equals and hashCode are one contract, and you change them together or not at all. Second, use only fields that define identity. Everything else stays out.
Step 1: Write the class that has equals() but no hashCode()
Goal: see the bug first, so you recognise it later.
src/main/java/com/example/parcels/BrokenParcel.java
package com.example.parcels;
import java.util.Objects;
/** equals() without hashCode(), and a mutable field that takes part in both. */
public class BrokenParcel {
private String trackingNumber;
public BrokenParcel(String trackingNumber) {
this.trackingNumber = trackingNumber;
}
public String getTrackingNumber() {
return trackingNumber;
}
public void setTrackingNumber(String trackingNumber) {
this.trackingNumber = trackingNumber;
}
@Override
public boolean equals(Object o) {
return o instanceof BrokenParcel other
&& Objects.equals(trackingNumber, other.trackingNumber);
}
// no hashCode(): the bug
@Override
public String toString() {
return "BrokenParcel[" + trackingNumber + "]";
}
}
Why it matters: equals says two parcels with the same tracking number are the same. But HashSet and HashMap don't call equals first. They call hashCode, pick a bucket, and only then compare with equals inside that bucket. Without an override, hashCode comes from Object and is based on identity, so two equal parcels land in different buckets and never meet.
Checkpoint: compile it.
javac -d out src/main/java/com/example/parcels/BrokenParcel.java
No output means success.
Step 2: Write the class whose hash code can change
Goal: show that a correct pair still fails if the fields it reads can change.
src/main/java/com/example/parcels/MutableKeyParcel.java
package com.example.parcels;
import java.util.Objects;
/** equals() and hashCode() agree, but the fields they use can change. */
public class MutableKeyParcel {
private String trackingNumber;
public MutableKeyParcel(String trackingNumber) {
this.trackingNumber = trackingNumber;
}
public void setTrackingNumber(String trackingNumber) {
this.trackingNumber = trackingNumber;
}
@Override
public boolean equals(Object o) {
return o instanceof MutableKeyParcel other
&& Objects.equals(trackingNumber, other.trackingNumber);
}
@Override
public int hashCode() {
return Objects.hash(trackingNumber);
}
@Override
public String toString() {
return "MutableKeyParcel[" + trackingNumber + "]";
}
}
The pair here is right. hashCode uses the same single field as equals. The problem is setTrackingNumber. If you put one of these in a HashSet or use it as a HashMap key and then change the number, the object sits in a bucket chosen by its old hash.
Step 3: Write the subclass that breaks symmetry
Goal: see why instanceof in equals and a subclass that adds state don't mix.
src/main/java/com/example/parcels/LooseParcel.java
package com.example.parcels;
import java.util.Objects;
/** Uses instanceof in equals on a non-final class. Fine until someone subclasses it. */
public class LooseParcel {
protected final String trackingNumber;
public LooseParcel(String trackingNumber) {
this.trackingNumber = trackingNumber;
}
@Override
public boolean equals(Object o) {
return o instanceof LooseParcel other
&& Objects.equals(trackingNumber, other.trackingNumber);
}
@Override
public int hashCode() {
return Objects.hash(trackingNumber);
}
}
src/main/java/com/example/parcels/ExpressParcel.java
package com.example.parcels;
import java.util.Objects;
/** Adds a field and extends equals to include it. That breaks symmetry. */
public class ExpressParcel extends LooseParcel {
private final String deliveryWindow;
public ExpressParcel(String trackingNumber, String deliveryWindow) {
super(trackingNumber);
this.deliveryWindow = deliveryWindow;
}
@Override
public boolean equals(Object o) {
return o instanceof ExpressParcel other
&& super.equals(other)
&& Objects.equals(deliveryWindow, other.deliveryWindow);
}
@Override
public int hashCode() {
return Objects.hash(trackingNumber, deliveryWindow);
}
}
ExpressParcel does what most of us would do: call super.equals, then compare the new field. The catch is that plain.equals(express) only asks the LooseParcel rules, so it says yes, and express.equals(plain) fails the instanceof ExpressParcel test. One answer one way, a different one the other.
Step 4: Write the Parcel that gets it right
Goal: the class you'd actually ship.
src/main/java/com/example/parcels/Parcel.java
package com.example.parcels;
import java.util.Objects;
/**
* A parcel is identified by its carrier and tracking number.
* Weight and recipient can change over the parcel's life, so they stay out of equals/hashCode.
*/
public class Parcel {
private final String carrier;
private final String trackingNumber;
private String recipient; // mutable, not part of identity
private int weightGrams; // mutable, not part of identity
public Parcel(String carrier, String trackingNumber, String recipient, int weightGrams) {
this.carrier = Objects.requireNonNull(carrier, "carrier");
this.trackingNumber = Objects.requireNonNull(trackingNumber, "trackingNumber").trim().toUpperCase();
this.recipient = recipient;
this.weightGrams = weightGrams;
}
public String carrier() { return carrier; }
public String trackingNumber() { return trackingNumber; }
public String recipient() { return recipient; }
public int weightGrams() { return weightGrams; }
public void reweigh(int grams) { this.weightGrams = grams; }
@Override
public boolean equals(Object o) {
if (this == o) return true; // fast path
if (o == null || getClass() != o.getClass()) return false;
Parcel other = (Parcel) o;
return carrier.equals(other.carrier)
&& trackingNumber.equals(other.trackingNumber);
}
@Override
public int hashCode() {
return Objects.hash(carrier, trackingNumber);
}
@Override
public String toString() {
return "Parcel[" + carrier + ":" + trackingNumber + ", " + weightGrams + "g]";
}
}
Decisions worth checking line by line:
carrierandtrackingNumberarefinal. They define identity, so they can't change after construction. That closes the mutable-key hole from Step 2 without any extra code.recipientandweightGramschange over a parcel's life, so they stay out of both methods. Reweighing a parcel doesn't move it to a different bucket.- The tracking number is trimmed and upper-cased in the constructor. Normalising at the door means
equalscan use a plainequalscomparison. getClass() != o.getClass()rather thaninstanceof. It's stricter: a subclass instance is never equal to aParcel. That keeps symmetry for free. I'd pick it for any class that isn'tfinaland has no reason to be compared across a hierarchy. If you need cross-subtype equality, make the classfinaland useinstanceof, or put the equality rules in one base class and makeequalsfinal there.Objects.hash(carrier, trackingNumber)is readable and correct. It allocates an array per call, which only matters in a very hot path. If a profiler ever points at it, write31 * carrier.hashCode() + trackingNumber.hashCode()by hand.
Checkpoint: compile everything so far.
javac -d out $(find src -name '*.java')
On Windows PowerShell, list the files explicitly, since find isn't there. You should see no output.
Step 5: Add the one-liner version
Goal: the shortest correct answer when your class is a plain data holder.
src/main/java/com/example/parcels/ParcelRecord.java
package com.example.parcels;
/** The short version: a record gets a correct equals/hashCode over all components. */
public record ParcelRecord(String carrier, String trackingNumber) {
public ParcelRecord {
trackingNumber = trackingNumber.trim().toUpperCase();
}
}
A record gets equals, hashCode and toString generated over every component. The compact constructor normalises the tracking number, same as Parcel. If all of an object's fields define identity, this is what I'd write. The catch is that you can't leave a field out of equality. A parcel with a mutable weight would want to be a class. More on this in how to use Java records.
Step 6: Write the demo
Goal: run all five scenarios in one program.
src/main/java/com/example/parcels/App.java
package com.example.parcels;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Map;
import java.util.Set;
public class App {
public static void main(String[] args) {
System.out.println("--- 1. equals without hashCode ---");
Set<BrokenParcel> broken = new HashSet<>();
broken.add(new BrokenParcel("1Z999"));
System.out.println("a.equals(b): " + new BrokenParcel("1Z999").equals(new BrokenParcel("1Z999")));
System.out.println("set contains an equal parcel? " + broken.contains(new BrokenParcel("1Z999")));
broken.add(new BrokenParcel("1Z999"));
System.out.println("set size after adding an 'equal' one: " + broken.size());
System.out.println("--- 2. mutable key ---");
Map<MutableKeyParcel, String> status = new HashMap<>();
MutableKeyParcel key = new MutableKeyParcel("1Z999");
status.put(key, "In transit");
System.out.println("before change: " + status.get(key));
key.setTrackingNumber("1Z000");
System.out.println("after change: " + status.get(key));
System.out.println("map still holds it: " + status.size() + " entry, "
+ "containsKey=" + status.containsKey(key));
System.out.println("--- 3. subclass breaks symmetry ---");
LooseParcel plain = new LooseParcel("1Z999");
ExpressParcel express = new ExpressParcel("1Z999", "9-11");
System.out.println("plain.equals(express): " + plain.equals(express));
System.out.println("express.equals(plain): " + express.equals(plain));
System.out.println("--- 4. the fixed Parcel ---");
Parcel a = new Parcel("UPS", " 1z999 ", "Asha", 1200);
Parcel b = new Parcel("UPS", "1Z999", "Someone else", 90);
Set<Parcel> parcels = new HashSet<>();
parcels.add(a);
System.out.println("a.equals(b): " + a.equals(b));
System.out.println("hash codes equal: " + (a.hashCode() == b.hashCode()));
System.out.println("set contains b: " + parcels.contains(b));
a.reweigh(1300);
System.out.println("after reweigh, set still contains a: " + parcels.contains(a));
System.out.println("different carrier equal? " + a.equals(new Parcel("DHL", "1Z999", "Asha", 1300)));
System.out.println("equals(null): " + a.equals(null));
System.out.println("--- 5. record ---");
System.out.println(new ParcelRecord("UPS", " 1z999 ").equals(new ParcelRecord("UPS", "1Z999")));
}
}
Run it
javac -d out $(find src -name '*.java')
java -cp out com.example.parcels.App
The output is the block you saw at the top. Section 2 is the quietest bug of the lot. status.size() is 1, so the entry is still in the map. It's just unreachable by key lookup, and it won't be garbage collected while the map lives. That's a slow memory leak with no error attached.
Prove it with checks
I couldn't download JUnit in this environment (Maven Central was blocked from my machine), so the checks are a small main method run with assertions on. In your own project, turn each check line into a JUnit 5 test; I'll cover that in how to write unit tests with JUnit 5.
src/main/java/com/example/parcels/ParcelChecks.java
package com.example.parcels;
import java.util.HashSet;
import java.util.Set;
/** Plain checks, run with java -ea. No test framework: nothing to download. */
public class ParcelChecks {
static int passed = 0;
static void check(String name, boolean ok) {
if (!ok) throw new AssertionError("FAILED: " + name);
System.out.println("ok " + name);
passed++;
}
public static void main(String[] args) {
Parcel a = new Parcel("UPS", "1Z999", "Asha", 1200);
Parcel b = new Parcel("UPS", "1Z999", "Ben", 50);
Parcel c = new Parcel("UPS", "1Z999", "Chi", 7);
Parcel other = new Parcel("UPS", "1Z000", "Asha", 1200);
check("reflexive", a.equals(a));
check("symmetric", a.equals(b) && b.equals(a));
check("transitive", a.equals(b) && b.equals(c) && a.equals(c));
check("consistent across calls", a.equals(b) == a.equals(b));
check("null is never equal", !a.equals(null));
check("different tracking number not equal", !a.equals(other));
check("different carrier not equal", !a.equals(new Parcel("DHL", "1Z999", "Asha", 1200)));
check("equal objects have equal hash codes", a.hashCode() == b.hashCode());
check("tracking number normalised", new Parcel("UPS", " 1z999 ", "x", 1).equals(a));
check("different class not equal", !a.equals("1Z999"));
Set<Parcel> set = new HashSet<>();
set.add(a);
check("HashSet finds an equal parcel", set.contains(b));
set.add(b);
check("HashSet keeps one of two equal parcels", set.size() == 1);
a.reweigh(5000);
check("hash code survives a reweigh", set.contains(a));
Set<BrokenParcel> broken = new HashSet<>();
broken.add(new BrokenParcel("X"));
check("BrokenParcel without hashCode is lost (this is the bug)",
!broken.contains(new BrokenParcel("X")));
LooseParcel plain = new LooseParcel("1");
ExpressParcel exp = new ExpressParcel("1", "9-11");
check("LooseParcel/ExpressParcel is asymmetric (this is the bug)",
plain.equals(exp) && !exp.equals(plain));
try {
new Parcel(null, "1", "x", 1);
check("null carrier rejected", false);
} catch (NullPointerException e) {
check("null carrier rejected: " + e.getMessage(), true);
}
System.out.println(passed + " checks passed");
}
}
java -ea -cp out com.example.parcels.ParcelChecks
ok reflexive
ok symmetric
ok transitive
ok consistent across calls
ok null is never equal
ok different tracking number not equal
ok different carrier not equal
ok equal objects have equal hash codes
ok tracking number normalised
ok different class not equal
ok HashSet finds an equal parcel
ok HashSet keeps one of two equal parcels
ok hash code survives a reweigh
ok BrokenParcel without hashCode is lost (this is the bug)
ok LooseParcel/ExpressParcel is asymmetric (this is the bug)
ok null carrier rejected: carrier
16 checks passed
The first five checks are the contract from Object.equals: reflexive, symmetric, transitive, consistent, and false for null. The hashCode check is the other half: equal objects must have equal hash codes. The reverse isn't required. Unequal objects may collide.
The parts that matter
HashMap.get(key) does two things. It calls key.hashCode() to choose a bucket, then walks that bucket calling equals. So a wrong hashCode hides objects, and a wrong equals finds the wrong ones. Both methods have to describe the same notion of "the same thing".
The Javadoc contract for Object.hashCode boils down to this: the value must stay the same while the fields used by equals stay the same, and equal objects must produce equal values. Nothing says the value has to be unique, and nothing says it has to be stable between runs. Don't persist hash codes.
For performance, how well your hashCode spreads values matters too: all-equal hash codes turn a map into a list. That's the territory of the HashMap performance article in this site's Performance series; it's planned but not published yet, so I won't link to a page that may not exist.
Errors I hit, and the fixes
I forced each one on purpose so the messages are exact.
method does not override or implement a method from a supertype
Over.java:5: error: method does not override or implement a method from a supertype
@Override
^
I'd written public boolean equals(Over o) instead of equals(Object o). That is an overload, not an override. Always put @Override on both methods; this is the only reason it complains.
Without the annotation it compiles and bites you later. I removed @Override and ran it:
overload says true
set says false
via Object ref: false
Direct calls use your overload; HashSet and List.contains call equals(Object), which is still Object's identity check.
Arrays inside Objects.hash
Objects.hash(x)==Objects.hash(y): false
Arrays.hashCode equal: true
Two int[] with the same content produce different Objects.hash values, because arrays use identity hashing. Use Arrays.hashCode and Arrays.equals for array fields, or Arrays.deepEquals for nested ones.
Floating-point fields
0.0 == -0.0: true, Double.equals: false
NaN == NaN: false, Double.compare: 0
Use Double.compare(a, b) == 0 in equals, and Double.hashCode in hashCode. Using == on doubles disagrees with the boxed rules.
Lost entries in a HashMap. No error, just null from get. Either hashCode is missing (Step 1) or a field in the key changed (Step 2). Make key fields final.
IDE generators. Both IDEs ask which fields to include. Tick only identity fields. IntelliJ also offers a template choice; java.util.Objects.equals() and hashCode() is the one I'd use. Lombok's @EqualsAndHashCode has a similar choice to make, and I'll look at it when the Lombok setup article is out.
Before you ship it
- JPA entities. Don't use all fields, and be careful with generated ids: an id that's
nullbeforepersistchanges the hash code afterwards. A business key (like carrier plus tracking number here) is safer. That's its own article. - Hibernate proxies.
getClass()comparison fails against a proxy subclass. Entities are the one place where theinstanceofform is usually right. - Mutable objects as keys. Prefer immutable keys. See how to create an immutable class in Java.
- Builders. If you build parcels with a builder, equality still lives on the built object. See how to implement the builder pattern in Java.
- Version note. Java 16 added pattern matching for
instanceofand records. Before that you'd write the cast by hand. Java 7 addedObjects.equalsandObjects.hash; before that, null-safe comparison was your job.
The short version
- Override
equalsandhashCodetogether, with@Overrideon both. - Use only fields that define identity, and make them
finalif the object will be a key. - Pick
getClass()orinstanceofon purpose. If you pickinstanceof, make the classfinal. - Use
Arrays.hashCode/Arrays.equalsfor arrays andDouble.comparefor doubles. - Prove it with checks: contract tests, plus "does a
HashSetfind an equal copy?".
FAQ: equals() and hashCode() questions I get
Do I always need to override hashCode when I override equals?
Yes. Skip it and anything in a hash-based collection (HashMap, HashSet, Collectors.toMap) can misbehave, as Section 1 showed. The reverse isn't strictly required, but a hashCode without equals is pointless.
Should I use getClass() or instanceof in equals?getClass() keeps symmetry when subclasses add state. instanceof is fine on a final class, or when a base class defines equality for the whole hierarchy and marks equals final. Hibernate entities are the usual exception.
Can I use a record instead of writing equals and hashCode?
If every component defines identity, yes. A record compares all components and you can't exclude one. For anything with changing state, a class is the better fit.
Why does Objects.hash give different results for equal arrays?
Arrays inherit identity-based hashCode from Object. Use Arrays.hashCode for arrays.