Difficulty: Advanced
How do you design an immutable class in Java? Also explain the equals and hashCode contract and what breaks if you override only one.
These two topics are often asked together because both are about designing value objects correctly, and both are places where small mistakes lead to bugs that are painful to trace. Let's start with immutability.
An immutable object cannot change after construction. String, Integer, LocalDate, and BigDecimal are all immutable. The benefits are large: instances are automatically thread-safe without locking, safe to share and cache, safe as HashMap keys, easy to reason about, and free of defensive-copy worries for callers. The cost is that each change creates a new object.
To build an immutable class in Java: declare the class final (or make constructors private) so it cannot be subclassed and made mutable; make all fields private and final; provide no setters; initialise everything in the constructor; do not let the this reference escape during construction; and, crucially, deal with mutable components. If a field holds a mutable object such as a List or Date, copy it in the constructor (defensive copy in) and never return the internal reference from getters, returning a copy or an unmodifiable view instead (defensive copy out). In the example, Team uses List.copyOf, so later changes to the original list do not affect it. Records in Java 16+ generate immutable classes concisely, but remember that a record with a List component is only shallowly immutable.
Now the equals and hashCode contract. By default, equals from Object compares identity and hashCode is derived from the object's address. For value objects you override equals to compare logical content. The contract says that equals must be reflexive, symmetric, transitive, consistent, and false for null. And the linking rule: if two objects are equal by equals, they must return the same hashCode. The reverse is not required, since collisions are allowed.
What breaks if you override only equals? Hash-based collections first use hashCode to find the bucket, and then equals to compare within it. If equal objects have different hash codes, they land in different buckets, so HashSet.contains and HashMap.get fail to find them. In the example, Point overrides both and the set finds the new Point(1,2) as expected, while Broken overrides only equals, so contains returns false, since the two equal instances almost certainly hash differently. If you override only hashCode, equal-hash objects do not compare equal and duplicates remain.
Guidelines: use the same fields in both methods; use Objects.hash and Objects.equals to write them safely; never include mutable fields in hashCode of objects used as map keys, because mutating a key after insertion makes it unfindable; prefer getClass comparison or instanceof consistently; and let the IDE, Lombok, or records generate them.
Tie the topics together: immutable classes make the best map keys precisely because their hashCode can never change.
import java.util.*;
final class Point {
private final int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Point)) return false;
Point p = (Point) o;
return x == p.x && y == p.y;
}
@Override public int hashCode() { return Objects.hash(x, y); }
}
class Broken {
final int x;
Broken(int x) { this.x = x; }
@Override public boolean equals(Object o) {
return o instanceof Broken && ((Broken) o).x == x; // hashCode not overridden
}
}
public class EqualsHashDemo {
public static void main(String[] args) {
Set<Point> good = new HashSet<>();
good.add(new Point(1, 2));
System.out.println(good.contains(new Point(1, 2)));
Set<Broken> bad = new HashSet<>();
bad.add(new Broken(1));
System.out.println(bad.contains(new Broken(1)));
}
}
Broken instances use identity hash codes, so equal objects land in different buckets (this result is virtually certain but not guaranteed by the language).
import java.util.*;
final class Team {
private final List<String> members;
Team(List<String> members) { this.members = List.copyOf(members); }
List<String> members() { return members; } // already unmodifiable
}
public class ImmutableDemo {
public static void main(String[] args) {
List<String> source = new ArrayList<>(List.of("A", "B"));
Team t = new Team(source);
source.add("C");
System.out.println(t.members());
}
}
immutability, equals, hashCode, defensive-copy, hashset