Demo: the broken HashSet and the equals/hashCode fix
On This Lesson 11 topics
Lesson Overview
This is a follow-along demo. You will put Money objects into a HashSet, watch duplicates appear, make a half-fix that still fails, and then finish the fix with hashCode. The planted mistake is the one Lesson 3 warned about: overriding equals without hashCode. Seeing it fail once, safely, is the best protection.
The demo is also the rehearsal for this module's lab, "Broken set": put Money objects into a set with no overrides, with only equals, and with both, record the set size each time, and explain the duplicates. If you can follow all steps here, the lab is a repeat with your own variations.
A set is a preview. HashSet belongs to the collections module, taught later. Treat it as a black box with three operations you need now: add(x) puts an element in, contains(x) asks whether an equal element is there, and size() counts the elements. A set should never hold two equal elements. How it finds them inside is not our topic today, except for the one hint from Lesson 3: it looks at hashCode() first.
Menus and shortcuts differ between IDEs and operating systems, so the steps are described by action ("compile and run"). A terminal with javac and java, or any IDE's run button, works equally well.
This lesson uses Lessons 1 to 5 of the module.
Learning Objectives
By the end of this lesson you will be able to:
- Reproduce duplicates in a
HashSetcaused by missingequals. - Explain why overriding only
equalsstill leaves duplicates. - Fix the problem with a matching
hashCode. - Add
toStringto read the results. - Show how a mutable field in
hashCodemakes an element unfindable. - State the lab's acceptance criterion and check it yourself.
Written Explanation
The plan
Do the steps in order, and run the program after each one.
| Step | Action | What you should see |
|---|---|---|
| 1 | Create Money with cents and currency, no overrides | Compiles |
| 2 | Add new Money(100, "SAR") twice to a set | Size is 2 |
| 3 | Override only equals | Size is still 2 |
| 4 | Planted mistake: explain why | The set never compared them |
| 5 | Add hashCode with Objects.hash(cents, currency) | Size is 1 |
| 6 | Add toString | Readable output |
| 7 | Bonus: mutate a field used in hashCode | contains fails |
For this demo we use three versions of the class. In your own project you would edit one class step by step; here they are separate so all results appear in one run.
What the set promises
The Java documentation defines the set's behavior in terms of equality: add puts an element in only when no element already inside is equal to it (by Objects.equals), and contains asks the same question. It also says the set is backed by a hash table, which is why the hash code matters. So the promise is "no equal duplicates", but the machinery needs a consistent hashCode to keep that promise. Our experiment tests exactly that gap between the promise and the machinery.
For the lab, record each experiment in a small table, with three columns: the version of Money, the set size, and the result of contains for an equal copy. Writing down the numbers before explaining them keeps the explanation honest: you explain what you observed, not what you expected.
Step 1 and 2: no overrides
class Money1 {
final long cents; final String currency;
Money1(long c, String cur) { cents = c; currency = cur; }
}
HashSet<Money1> s1 = new HashSet<>();
s1.add(new Money1(100, "SAR"));
s1.add(new Money1(100, "SAR"));
System.out.println("no overrides: " + s1.size());The set sees two different objects: the default equals is identity and the default hashCode follows identity. The business sees one amount entered twice.
Step 3 and 4: only equals (the planted mistake)
class Money2 {
final long cents; final String currency;
Money2(long c, String cur) { cents = c; currency = cur; }
@Override public boolean equals(Object o) {
return o instanceof Money2 m && cents == m.cents && currency.equals(m.currency);
}
}Now new Money2(100, "SAR").equals(new Money2(100, "SAR")) is true. It feels fixed. Run it and the set still has two elements. Why? The hashCode is still the identity-based default, so the two equal objects return different hash codes. The set uses the hash code to decide where to look, finds nothing to compare in that place, and never calls equals at all. The contract was broken: equal objects with different hashes.
Step 5 and 6: add hashCode and toString
class Money3 {
final long cents; final String currency;
Money3(long c, String cur) { cents = c; currency = cur; }
@Override public boolean equals(Object o) {
return o instanceof Money3 m && cents == m.cents && currency.equals(m.currency);
}
@Override public int hashCode() { return Objects.hash(cents, currency); }
@Override public String toString() { return "Money[" + cents + " " + currency + "]"; }
}Equal objects now produce the same hash code, so the set places them together and then equals identifies the duplicate.
Step 7: a mutable field in hashCode
class MutableMoney {
long cents; final String currency; // cents can change
// constructor, equals and hashCode over cents and currency
}Put one in a set, change cents, then call contains on the same object. The object is still stored, but filed under its old hash. The new hash points elsewhere, so the lookup fails.
Examples
Example 1: the three sets side by side (simple)
HashSet<Money1> s1 = new HashSet<>();
s1.add(new Money1(100, "SAR")); s1.add(new Money1(100, "SAR"));
System.out.println("no overrides: " + s1.size());
HashSet<Money2> s2 = new HashSet<>();
s2.add(new Money2(100, "SAR")); s2.add(new Money2(100, "SAR"));
System.out.println("equals only: " + s2.size() + " contains="
+ s2.contains(new Money2(100, "SAR")));
HashSet<Money3> s3 = new HashSet<>();
s3.add(new Money3(100, "SAR")); s3.add(new Money3(100, "SAR")); s3.add(new Money3(250, "SAR"));
System.out.println("both: " + s3.size() + " contains="
+ s3.contains(new Money3(100, "SAR")));Real output:
no overrides: 2
equals only: 2 contains=false
both: 2 contains=trueThe last set received three adds but holds two elements: 100 SAR once and 250 SAR once. The middle line is the dangerous one: even contains fails for an amount that is in the set.
A word of caution: with only equals, two objects could sometimes share a bucket by chance and appear to work, because hash codes of different objects may happen to clash. The failure is not guaranteed on every run, which is exactly why it is a nasty bug. Our run showed 2, as expected from the contract.
Example 2: reading results with toString (practical)
System.out.println(new Money3(100, "SAR"));
System.out.println(new Money2(100, "SAR").hashCode()
== new Money2(100, "SAR").hashCode());Money[100 SAR]
falseThe second line is the contract check: two equal Money2 objects with different hash codes. That single false explains the duplicates, and it makes a handy unit test: "equal objects must have equal hash codes".
Example 3: the vanishing element (realistic)
HashSet<MutableMoney> s4 = new HashSet<>();
MutableMoney m = new MutableMoney(100, "SAR");
s4.add(m);
System.out.println(s4.contains(m));
m.cents = 500;
System.out.println(s4.contains(m) + " size=" + s4.size());
m.cents = 100;
System.out.println(s4.contains(m));true
false size=1
trueThe set still holds the object (size=1), but contains cannot find it after the mutation, and finds it again when the value is restored. In a real service, an object in a cache or a set of processed ids that mutates this way would cause duplicate processing and no error message anywhere. The fix is to base hashCode on final fields or on a stable id.
Backend Insight
This bug appears in real systems in disguised forms: a "processed message ids" set that fails to prevent a duplicate payment, a cache that never hits, a map that grows forever because every lookup misses. None of these throw an exception. They show up as wrong counts, repeated work, or memory growth, often long after the code was written.
The defense is cheap and uses what you know:
- Always override
equalsandhashCodetogether, using the same fields. - Prefer immutable value types (final fields, or a record from Lesson 5).
- Add a small test: equal objects have equal hash codes, and an object stays findable in a set after the operations your code performs.
Common Mistakes
Mistake 1: Overriding only equals
The set still has duplicates, as step 3 showed. Change both together.
Mistake 2: Trusting a single successful run
An accidental hash clash can hide the bug in one test. Check the contract directly: compare hashCode() values for equal objects.
Mistake 3: Mutating a field that is part of hashCode
Stored objects become unfindable. Use final fields or an id.
Mistake 4: Testing only the size
Also test contains for an equal-but-different object. In step 3, size and contains together told the story.
Exercises
Try each exercise yourself before opening the solutions.
Level A: Foundation
A1. What size should a set report after adding new Money3(100, "SAR") three times? Why?
Show solution A1
A1. One. The three objects are equal and have the same hash code, so the set keeps a single element.
A2. In the demo, why does the equals-only set report 2?
Show solution A2
A2. The hash codes of equal objects differ (identity-based default), so the set does not compare them with equals and keeps both.
A3. What does contains ask?
Show solution A3
A3. Whether an element equal to the argument is in the set.
Level B: Application
B1. A class Tag(String label) has hashCode only (using label) and no equals. Predict what happens when two Tag("x") objects are added to a set, and explain.
Show solution B1
B1. The set will probably hold two elements. The default equals is identity, so the two objects are not equal even though they hash the same (they share a bucket, but equals says they differ). hashCode alone does not make objects equal.
B2. Write hashCode for Money3 using only cents. Is it still correct? Would it be good?
Show solution B2
B2. return Long.hashCode(cents); is still correct, because equal objects have equal cents and therefore equal hashes. But it is poor, since all Money with the same cents and different currencies clash, which is slower. Objects.hash(cents, currency) is better.
B3. Which of these test lines would reveal the demo bug fastest: a.equals(b), or a.hashCode() == b.hashCode() for two equal objects?
Show solution B3
B3. The second: a.hashCode() == b.hashCode() for two equal objects. equals was already true in the demo, so it hides the problem.
Level C: Challenge
C1. Your Order(long id, String note) class uses Objects.hash(id, note) and note can be edited by staff after an order is placed in a set of "open orders". Describe the symptom and rewrite hashCode.
Show solution C1
C1. Symptom: after a note edit, contains(order) returns false and the order may be processed twice or never removed. Fix: base equals/hashCode on id only: return Long.hashCode(id); (and compare id in equals).
C2. Write a short checklist of three tests you would write for any value class.
Show solution C2
C2. For example: (1) equals is reflexive, symmetric, and false for null and for another class; (2) equal objects have equal hash codes; (3) an object added to a set can be found with contains using an equal copy.
Mini Challenge
This is the lab, "Broken set". Create a Money class with cents and currency. Run three experiments with a HashSet: no overrides, only equals, and both. For each, add new Money(100, "SAR") twice and a new Money(250, "SAR") once, and record size() and contains(new Money(100, "SAR")). Write a short explanation (a few sentences) of the duplicates in your own words. Add toString to print readable output.
Check your work (acceptance): the final version shows size 2 and contains true; you can explain that the equals-only version fails because the hash codes of equal objects differ, so the set never calls equals.
Summary & Next Lesson
Key takeaways
- With no overrides, a set treats every object as unique.
- Overriding only
equalsis a half-fix: equal objects get different hash codes, so the set still holds duplicates andcontainscan fail. - Overriding
equalsandhashCodetogether, from the same fields, fixes it. - A mutable field in
hashCodecan make a stored element unfindable. - Test the contract directly: equal objects must have equal hash codes.
Next lesson
This was the last lesson of this module. After the lab, the course continues with module BEG-027. The collections you previewed here, HashSet and its relatives, are explained properly in a later module (BEG-030), where your equals/hashCode pair will matter again.
Self-check
- What size does a set show after two adds of equal
Moneyobjects with no overrides? - Why does overriding only
equalsnot help? - Which line fixes the duplicates?
- What is the quickest direct check of the contract?
- Why can a mutated element vanish from a set?
Show answers
- Two, because the default
equalsis identity. - Equal objects keep different hash codes, so the set never compares them.
- A
hashCodethat uses the same fields asequals, such asObjects.hash(cents, currency). - Compare
hashCode()of two equal objects. - Its hash changes, so the set looks in a different place than where it was stored.
References
- Oracle, Java SE 21 API:
java.lang.Object. It states that equal objects must have equal hash codes and thathashCodeshould be overridden wheneverequalsis. - Oracle, Java SE 21 API:
java.util.Objects. It documentsObjects.hash, which combines the given fields. - Oracle, Java SE 21 API:
java.util.HashSet. It says aHashSetis backed by a hash table (aHashMapinstance), thataddonly adds an element when no existing element is equal byObjects.equals, and thatcontainsis defined the same way. - The explanation that a hash-based set picks a place from the hash code first, and only then calls
equals, is a simplified description that was not checked against theHashSetdocumentation. The program outputs above were produced by compiling and running on JDK 21.
Practice
Apply this lesson with the module's lab, exercises and quiz.
Marking a lesson complete is optional and you can undo it any time. Your progress stays in this browser.