Skip to content

Java 21 Classes, Records and Sealed Types

Step 5 of 20

Java 21 Object-Oriented Programming: Classes, Records and Sealed Types

Introduction

Object-oriented programming in Java 21 connects class design with code behavior. This 1Z0-830 lesson explains constructor chains, records, nested classes, overriding, interfaces and sealed hierarchies. You will trace initialization and distinguish the member selected at compile time from the method dispatched at run time.

Examples use Java SE 21 rules. An explicit constructor invocation must be the first statement in this version.

Learning objectives

After this lesson you will be able to:

  • Declare classes, records and the four kinds of nested classes, and instantiate each correctly.
  • Predict the order of static initializers, instance initializers, field initializers and constructors.
  • Explain the object lifecycle: creation, reachability, eligibility for garbage collection, and why finalize is not a tool.
  • Distinguish overloading, overriding, hiding and shadowing, and apply the overriding rules (access, return type, exceptions).
  • Use abstract classes, interfaces (default, static, private methods) and resolve diamond conflicts.
  • Declare sealed classes and interfaces and reason about their permits rules.
  • Write enums with fields, constructors and constant-specific bodies.
  • Combine instanceof pattern matching with inheritance without compile errors.

1. Classes, fields, methods and constructors

A class declares state (fields) and behavior (methods). Fields have default values (0, false, null) while local variables have none and must be definitely assigned before use.

Java
public class Account {
    private static int created;           // one copy per class
    private final String id;              // one copy per object, assigned once
    private long balanceCents;            // defaults to 0

    public Account(String id) {
        this.id = id;
        created++;
    }

    public void deposit(long cents) {
        if (cents <= 0) throw new IllegalArgumentException("amount must be positive");
        balanceCents += cents;
    }
}

Key rules:

  • For an ordinary named class with no constructor declaration, the compiler adds a no-argument constructor with the same access as the class. As soon as you declare any constructor, the default one disappears.
  • In Java SE 21, an explicit constructor invocation, this(...) or super(...), must be the first statement. If you write neither, the compiler inserts super();. If the superclass has no accessible no-argument constructor, the subclass constructor does not compile.
  • A final instance field must be assigned exactly once, by its initializer, an instance initializer block or every constructor. A static final field must be assigned by its initializer or a static initializer.
  • Access modifiers, from widest to narrowest: public, protected (package access plus restricted subclass access across packages), package-private (no keyword), private.

Overloading means methods with the same name and different parameter lists. Resolution first considers fixed-arity calls without boxing or unboxing, then fixed-arity calls allowing those conversions, and finally variable-arity invocation. Reference widening also participates. Within a phase, the compiler selects the most specific applicable method or reports ambiguity. A single print(long) call with an int argument chooses print(long) over print(Integer) because widening beats boxing.

2. Initialization order

Class initialization is triggered by active use, such as creating an instance or invoking a declared static method. Reading an inlined constant does not trigger it. Superclasses initialize before subclasses; each class then runs its static field initializers and blocks in textual order. For each new, the order is:

  1. The superclass constructor chain runs first (recursively), which itself runs the superclass instance initializers.
  2. Instance field initializers and instance initializer blocks of the current class, in textual order.
  3. The body of the constructor.
Java
class Base {
    static { System.out.print("1 "); }
    { System.out.print("2 "); }
    Base() { System.out.print("3 "); }
}
class Derived extends Base {
    static { System.out.print("4 "); }
    { System.out.print("5 "); }
    Derived() { System.out.print("6 "); }
}
// new Derived(); prints: 1 4 2 3 5 6
// A second new Derived() prints only: 2 3 5 6

A classic trap: if a Base constructor calls an overridable method, the overriding method in Derived runs before Derived's field initializers, so it sees default values (null, 0).

3. Records

A record is a concise, shallowly immutable data carrier. The header record Point(int x, int y) {} generates private final fields, a canonical constructor, accessors named x() and y() (not getX), and equals, hashCode and toString.

