skip to content
everything.deepDives.equalsAndHashcodeTraps

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

Vanish.java
  1. 1Set<Point> set = new HashSet<>();
  2. 2Point p = new Point(1, 2);
  3. 3set.add(p);
  4. 4p.x = 7;
  5. 5set.contains(p);
  6. 6set.remove(p);
  7. 7set.add(p);
  8. 8set.contains(new Point(1, 2));
0123456789101112131415p: Point(1, 2)hashCode() = 994 → bucket 2
size() = 0

an empty HashSet: 16 buckets, nothing in them. p is a Point(1, 2) whose hashCode() uses both fields.

step 1/7
step through it. the set's entry keeps the hash it was filed under; the object behind it changes. every lookup lands in the bucket the current hash points to, and comes back false. bucket numbers are what jdk 25's HashMap really computes.

here's the real thing:

Main.java
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);
}
output
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.

Main.java
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));
}
output
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.

instanceofgetClass()
subclass that adds statebreaks symmetrycorrect
subclass that adds nothing, proxies, mockscorrectnever equal
the way outmake the class (or its equals) finaldon'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:

  • float and double components are compared like Double.compare does, by value bits, not with ==.
  • array components are compared with the array's own equals, which is identity.
Main.java
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})));
}
output
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:

double 0.000x0000000000000000
sign · 1 bitexponent · 11 bitsmantissa · 52 bits
double -0.000x8000000000000000
sign · 1 bitexponent · 11 bitsmantissa · 52 bits
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.

stack
main
a
b
heap
int[]
{1, 2, 3}
int[]
{1, 2, 3}
two arrays, same contents, two references. a.equals(b) asks whether the arrows point at the same object, and they don't.
Main.java
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));
}
output
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

predict the output
java
Map<Long, String> names = new HashMap<>();
names.put(1L, "ada");
IO.println(names.get(1));
one level deeper: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().)

keylines
  • a hash collection files a key under its hash at insertion. mutate the key and it's lost.
  • instanceof breaks symmetry with subclasses that add state; getClass() breaks with proxies and mocks. make value classes final.
  • records compare doubles by bits (0.0 ≠ -0.0, NaN = NaN) and arrays by identity.
  • arrays never override equals. use Arrays.equals, deepEquals, or don't use arrays as keys.
  • Map<Long, ?>.get(1) compiles and returns null. an Integer is never equal to a Long.