JAVA / NESTED CLASSES AND THE OBJECT CONTRACT
hashCode and why equal objects must hash equally
Write hashCode so it agrees with equals, and predict why HashSet and HashMap silently lose objects when it does not.
What you will learn
- State the contract: equal objects must share a hash code, unequal ones may too
- Explain why HashSet.contains fails right after add when hashCode is inherited
- Build hashCode from exactly the fields equals reads, or a subset of them
- Spot mutable keys whose changing hash strands an entry in the wrong bucket
Understanding hashCode and why equal objects must hash equally
Object.hashCode returns an int, and the contract binding it to equals runs in one direction only: if a.equals(b) is true then a.hashCode() must equal b.hashCode(), while matching hash codes imply nothing at all. The asymmetry exists because hash-based containers use the int to pick a bucket and only ever call equals on the few entries already sitting in that bucket. Picture hashCode as the label on a filing drawer and equals as reading the folders inside it: two identical forms filed in different drawers will never be recognised as duplicates, no matter how carefully each folder is read.
That is exactly what happens when a class overrides equals but inherits Object.hashCode, since the inherited version derives from the object's identity and hands two equal-but-distinct instances unrelated ints. HashMap spreads the hash, indexes its table with it, then compares the stored hash before it will even call equals, so a mismatch means equals is never reached: contains returns false immediately after add, get returns null, and add quietly stores a second copy. Nothing fails at compile time and nothing fails in a plain a.equals(b) check, which is why the bug usually surfaces long after the equals method was written.
The same consistency requirement applies over time, not just across instances: while an object is a key in a HashMap its hash code must not change, or the entry stays in the bucket computed at insertion and becomes unreachable through get and remove even though iteration still shows it. Build hashCode from exactly the fields equals compares or a subset of them; Objects.hash(f1, f2) is fine for reference fields, and h = 31 * h + field avoids the array allocation and boxing that Objects.hash performs on a hot path. Returning a constant is legal and never incorrect, only slow, because every key lands in one bucket; records generate equals and hashCode together from the same components, which is why they never fall into this trap.
import java.util.HashSet;
import java.util.Set;
public class HashCodeDemo {
// equals overridden, hashCode inherited from Object
static final class Broken {
private final String sku;
Broken(String sku) { this.sku = sku; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Broken)) return false;
return sku.equals(((Broken) o).sku);
}
}
// equals and hashCode read the same field
static final class Fixed {
private final String sku;
Fixed(String sku) { this.sku = sku; }
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Fixed)) return false;
return sku.equals(((Fixed) o).sku);
}
@Override
public int hashCode() {
return sku.hashCode();
}
}
public static void main(String[] args) {
Set<Broken> broken = new HashSet<>();
broken.add(new Broken("A-100"));
System.out.println("Broken equals: " + new Broken("A-100").equals(new Broken("A-100")));
System.out.println("Broken contains: " + broken.contains(new Broken("A-100")));
broken.add(new Broken("A-100"));
System.out.println("Broken size: " + broken.size());
Set<Fixed> fixed = new HashSet<>();
fixed.add(new Fixed("A-100"));
System.out.println("Fixed equals: " + new Fixed("A-100").equals(new Fixed("A-100")));
System.out.println("Fixed contains: " + fixed.contains(new Fixed("A-100")));
fixed.add(new Fixed("A-100"));
System.out.println("Fixed size: " + fixed.size());
}
}hashCode chooses the bucket that decides whether equals is ever consulted, so equal objects with different hash codes can never be found.
Worked examples
A mutable key that hides from its own map
Changing a field used by hashCode after insertion makes the entry unreachable through get while it stays in the table.
import java.util.HashMap;
import java.util.Map;
import java.util.Objects;
public class MutableKeyDemo {
static final class Tag {
private String name;
Tag(String name) { this.name = name; }
void rename(String name) { this.name = name; }
@Override
public boolean equals(Object o) {
return o instanceof Tag && Objects.equals(name, ((Tag) o).name);
}
@Override
public int hashCode() {
return Objects.hashCode(name);
}
}
public static void main(String[] args) {
Tag key = new Tag("draft");
Map<Tag, Integer> counts = new HashMap<>();
counts.put(key, 7);
System.out.println("get before rename: " + counts.get(key));
key.rename("final");
System.out.println("get after rename: " + counts.get(key));
System.out.println("map size: " + counts.size());
System.out.println("stored key is now: " + counts.keySet().iterator().next().name);
}
}Example explained
Line 1put files the entry in the bucket chosen by "draft".hashCode() and caches that hash inside the node.
Line 2rename mutates the only field hashCode reads, so get now computes "final".hashCode() and probes a different bucket.
Line 3The entry is not deleted: size is still 1, because size counts nodes and does not recompute hashes.
Line 4Iteration walks the table directly rather than hashing, so the lost key still prints, which is what makes this bug so confusing.
Collisions are legal, equality is not
Two unequal objects may share a hash code, and a HashSet still separates them by calling equals inside the bucket.
import java.util.HashSet;
import java.util.Objects;
import java.util.Set;
public class CollisionDemo {
static final class Pair {
private final int a;
private final int b;
Pair(int a, int b) { this.a = a; this.b = b; }
@Override
public boolean equals(Object o) {
if (!(o instanceof Pair)) return false;
Pair p = (Pair) o;
return a == p.a && b == p.b;
}
@Override
public int hashCode() {
return a + b; // legal, but (1,2) and (2,1) collide
}
}
public static void main(String[] args) {
Pair x = new Pair(1, 2);
Pair y = new Pair(2, 1);
System.out.println("x.equals(y): " + x.equals(y));
System.out.println("same hash: " + (x.hashCode() == y.hashCode()));
Set<Pair> set = new HashSet<>();
set.add(x);
set.add(y);
System.out.println("set size: " + set.size());
System.out.println("Objects.hash(1, 2): " + Objects.hash(1, 2));
System.out.println("Objects.hash(2, 1): " + Objects.hash(2, 1));
}
}Example explained
Line 1a + b never breaks the contract, because equal Pairs have equal fields and therefore equal sums.
Line 2x and y collide yet stay distinct: HashSet lands on one bucket and then calls equals, so size is 2.
Line 3Objects.hash folds each field with result = 31 * result + fieldHash, so order matters and 994 differs from 1024.
Line 4A collision-prone hash is a performance problem only; a hash that disagrees with equals is a correctness problem.
Important notes
Never shortcut equals by comparing hash codes; distinct objects are allowed to share an int, so that would report unequal objects as equal.
hashCode only has to be stable within a single JVM run, so never persist it or use it as a database key; Object's default and record hashes can differ on the next launch.
Common mistakes
Overriding equals and leaving hashCode inherited: contains returns false for an object just added, and duplicates accumulate in sets.
Including a field in hashCode that equals ignores, such as a generated id or timestamp: equal objects hash differently and every map lookup misses.
Mutating a field used by hashCode while the object is a HashMap key: get and remove stop finding it, although it still shows up when iterating.
Try it yourself
Change, predict, then run
Write a final class Coord with int x and y whose equals compares only x, but whose hashCode returns Objects.hash(x, y); add new Coord(1, 1) to a HashSet and print contains(new Coord(1, 2)) together with the result of equals. Then change hashCode to Integer.hashCode(x) and rerun to watch the lookup start succeeding.
Open the Java workspaceCheck your understanding
A class overrides equals to compare only its id field, and overrides hashCode as Objects.hash(id, name). One instance with id 7 and name "a" is added to a HashSet, then contains is called with a new instance with id 7 and name "b". What happens?
- contains returns false, because the two objects hash to different buckets even though equals reports them equal
- contains returns true, because HashSet calls equals on every element it holds
- The code does not compile, since hashCode must be built from the same fields as equals
- contains returns true, but the set's size silently grows to 2
Show answer
hashCode uses name while equals ignores it, so two equal objects produce different ints and HashSet probes a bucket that does not hold the stored entry; equals is never called and the answer is false. Option 2 is tempting because HashSet really does rely on equals, but only after the hash has narrowed the search to one bucket, so a wrong hash makes the element unreachable. The compiler cannot detect this at all, which is why the failure is silent rather than a compile error.