Java
public record Price(String currency, long cents) {
    public Price {                              // compact canonical constructor
        if (cents < 0) throw new IllegalArgumentException("negative price");
        currency = currency.toUpperCase(java.util.Locale.ROOT);      // may reassign the parameters
    }
    public Price(long cents) { this("EUR", cents); }  // other constructors must delegate
    public static Price zero() { return new Price(0); }
}

Rules to memorize:

  • A record is implicitly final and cannot extend a class (it extends java.lang.Record) but can implement interfaces.
  • You cannot declare instance fields other than the components; you can declare static fields, static methods and instance methods.
  • In a compact constructor you do not assign this.field; the fields are assigned automatically at the end, after your validation and any parameter reassignment.
  • A non-canonical constructor must start with this(...).
  • You may explicitly declare an accessor, but it must be public and return the component type.
  • Shallow immutability: a final component reference can point to mutable data. List.copyOf prevents structural changes through the stored list and separates it from later source-list edits; mutable elements remain shared.

4. Nested classes

Kind Declared Can access Needs outer instance Notes
Static nested static class N inside a class outer members, including private ones; instance members require an explicit outer reference No Created with new Outer.N()
Inner (member) class I inside a class all outer members incl. private Yes outer.new I(); since Java 16 may declare static members
Local inside a method outer members and effectively final locals Yes in instance method Not visible outside the method
Anonymous new Type() { ... } same as local Yes in instance context No constructor; extends a class or implements one interface

Local records, local enums and local interfaces are allowed and are implicitly static, so they cannot capture local variables or instance members of the enclosing method.

Java
public class Shop {
    private final String name = "Main Street";
    class Receipt {                       // inner class
        String header() { return "Receipt from " + name; }
    }
    static class Catalog { }              // static nested class
}

Shop shop = new Shop();
Shop.Receipt r = shop.new Receipt();      // needs the outer instance
Shop.Catalog c = new Shop.Catalog();      // does not

Variables captured by a lambda, local class or anonymous class must be effectively final: never reassigned after initialization.

5. Object lifecycle and garbage collection

An object is created by new, lives while it is reachable through a chain of references from a live thread, a static field or a local variable, and becomes eligible for garbage collection when no reachable reference remains. The exam asks "after which line is the object eligible?", and the usual patterns are:

Java
Widget a = new Widget();   // object W1
Widget b = a;              // W1 has two references
a = null;                  // still reachable through b
b = new Widget();          // W2 created; W1 now eligible

Facts to remember:

  • System.gc() is only a suggestion; the JVM may ignore it. No output order from the collector is guaranteed.
  • finalize() is deprecated for removal since Java 18 (JEP 421). Use explicit close and try-with-resources for timely cleanup. A Cleaner can provide fallback cleanup, with no guarantee of prompt execution.
  • Islands of isolation (objects referencing each other, but unreachable from outside) are all eligible.
  • A reference passed to a method is copied. Reassigning the parameter inside the method does not change the caller's variable, but mutating the object is visible.

6. Inheritance, overriding and polymorphism

Inheritance depends on accessibility: public and protected members can be inherited across packages; package-private members require the same package. Private members and constructors are not inherited. Overriding rules for an instance method:

  • An override-compatible signature; for the non-generic examples here, the same name and parameter types.
  • The return type is the same or a covariant subtype (for reference types).
  • The access modifier is the same or wider.
  • The throws clause may not add new or broader checked exceptions (it may drop them or narrow them).
  • The overridden method must not be final, static or private.

Hiding applies to static methods and to fields: they are resolved by the declared (compile-time) type of the reference, not by the runtime object. Instance methods are resolved by the runtime type (dynamic dispatch). Overloads are chosen at compile time using the declared types of the arguments.

Java
class Animal {
    String name = "animal";
    String sound() { return "..."; }
    static String kind() { return "Animal"; }
}
class Dog extends Animal {
    String name = "dog";
    @Override String sound() { return "woof"; }
    static String kind() { return "Dog"; }
}
Animal a = new Dog();
System.out.println(a.name);     // animal  (field: declared type)
System.out.println(a.sound());  // woof    (instance method: runtime type)
System.out.println(a.kind());   // Animal  (static: declared type)

