equals and hashCode traps
mutable keys, inheritance, and the object that vanished from a set.
- chapter
- chapter 17.13
- reading time
- 9 min read
a hash collection files every object under its hashCode() at the moment it went in, and finds it again by recomputing the hash and then asking equals. so a key whose hash changes while it's inside is lost: still there, never found, never removed.
the rest of the traps are about what "equal" means: getClass() vs instanceof, records comparing doubles by their bits and arrays by identity, Long keys that never match an int. each one compiles, runs, and gives the wrong answer quietly.
the picture
you know the rule "if you override equals, override hashCode"? that's the famous half. the half that bites in production is quieter: a key's hash must not change while it's in a hash collection. the contracts themselves are covered in the object methods and effective java's items 10 and 11. this chapter is about the ways code that follows them still goes wrong.
think of a library that shelves books by the first letter of the title. you shelve "Atlas" under A. then someone renames it "Zebra" with a marker, right there on the shelf. ask for "Zebra" and the librarian checks Z: nothing. ask for "Atlas" and she checks A, finds a book, reads the cover, and it says Zebra, so that's not it either. the book is in the library, and nobody will ever find it.
a key that changes
- 1Set<Point> set = new HashSet<>();
- 2Point p = new Point(1, 2);
- 3set.add(p);
- 4p.x = 7;
- 5set.contains(p);
- 6set.remove(p);
- 7set.add(p);
- 8set.contains(new Point(1, 2));
an empty HashSet: 16 buckets, nothing in them. p is a Point(1, 2) whose hashCode() uses both fields.
here's the real thing:
class Point {
int x, y; // mutable, and used by hashCode
Point(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) { return o instanceof Point p && x == p.x && y == p.y; }
@Override public int hashCode() { return Objects.hash(x, y); }
@Override public String toString() { return "(" + x + ", " + y + ")"; }
}
void main() {
Set<Point> set = new HashSet<>();
Point p = new Point(1, 2);
set.add(p);
p.x = 7; // mutate while inside the set
IO.println(set.contains(p) + " " + set.remove(p) + " " + set.size() + " " + set);
set.add(p);
IO.println(set.size() + " " + set);
}false false 1 [(7, 2)] 2 [(7, 2), (7, 2)]
contains false, remove false, size 1, and after one more add, a set holding the same object twice. nothing threw. this is also a slow memory leak: entries like that can never be removed, only cleared with the whole collection (more in memory leaks).
the fix is to make it impossible: keys with final fields (or records of immutable values), or at least a hashCode that only uses fields that never change.
getClass or instanceof
there are two ways to write the type check in equals, and each one breaks something.
with instanceof, a subclass that adds a field can't be equal both ways: point.equals(colorPoint) is true but colorPoint.equals(point) isn't (that's the ColorPoint problem, drawn out in the object methods). so people switch to getClass(), which only accepts the exact same class. that fixes symmetry and breaks something else: any subclass at all, even one that adds nothing, is never equal.
class Strict {
final int x, y;
Strict(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
if (o == null || getClass() != o.getClass()) return false; // the getClass() style
Strict s = (Strict) o;
return x == s.x && y == s.y;
}
@Override public int hashCode() { return 31 * x + y; }
}
void main() {
Strict a = new Strict(1, 2);
Strict b = new Strict(1, 2) { }; // an anonymous subclass that adds nothing
IO.println(a.equals(b) + " " + b.equals(a));
}false false
an empty anonymous subclass is contrived, but the same thing happens for real with proxies: Hibernate's lazy-loading proxies, Mockito spies, and anything that generates a subclass of your class at runtime. a getClass()-based equals says the proxy isn't equal to the entity it stands for.
instanceof | getClass() | |
|---|---|---|
| subclass that adds state | breaks symmetry | correct |
| subclass that adds nothing, proxies, mocks | correct | never equal |
| the way out | make the class (or its equals) final | don't let subclasses exist either |
so both roads end in the same place: equality and inheritance don't mix. make value classes final and use instanceof (or a pattern: o instanceof Point p), or use a record, which is final by definition.
records, and their two surprises
a record generates equals and hashCode from its components, consistently, and it's final, so most of this chapter doesn't apply. but the generated equals compares each component in a specific way, and two of them surprise people:
floatanddoublecomponents are compared likeDouble.comparedoes, by value bits, not with==.- array components are compared with the array's own
equals, which is identity.
record Temp(double celsius) {}
record Key(byte[] bytes) {}
void main() {
IO.println((0.0 == -0.0) + " " + new Temp(0.0).equals(new Temp(-0.0)));
IO.println((Double.NaN == Double.NaN) + " " + new Temp(Double.NaN).equals(new Temp(Double.NaN)));
IO.println(new Key(new byte[] {1, 2}).equals(new Key(new byte[] {1, 2})));
}true false false true false
0.0 == -0.0 is true, but they're different bit patterns, so the records aren't equal. NaN == NaN is false, but the bits match, so the records are. the Double.compare way is the right one for a hash collection (it's reflexive, and equal values get equal hashes), it's just not what == taught you. here are the two zeros, bit by bit:
0.0 and -0.0 as the 64 bits a double really is. only the sign bit differs. == says they're equal; Double.compare, and so a record, says they aren't.and the Key record with a byte[] inside is equal only to itself. a record holding a List compares contents (because List.equals does), but then its hash changes if someone mutates that list, which is the first section all over again. give records immutable components: List.copyOf(list) in the constructor, and no arrays.
arrays use Object's equals
an array is an object that never overrode equals or hashCode. both are identity: two arrays with the same contents are two different keys.
a.equals(b) asks whether the arrows point at the same object, and they don't.void main() {
int[] a = {1, 2, 3};
int[] b = {1, 2, 3};
IO.println(a.equals(b) + " " + Arrays.equals(a, b) + " " + (a.hashCode() == b.hashCode()) + " " + (Arrays.hashCode(a) == Arrays.hashCode(b)));
Set<int[]> set = new HashSet<>(List.of(a, b));
IO.println(set.size() + " " + set.contains(new int[] {1, 2, 3}));
int[][] m = {{1}, {2}};
int[][] n = {{1}, {2}};
IO.println(Arrays.equals(m, n) + " " + Arrays.deepEquals(m, n));
}false true false true 2 false false true
Arrays.equals and Arrays.hashCode compare contents, one level deep. for arrays of arrays you need Arrays.deepEquals and deepHashCode, because one level down the elements are arrays again, compared by identity. and a Set<int[]> or a Map<byte[], ...> is almost always a bug: wrap the array in a record with hand-written equals/hashCode, or use a List<Integer>, or a String if it's bytes of text.
the key that's the wrong type
Map<Long, String> names = new HashMap<>();
names.put(1L, "ada");
IO.println(names.get(1));URL.equals goes to the network+
java.net.URL.equals and hashCode consider two URLs equal if their hosts resolve to the same IP address, so they can do a DNS lookup, block, and give different answers when DNS changes or you're offline. a HashSet<URL> can make network calls. use java.net.URI for anything you compare or store: its equals is purely textual. (URL's constructors have been deprecated since java 20, in favour of URI.toURL().)
- a hash collection files a key under its hash at insertion. mutate the key and it's lost.
instanceofbreaks symmetry with subclasses that add state;getClass()breaks with proxies and mocks. make value classesfinal.- records compare
doubles by bits (0.0≠-0.0,NaN=NaN) and arrays by identity. - arrays never override
equals. useArrays.equals,deepEquals, or don't use arrays as keys. Map<Long, ?>.get(1)compiles and returnsnull. anIntegeris never equal to aLong.