Java Classes, Records, Interfaces and Sealed Types
Java beginner lecture on OpenJDK 26: the equals/hashCode bug that puts duplicates in a HashSet, records, interfaces vs abstract classes, sealed switch.
Series: Java lecture 2/6 (beginner). For people who can write a main method and now need to design their own types. After this lecture you can choose between a class, a record, an interface, an abstract class and a sealed hierarchy, and you will know the one equals/hashCode mistake that silently corrupts a HashSet.
Most Java tutorials show how to declare a class. The questions that actually cost time come later: why does my set contain the same item twice, why did adding a new subtype not produce any error, should this be a record? This lecture answers them with code run on OpenJDK 26 (Temurin 26+35), Apple Silicon Mac (M1 Pro), including the exact javac messages and one measurement of what a bad hashCode costs.
As in lecture 1, each example is a single file you can run with java File.java.
Classes, constructors and encapsulation
A class bundles state with the rules that keep that state valid. Encapsulation means the rules live in one place: fields are private, and the only way in is through methods that check their input.
public class Account {
private final String owner;
private long balance; // minor units (cents, yen, won); never double
public Account(String owner, long initial) {
if (owner == null || owner.isBlank()) throw new IllegalArgumentException("owner is required");
if (initial < 0) throw new IllegalArgumentException("initial < 0: " + initial);
this.owner = owner;
this.balance = initial;
}
public Account(String owner) { this(owner, 0); } // constructor chaining
public void withdraw(long amount) {
if (amount <= 0) throw new IllegalArgumentException("amount must be > 0");
if (amount > balance) throw new IllegalStateException("insufficient: " + balance + " < " + amount);
balance -= amount;
}
public long balance() { return balance; }
public String owner() { return owner; }
public static void main(String[] args) {
Account a = new Account("kim", 1000);
a.withdraw(300);
System.out.println(a.owner() + " " + a.balance());
System.out.println(a);
a.withdraw(5000);
}
}
kim 700
Account@6f3b5d16
Exception in thread "main" java.lang.IllegalStateException: insufficient: 700 < 5000
at Account.withdraw(Account.java:15)
at Account.main(Account.java:26)
Three things beginners notice here:
System.out.println(a)printedAccount@6f3b5d16: the class name and a hash in hex, which changes from run to run. That isObject.toString(). You get something readable only if you override it (or use a record, below).- Code in another class that writes
a.balance = 1_000_000;does not compile:error: balance has private access in Account. That error is the point of encapsulation; the fix is a method such asdeposit, not making the field public. - Once you write any constructor, Java no longer gives you the free no-argument one.
new Point()on a class with onlyPoint(int x, int y)failed witherror: constructor Point in class Point cannot be applied to given types;followed byrequired: int,intandfound: no arguments.
The equals/hashCode contract, and the HashSet duplicate bug
HashSet and HashMap find an element in two steps: hashCode() picks a bucket, then equals() compares inside that bucket. The contract in Object's documentation is therefore: if a.equals(b), then a.hashCode() == b.hashCode(). Break it and the collections misbehave without an exception.
import java.util.*;
public class HashBug {
static class P0 { final int x, y; P0(int x, int y) { this.x = x; this.y = y; } }
static class P1 { // equals overridden, hashCode forgotten
final int x, y;
P1(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
return o instanceof P1 p && p.x == x && p.y == y;
}
}
static class P2 { // both, correctly
final int x, y;
P2(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
return o instanceof P2 p && p.x == x && p.y == y;
}
@Override public int hashCode() { return Objects.hash(x, y); }
}
static class P3 { // correct, but mutable fields in hashCode
int x, y;
P3(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
return o instanceof P3 p && p.x == x && p.y == y;
}
@Override public int hashCode() { return Objects.hash(x, y); }
}
public static void main(String[] args) {
Set<Object> s0 = new HashSet<>(List.of(new P0(1, 2), new P0(1, 2)));
Set<Object> s1 = new HashSet<>(); s1.add(new P1(1, 2)); s1.add(new P1(1, 2));
Set<Object> s2 = new HashSet<>(); s2.add(new P2(1, 2)); s2.add(new P2(1, 2));
System.out.println("no equals/hashCode : size=" + s0.size());
System.out.println("equals only : size=" + s1.size()
+ ", equals? " + new P1(1, 2).equals(new P1(1, 2))
+ ", contains? " + s1.contains(new P1(1, 2)));
System.out.println("equals + hashCode : size=" + s2.size()
+ ", contains? " + s2.contains(new P2(1, 2)));
int dup = 0;
for (int i = 0; i < 10_000; i++) {
Set<P1> s = new HashSet<>();
s.add(new P1(i, i)); s.add(new P1(i, i));
if (s.size() == 2) dup++;
}
System.out.println("equals only, 10,000 trials: duplicates in " + dup);
System.out.println("ArrayList.contains : "
+ new ArrayList<>(List.of(new P1(1, 2))).contains(new P1(1, 2)));
P3 p = new P3(1, 2);
Set<P3> s3 = new HashSet<>(); s3.add(p);
p.x = 99; // mutate after insertion
System.out.println("mutated key : contains(p)=" + s3.contains(p)
+ ", size=" + s3.size() + ", remove(p)=" + s3.remove(p));
}
}
no equals/hashCode : size=2
equals only : size=2, equals? true, contains? false
equals + hashCode : size=1, contains? true
equals only, 10,000 trials: duplicates in 10000
ArrayList.contains : true
mutated key : contains(p)=false, size=1, remove(p)=false
Read the second line slowly: equals says the two points are equal, yet the set holds both and cannot find a third equal one. It is not an occasional failure; it happened in all 10,000 trials, because the default hashCode is per-object. Worse, ArrayList.contains (which only uses equals) returns true, so a test written with a list passes and the bug appears only when someone switches to a set.
The last line is the other half of the contract: a key whose hash fields change after insertion is lost. The set still has size 1, but neither contains nor remove can reach it.
javac can catch the first mistake if you ask:
$ javac -Xlint:all HashBug.java
HashBug.java:8: warning: [overrides] Class P1 overrides equals, but neither it nor any superclass overrides hashCode method
Common mistake: equals(P o) instead of equals(Object o)
public boolean equals(P o) { return o != null && o.x == x; } // overload, not override
p.equals(p2) : true
o.equals(p2) : false // o is declared as Object
List.of(..).contains: false
What happens: you added a new method; collections call equals(Object) and still get the inherited identity check. Fix: always write @Override. With it, javac refuses the wrong signature: error: equals(P) in P does not override or implement a method from a supertype.
What a bad hashCode costs
A hashCode that returns a constant is legal: equal objects do get equal hashes. It just puts every key in one bucket. We added n keys to a HashSet and then looked each one up, comparing a record's generated hashCode with return 42 (best of 3 after JIT warm-up, OpenJDK 26, M1 Pro):
n= 1,000 record hashCode: 0.21 ms constant hashCode: 2.3 ms
n= 5,000 record hashCode: 0.17 ms constant hashCode: 61.9 ms
n=20,000 record hashCode: 1.26 ms constant hashCode: 1246.7 ms
Three runs agreed within about 4% on the large numbers (1,201 to 1,247 ms). Four times the keys took about 20 times longer, because each lookup scans the shared bucket. The record rows are so small that JIT noise dominates (5,000 came out faster than 1,000); the full program, HashCost.java, is only HashSet.add and contains in two loops.
Records: data classes with the contract built in
A record declares its components once, and the compiler generates the private final fields, the constructor, accessors (x(), not getX()), equals, hashCode and toString, all consistent with each other.
import java.util.*;
public class Records {
record Point(int x, int y) {}
record Range(int lo, int hi) {
Range { // compact constructor
if (lo > hi) throw new IllegalArgumentException("lo > hi: " + lo + " > " + hi);
}
int length() { return hi - lo; }
}
record Tags(List<String> values) {
Tags { values = List.copyOf(values); } // defensive copy
}
public static void main(String[] args) {
Point a = new Point(1, 2), b = new Point(1, 2);
System.out.println(a);
System.out.println("equals=" + a.equals(b) + ", sameHash=" + (a.hashCode() == b.hashCode())
+ ", == " + (a == b));
System.out.println("HashSet size=" + new HashSet<>(List.of(a, new Point(3, 4))).size()
+ ", contains(1,2)=" + new HashSet<>(List.of(a)).contains(new Point(1, 2)));
System.out.println("x()=" + a.x() + ", length=" + new Range(3, 10).length());
var src = new ArrayList<>(List.of("java"));
Tags t = new Tags(src);
src.add("oops");
System.out.println(t);
try { t.values().add("x"); }
catch (UnsupportedOperationException e) { System.out.println("values().add -> " + e); }
new Range(10, 3);
}
}
Point[x=1, y=2]
equals=true, sameHash=true, == false
HashSet size=2, contains(1,2)=true
x()=1, length=7
Tags[values=[java]]
values().add -> java.lang.UnsupportedOperationException
Exception in thread "main" java.lang.IllegalArgumentException: lo > hi: 10 > 3
at Records$Range.<init>(Records.java:8)
at Records.main(Records.java:32)
- The compact constructor (
Range { … }with no parameter list) runs before the fields are assigned. It is the place for validation and normalisation; you can reassign the parameter (values = List.copyOf(values)) but notthis.values. - A record is shallowly immutable. Without the
List.copyOf, the caller's latersrc.add("oops")would have leaked into the record. Copying makes the record safe to use as a map key. - Records cannot hold extra instance fields: adding
int z;gaveerror: field declaration must be staticwith the hint(consider replacing field with record component). Assigning a component in a method gaveerror: cannot assign a value to final variable x. - Records cannot extend a class (they implicitly extend
java.lang.Record), but they can implement interfaces, which is what the sealed example below relies on.
Interfaces, default methods and abstract classes
An interface describes what a type can do. Since Java 8 it can carry default methods (behaviour built on the abstract ones) and static factory methods, but no instance state. An abstract class can hold fields, constructors and final methods, at the cost of using up the single superclass slot.
import java.util.*;
public class Kinds {
interface Notifier {
void send(String to, String msg); // abstract
default void sendAll(List<String> to, String msg) { // default
for (String t : to) send(t, msg);
}
static Notifier console() {
return (to, m) -> System.out.println("[console] " + to + ": " + m);
}
}
static abstract class Job {
private int runs; // state: an interface can't hold this
protected final String name;
Job(String name) { this.name = name; }
public final void run() { // template method
runs++;
System.out.println("start " + name + " #" + runs);
doWork();
}
protected abstract void doWork();
}
static class Cleanup extends Job {
Cleanup() { super("cleanup"); }
@Override protected void doWork() { System.out.println(" deleting temp files"); }
}
public static void main(String[] args) {
Notifier.console().sendAll(List.of("alice", "bob"), "deploy done");
Job j = new Cleanup();
j.run(); j.run();
}
}
[console] alice: deploy done
[console] bob: deploy done
start cleanup #1
deleting temp files
start cleanup #2
deleting temp files
Because Notifier has exactly one abstract method, a lambda can implement it. Job.run() is final, so subclasses cannot skip the counter; they only fill in doWork().
Errors you will meet:
Diamond.java:4: error: types A and B are incompatible;
static class C implements A, B { }
class C inherits unrelated defaults for hello() from types A and B
AbsNew.java:3: error: Half is not abstract and does not override abstract method doWork() in Job
AbsNew.java:4: error: Job is abstract; cannot be instantiated
The first appears when two interfaces provide the same default method. Override it and pick explicitly with A.super.hello().
| Situation | Choose | Reason |
|---|---|---|
| Immutable data identified by its values (DTO, map key, result) | record |
equals/hashCode/toString generated and consistent |
| A closed set of cases (payment method, shape, API result) | sealed interface + records |
the compiler checks every switch covers all cases |
| Unrelated classes share a capability | interface (+ default methods) |
a class can implement many interfaces |
| Subclasses share fields, constructor logic or a fixed algorithm | abstract class |
interfaces cannot hold instance state |
| Mutable object with invariants (account, cart, connection) | ordinary class, private fields |
encapsulation; not a record |
Sealed interfaces and pattern matching switch
A sealed interface lists all of its implementations. The compiler can then check a switch for exhaustiveness, so adding a new case turns every switch that forgot it into a compile error rather than a runtime surprise.
public class Shapes {
sealed interface Shape permits Circle, Rect, Triangle {}
record Circle(double r) implements Shape {}
record Rect(double w, double h) implements Shape {}
record Triangle(double b, double h) implements Shape {}
static double area(Shape s) {
return switch (s) {
case Circle c -> Math.PI * c.r() * c.r();
case Rect(double w, double h) -> w * h; // record pattern
case Triangle t when t.b() == 0 -> 0; // guard
case Triangle t -> 0.5 * t.b() * t.h();
};
}
public static void main(String[] args) {
Shape[] all = { new Circle(1), new Rect(2, 3), new Triangle(4, 5) };
for (Shape s : all) System.out.printf("%-30s area=%.2f%n", s, area(s));
area(null);
}
}
Circle[r=1.0] area=3.14
Rect[w=2.0, h=3.0] area=6.00
Triangle[b=4.0, h=5.0] area=10.00
Exception in thread "main" java.lang.NullPointerException
at java.base/java.util.Objects.requireNonNull(Objects.java:220)
at Shapes.area(Shapes.java:8)
at Shapes.main(Shapes.java:19)
There is no default branch, and none is needed. Delete the two Triangle cases and javac stops:
Shapes.java:8: error: the switch expression does not cover all possible input values
return switch (s) {
^
That is the point of sealing. If you had written default -> 0, the missing triangle would have compiled and quietly returned an area of 0. Add a type that is not in permits and you get error: class is not allowed to extend sealed class: Shape (as it is not listed in its 'permits' clause).
Two details from the run: a pattern switch throws NullPointerException on null unless you add case null -> … (we added it and the program finished normally), and case order matters: the guarded Triangle t when … must come before the plain Triangle t.
Checking the "since Java N" claims
We compiled the same files with older --release values on javac 26:
$ javac --release 15 Shapes17.java
error: sealed classes are not supported in -source 15
(use -source 17 or higher to enable sealed classes)
error: records are not supported in -source 15
(use -source 16 or higher to enable records)
$ javac --release 16 Shapes17.java
error: sealed classes are not supported in -source 16
(use -source 17 or higher to enable sealed classes)
$ javac --release 17 Shapes17.java && java -cp out Shapes17
Rect[w=2.0, h=3.0] 6.0
$ javac --release 17 Shapes.java
Shapes.java:9: error: patterns in switch statements are not supported in -source 17
(use -source 21 or higher to enable patterns in switch statements)
Shapes.java:10: error: deconstruction patterns are not supported in -source 17
(use -source 21 or higher to enable deconstruction patterns)
Shapes17.java is the same sealed hierarchy with if (s instanceof Circle c) instead of a switch. So: records need 16, sealed types 17, pattern switch and record patterns 21. If your team is on Java 17, you get the sealed hierarchy but must write the instanceof chain, and you lose the exhaustiveness check.
Honest limits and version notes
- All code was compiled and run with OpenJDK 26 (Temurin 26+35) on an Apple Silicon Mac (M1 Pro). The
--releasechecks emulate older language levels with javac 26; we did not install JDK 16, 17 or 21. - The hash timing is a best-of-3 loop after warm-up, not a JMH benchmark. Use it for the ratio (about 1,000× at 20,000 keys), not the absolute numbers.
HashMapturns very long buckets into trees, which helps only when keys areComparable. OurBadkey was not, so we saw the worst case.- The identity hash in
Account@6f3b5d16differs on every run.
Previous: Java lecture 1/6: types, strings and equality. Next: Java lecture 3/6: collections and their performance. Related: Python lecture 2, VB.NET lecture 3: classes, inheritance and interfaces, and the C# take on interfaces.
FAQ
What happens if I override equals but not hashCode in Java?
Two equal objects get different hash codes, so a HashSet stores both and contains() returns false. In our run, adding the same point twice produced a set of size 2 in 10,000 out of 10,000 trials, while ArrayList.contains still returned true. javac -Xlint:all warns: [overrides] Class P1 overrides equals, but neither it nor any superclass overrides hashCode method.
Should I use a record or a class?
Use a record when the type is just immutable data identified by its values: it generates the constructor, accessors, equals, hashCode and toString, and you can validate in a compact constructor. Use a class when you need mutable state, hidden fields, or inheritance from another class. Records were standardized in Java 16; compiling with --release 15 fails.
Which Java version do I need for sealed interfaces and pattern matching switch?
Sealed classes and interfaces need Java 17. Pattern matching in switch and record deconstruction patterns need Java 21: with --release 17 javac said patterns in switch statements are not supported in -source 17 (use -source 21 or higher). We checked both with javac 26.