Always annotate with @Override: it turns a typo or a mismatched signature into a compile error. Calling super.sound() invokes the parent version; super cannot be used in a static context.

7. Abstract classes and interfaces

An abstract class cannot be instantiated and may mix abstract methods (no body) with concrete ones, fields and constructors. A concrete subclass must implement every inherited abstract method. An abstract method cannot be private, static or final.

An interface declares a contract. Since Java 8 and 9 it may contain:

  • abstract methods (implicitly public abstract),
  • default methods (a body, inherited by implementers, public),
  • static methods (called as Interface.method(), not inherited by implementers),
  • private instance helpers for default methods and private static helpers usable by static or default methods,
  • constants (implicitly public static final).
Java
interface Notifier {
    void send(String to, String body);

    default void sendAll(List<String> recipients, String body) {
        recipients.forEach(r -> send(r, decorate(body)));
    }
    private String decorate(String body) { return "[OnLevelUp] " + body; }
    static Notifier noop() { return (to, body) -> { }; }
}

Diamond conflicts. Unrelated interface defaults with override-equivalent signatures need resolution if no class method supplies the implementation. A more specific subinterface default can win over its parent default. Inside the override it can choose a parent with InterfaceName.super.method(). A concrete method inherited from a class always wins over an interface default ("class wins" rule).

Feature Abstract class Interface
Instance state (fields) Yes No (only constants)
Constructors Yes No
Multiple inheritance One superclass Many interfaces
Method bodies Any visibility default, static, private only
Functional interface (lambda target) No Yes, if not declared sealed and it has one effective abstract function; public Object method signatures do not count

8. Sealed classes and interfaces

Java 17 finalized sealed types, which restrict who may extend or implement them. This is essential for exhaustive pattern matching.

Java
sealed interface Shape permits Circle, Rectangle, Square { }
record Circle(double r) implements Shape { }
record Rectangle(double w, double h) implements Shape { }
non-sealed class Square implements Shape { }

Rules:

  • A permitted class must directly extend or implement the sealed type and be final, sealed or non-sealed. A permitted subinterface must be sealed or non-sealed. Records are implicitly final. Enums are implicitly final or sealed depending on their constant bodies.
  • If the permitted subclasses are in the same source file as the sealed type, permits can be omitted and they are inferred.
  • Permitted subclasses must be in the same package (or the same module, if the sealed type is in a named module).
  • A non-sealed class reopens the hierarchy: anyone can extend it.

9. Enums

An enum defines a fixed set of constants and extends Enum. It is implicitly final when no constant has a class body, otherwise implicitly sealed with those anonymous constant classes as permitted subclasses. It can implement interfaces. Its constructor is private if no access modifier is written; public and protected constructors are forbidden.

Java
public enum Plan {
    FREE(0), PRO(999) {
        @Override String badge() { return "best value"; }
    }, TEAM(2999);

    private final int priceCents;
    Plan(int priceCents) { this.priceCents = priceCents; }
    String badge() { return name().toLowerCase(); }
    int priceCents() { return priceCents; }
}

Useful API: values(), valueOf(String) (throws IllegalArgumentException if the name does not exist), name(), ordinal(), and compareTo (by ordinal). The constants must come first in the body, ended by a semicolon when other members follow. Enums work in switch (no qualified names in classic case labels, in Java 21 qualified names are allowed) and EnumMap/EnumSet are efficient collections keyed by them.

10. instanceof pattern matching with inheritance

obj instanceof Type t tests and binds in one step. Compile-time rules: the cast from the expression's static type to Type must be legal, so testing a String expression against Integer is always a compile error, while Object permits many reference tests. An interface-to-class test can still fail at compile time when the types are provably incompatible, for example an unrelated interface and a final class. The binding variable is in scope only where the test is definitely true, and the pattern variable may not redeclare a name already in scope.

Java
if (!(shape instanceof Circle c)) {
    return 0;
}
return Math.PI * c.r() * c.r();       // c is in scope here

Worked example: predict the output

Statement. What does this program print?

