If you override equals but not hashCode, what breaks and when?
Equal objects must produce equal hash codes. Break that and a HashMap sends lookups to a bucket the entry was never placed in, so the map holds a value you can no longer retrieve and a set silently admits duplicates. The same failure appears later if a key is mutated after insertion.
What the interviewer is scoring
- Does the candidate state both halves of the contract, not only the hash-code half
- Whether the failure is described as a lookup miss in a specific bucket rather than vaguely as "the map misbehaves"
- That they connect the contract to immutability without being led there
- Can they explain why symmetry forces a choice between getClass and instanceof
- Whether they know TreeMap ignores equals entirely and uses compareTo
Answer
Both halves of the contract
equals has four obligations that most candidates can recite: it is reflexive, symmetric, transitive, and consistent, and x.equals(null) is always false. The half that gets forgotten is the bridge to hashing, and it runs in one direction only. If two objects are equal then their hash codes must be equal. If two objects are unequal, their hash codes may collide freely — that is just a performance cost, not a correctness one.
The asymmetry matters because it tells you which mistake is fatal. Returning a constant from hashCode is legal and merely slow, since every entry piles into one bucket. Returning something derived from a field that equals ignores, or forgetting hashCode altogether so you inherit identity hashing from Object, is a correctness bug.
How the failure shows up
Hash-based lookup is two steps: compute the hash to pick a bucket, then walk that bucket comparing with equals. Overriding only equals means two objects that compare equal land in different buckets, so step one never brings them together and step two never gets to run. You call map.put(keyA, value), then map.get(keyB) with an equal key, and get null back while the entry sits in the table. A HashSet built from those objects reports a size larger than the number of distinct values, and contains returns false for a value the set is holding.
Nothing throws. That is what makes it a good interview question: the symptom surfaces far from the cause, usually as a duplicate row, a cache that never hits, or a deduplication step that quietly does nothing.
Writing the pair
import java.util.Objects;
public final class Money {
private final String currency;
private final long minorUnits;
public Money(String currency, long minorUnits) {
this.currency = Objects.requireNonNull(currency);
this.minorUnits = minorUnits;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
// getClass() rather than instanceof: a subclass that adds state would
// otherwise be equal to a Money instance while Money is not equal to it,
// which breaks symmetry.
if (o == null || getClass() != o.getClass()) return false;
Money other = (Money) o;
return minorUnits == other.minorUnits && currency.equals(other.currency);
}
@Override
public int hashCode() {
// Must be derived from exactly the fields equals compares, and no others.
return Objects.hash(currency, minorUnits);
}
}
The getClass versus instanceof choice is a real trade-off rather than a style preference. instanceof lets a subclass be equal to its superclass instance, which is what you want for something like a Hibernate proxy but which cannot be made symmetric if the subclass adds a field to the comparison. getClass keeps symmetry and costs you interoperability with proxies and generated subclasses. Declaring the class final, as above, removes the question.
Immutability is the same bug on a delay
A correct equals/hashCode pair still fails if the fields they read can change after the object is used as a key. The entry is placed in the bucket implied by the hash at insertion time and stays there. Mutate a field, and later lookups compute a different hash and search a different bucket, so the entry becomes unreachable through get, contains and remove while still being visible to iteration and still holding a reference to its value. That is a genuine leak, not just a lost lookup.
This is why the honest answer to "what makes a good map key" is: something immutable. Final class, final fields, defensive copies of anything mutable on the way in and on the way out, and no reference to this escaping the constructor. Immutability also buys you safe publication across threads, because the JVM guarantees that final fields written in a constructor are visible to any thread that observes a properly constructed reference.
Where strong candidates go further
The distinguishing move is to note that equals is not the only comparison Java uses. Sorted collections — TreeMap, TreeSet, and anything backed by a Comparator — never call equals at all; they decide identity by whether compareTo returns zero. So a class whose ordering is inconsistent with its equality behaves differently depending on which collection you drop it into. BigDecimal is the canonical example: 1.0 and 1.00 are not equals, so a HashSet keeps both, while compareTo returns zero, so a TreeSet keeps one. Neither collection is broken. The class simply has two notions of sameness, and picking a collection picks one of them.
The second move is to be precise about records. A record generates equals and hashCode from its components, which is correct and saves you the boilerplate above, but it delegates per component. A component whose type is an array gets Object's reference-based comparison, so two records holding arrays with identical contents are not equal. A component that is a mutable List makes the record's hash code mutable along with it.
Overriding one of
equalsandhashCodewithout the other is not a partial implementation, it is a broken one, and the collection will not tell you. The related discipline is that anything you use as a key should be immutable, because a mutated key produces exactly the same silent failure months after the code that broke it shipped.
Likely follow-ups
- Why is a mutated key a memory leak and not just a failed lookup?
- BigDecimal treats 1.0 and 1.00 as unequal under equals but equal under compareTo. What does that do to a HashSet versus a TreeSet?
- How would you write equals for a JPA entity whose ID is database-generated?
- Does a record's generated equals do the right thing for every component type?
Related questions
- How would you design a thread-safe component, and why is adding synchronized to every method not a design?hardAlso on immutability7 min
- When is a factory the right answer, when is a builder, and when should you just call the constructor?mediumAlso on immutability7 min
- How would you design a ledger so that any balance can always be proved?hardAlso on immutability5 min
- Adding a second LEFT JOIN doubled the revenue figure on a report. Explain what happened and write the correct query.mediumSame kind of round: coding4 min
- Your test still reaches the network even though you patched the client. Talk me through fixture scope, parametrisation, and where a patch has to point.mediumSame kind of round: concept4 min
- This form marks invalid fields with red text and a red border. What has to change?mediumSame kind of round: concept4 min
- How is `this` determined in JavaScript, and why does a method lose it when you pass it as a callback?mediumSame kind of round: concept4 min
- How do you remove elements from a collection while iterating it, and what makes an iterator fail-fast?mediumSame kind of round: concept5 min