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) printed Account@6f3b5d16: the class name and a hash in hex, which changes from run to run. That is Object.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 as deposit, 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 only Point(int x, int y) failed with error: constructor Point in class Point cannot be applied to given types; followed by required: int,int and found: 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
Bar chart in milliseconds. 5,000 keys: record 0.17, constant hashCode 61.9. 20,000 keys: record 1.26, constant hashCode 1,246.7. With a constant hashCode the time grew about 20 times when the key count grew 4 times.
Adding n keys to a HashSet and looking each one up: a record's generated hashCode vs a legal but constant hashCode (return 42). OpenJDK 26, Apple Silicon Mac (M1 Pro), best of 3 after JIT warm-up.

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 not this.values.
  • A record is shallowly immutable. Without the List.copyOf, the caller's later src.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; gave error: field declaration must be static with the hint (consider replacing field with record component). Assigning a component in a method gave error: 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().

Decision diagram. Start: a new type. Question 1: is it plain immutable data? If yes, use a record. If no, question 2: is there a fixed, known set of variants? If yes, use a sealed interface whose cases are records. If no, question 3: do the implementations share state or constructor logic? If yes, use an abstract class. If no, use an interface, with default methods for shared behaviour.
Choosing between record, sealed interface, abstract class and interface for a new type. Ask the questions in this order.
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 --release checks 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.
  • HashMap turns very long buckets into trees, which helps only when keys are Comparable. Our Bad key was not, so we saw the worst case.
  • The identity hash in Account@6f3b5d16 differs 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.

  • #Java
  • #beginner
  • #OpenJDK 26
  • #record
  • #sealed
  • #interface
  • #equals
  • #hashCode

t.laboratory

A technical blog that records real problems from the apps, servers and services we build and run — and the code that fixed them.

About · Apps

↑