Java
class Vehicle {
    String describe() { return "vehicle " + wheels(); }
    int wheels() { return 4; }
}
class Bike extends Vehicle {
    int wheels = 2;
    @Override int wheels() { return wheels; }
}
public class Main {
    public static void main(String[] args) {
        Vehicle v = new Bike();
        System.out.println(v.describe());
        System.out.println(v.wheels);
    }
}

Step 1. v.wheels does not compile: Vehicle has no field wheels. Fields are resolved by declared type, and Vehicle declares only a method with that name; a method and a field may share a name, but the field must exist in the declared type.

Step 2. Remove that line. describe() is inherited and calls wheels(). Dynamic dispatch selects Bike.wheels(), which returns the field wheels, which is 2 once the constructor has run.

Solution. After removing the faulty line the output is vehicle 2; as written, compilation fails at v.wheels.

Why the alternatives are worse. Answering vehicle 4 forgets dynamic dispatch. Answering 2 for the second line forgets that fields use the declared type. Answering "prints vehicle 2 then 2" misses the compile error entirely.

Common pitfalls and exam tips

  • Exam tip: read the declared type and runtime type separately on every line. Methods follow the runtime type, fields and static methods follow the declared type.
  • A constructor named like the class but with a return type (even void) is an ordinary method, not a constructor.
  • this() and super() cannot both appear, and neither can appear after another statement.
  • A subclass cannot reduce visibility: overriding a public method with a package-private one fails to compile. Review checked exceptions in overriding methods for the related throws restriction.
  • Static methods cannot be overridden, and @Override on one is a compile error.
  • Records: forgetting that the accessor is name() rather than getName() is a frequent distractor.
  • A sealed type with a permitted subclass that is not final, sealed or non-sealed does not compile.
  • Plan.valueOf("pro") fails: matching is case-sensitive. The generic Enum.valueOf form also requires the enum Class argument.
  • Interface constants are implicitly static final; assigning to one is a compile error.

Mini case study: modelling subscriptions

OnLevelUp sells two kinds of access: a monthly subscription and a lifetime purchase of one course. A junior developer proposes class Access with a String type field and if chains everywhere. Redesign it using the features of this lesson.

Solution. Use a sealed interface with record implementations, and an enum for billing periods:

Java
enum Period { MONTHLY, LIFETIME }

sealed interface Access permits Subscription, CoursePurchase { 
    boolean grants(String courseId);
}
record Subscription(String userId, Period period) implements Access {
    public Subscription { if (period != Period.MONTHLY) throw new IllegalArgumentException(); }
    public boolean grants(String courseId) { return true; }
}
record CoursePurchase(String userId, String courseId) implements Access {
    public boolean grants(String id) { return courseId.equals(id); }
}

The sealed interface lets a switch over Access be exhaustive without default. See Java 21 switch patterns and exhaustiveness for the matching rules. Adding a third subtype requires recompiling exhaustive switches that no longer cover the hierarchy. Records give equality and toString for free, and the compact constructor validates data at creation time. An enum prevents invalid period strings. The String type design would silently accept typos and push errors to runtime.

Key terms

  • Overriding: a subclass instance method replacing an inherited one with the same signature.
  • Hiding: a static method or field in a subclass masking the parent member, resolved by declared type.
  • Compact constructor: record constructor without a parameter list, run before fields are assigned.
  • Sealed type: a class or interface that lists its permitted direct subtypes.
  • Effectively final: a variable never reassigned after initialization.
  • Eligible for GC: no reachable reference to the object remains.
  • Default method: an interface method with a body, inherited by implementers.
  • Covariant return: an overriding method returning a subtype of the original return type.

Summary checklist

  • I can trace static, instance and constructor initialization across a two-level hierarchy.
  • I know what a record generates and what a compact constructor can and cannot do.
  • I can choose between static nested, inner, local and anonymous classes and know their access rules.
  • I can say after which line an object becomes eligible for garbage collection.
  • I can apply the overriding rules for access, return type and checked exceptions.
  • I can resolve default-method conflicts with Interface.super.method().
  • I can declare sealed hierarchies with final, sealed and non-sealed subtypes.
  • I can write an enum with fields, a constructor and a constant-specific body.

Official sources