JAVA / INHERITANCE AND POLYMORPHISM
final classes and methods that cannot change
Use final on methods and classes to close off overriding and subclassing at compile time, and decide which parts of a class stay open as hooks.
What you will learn
- Mark a method final so a subclass redeclaring that signature will not compile.
- Mark a class final to block extends; its methods then become implicitly final.
- Pair a final skeleton method with protected hooks to open only the safe parts.
- Keep final class, final method and final field apart: none of them freeze object state.
Understanding final classes and methods that cannot change
final on a method means the compiler rejects any subclass that declares a method with the same name and parameter list. final on a class means no type may name it after extends, which also makes every method it declares implicitly final, since there is no subclass left to override them. Neither form says anything about fields or about whether an object can change: final here acts purely on the inheritance mechanism, is checked by javac, and is rechecked by the JVM when a class is loaded.
Every non-final method in a class you publish is an open invitation, because someone can substitute their own body and your other methods will then call that body instead of yours. That is fine for behaviour you meant to be swapped and dangerous for anything that enforces a rule, since a validation check a subclass can override is not a check at all. It is why String is final: code that inspects a path or a permission string and then acts on it must be sure nobody passed in a "String" that reports one value during the check and another during the use, and that a cached hash code is not lying.
In practice you decide method by method what you are willing to support forever: a final skeleton that owns the order of operations and the checks, plus a few protected hooks that subclasses fill in. The asymmetry matters, because removing final later is a compatible change while adding it breaks every existing subclass, so final-until-there-is-a-reason is the cheap direction in which to be wrong. Do not reach for final as a speed trick either; the JIT already turns single-implementation virtual calls into direct calls, so final is a design statement that the compiler happens to enforce.
class Account {
private long cents;
Account(long cents) {
this.cents = cents;
}
// Final: no subclass may drop, reorder or weaken these checks.
public final void withdraw(long amount) {
if (amount <= 0) {
throw new IllegalArgumentException("amount must be positive");
}
if (amount > cents) {
throw new IllegalStateException("insufficient funds");
}
cents -= amount;
afterWithdraw(amount);
}
// Open on purpose: this is the one place a subclass may add behaviour.
protected void afterWithdraw(long amount) {
}
public long balance() {
return cents;
}
}
class AuditedAccount extends Account {
AuditedAccount(long cents) {
super(cents);
}
@Override
protected void afterWithdraw(long amount) {
System.out.println("audit: -" + amount + ", left " + balance());
}
// public void withdraw(long amount) { } // compile error: withdraw(long) is final
}
public class Main {
public static void main(String[] args) {
Account a = new AuditedAccount(500);
a.withdraw(120);
try {
a.withdraw(1000);
} catch (IllegalStateException e) {
System.out.println("refused: " + e.getMessage());
}
System.out.println("final balance " + a.balance());
}
}final removes an extension point on purpose, so the body you wrote is guaranteed to be the body that runs, while making nothing immutable.
Worked examples
A final class cannot be extended at all
Shows that final on the class blocks extends outright and makes each of its methods unoverridable without any extra keywords.
import java.lang.reflect.Modifier;
final class Temperature {
private final double celsius;
Temperature(double celsius) { this.celsius = celsius; }
double fahrenheit() { return celsius * 9 / 5 + 32; }
@Override public String toString() { return celsius + "C"; }
}
// class Kelvin extends Temperature { } // error: cannot inherit from final Temperature
class Room {
private final Temperature t;
Room(Temperature t) { this.t = t; }
void report() { System.out.println(t + " = " + t.fahrenheit() + "F"); }
}
public class Main {
public static void main(String[] args) {
new Room(new Temperature(20.0)).report();
System.out.println("Temperature final? " + Modifier.isFinal(Temperature.class.getModifiers()));
System.out.println("String final? " + Modifier.isFinal(String.class.getModifiers()));
}
}Example explained
Line 1final class Temperature turns the commented-out extends line into a compile error, so fahrenheit() can never be replaced by a version that reports a different number.
Line 2Because the class is final there is no subclass to override anything, so writing final on fahrenheit() as well would add nothing.
Line 3Room gets the behaviour by holding a Temperature field, which is the only route left once inheritance is closed.
Line 4Modifier.isFinal reads the flag out of the class file, and java.lang.String carries the same flag for exactly the reason above.
final does not mean immutable
A final class held in a final variable whose state still changes, separating the three unrelated uses of the keyword.
final class Counter { // no subclass can ever exist
private int n; // n is not final, so instances still mutate
void bump() { n++; }
int value() { return n; }
}
public class Main {
public static void main(String[] args) {
final Counter c = new Counter();
c.bump();
c.bump();
System.out.println("value " + c.value());
Counter alias = c; // second name for the same object
alias.bump();
System.out.println("value through c " + c.value());
// c = new Counter(); // error: cannot assign a value to final variable c
}
}Example explained
Line 1final class Counter closes the type to subclassing and says nothing whatsoever about the field n.
Line 2final Counter c freezes the reference, so c keeps pointing at one object, which is why the commented assignment is rejected.
Line 3alias.bump() mutates that same object and c.value() reports 3, proving nothing was made read-only.
Line 4Only declaring n final, and removing bump(), would give instances that genuinely cannot change.
sealed as a permitted list instead of a hard lock
Java 17 or newer: extension is still allowed, but only by the subclasses the author names.
import java.util.Locale;
abstract sealed class Shape permits Circle, Square {
abstract double area();
}
final class Circle extends Shape {
private final double r;
Circle(double r) { this.r = r; }
@Override double area() { return Math.PI * r * r; }
}
final class Square extends Shape {
private final double s;
Square(double s) { this.s = s; }
@Override double area() { return s * s; }
}
// final class Triangle extends Shape { } // error: Triangle is not listed in permits
public class Main {
public static void main(String[] args) {
Shape[] shapes = { new Circle(1), new Square(3) };
for (Shape s : shapes) {
System.out.printf(Locale.US, "%s area %.3f%n", s.getClass().getSimpleName(), s.area());
}
System.out.println("Shape sealed: " + Shape.class.isSealed());
}
}Example explained
Line 1permits Circle, Square names the complete set of direct subclasses, so the commented Triangle is rejected by javac even though Shape is not final.
Line 2Each permitted subclass must state how far extension goes; Circle and Square are final, so the hierarchy stops there.
Line 3isSealed() reports true, and that closed set is what lets the compiler check a switch over Shape for exhaustiveness.
Line 4Use this when a fixed family of subtypes is the point, and plain final when no subtype is wanted at all.
Important notes
final on a private method adds nothing, because private methods are not inherited; a subclass method with the same name is a separate method, never an override.
abstract and final on the same class is a compile error, and records are implicitly final, so you cannot extend a record even though no final keyword appears in it.
Common mistakes
Reading final class as immutable and then sharing the instance across threads with no synchronization: the fields are still writable, so you get a data race instead of a safe value object.
Declaring every method final in a library other people must extend, then finding that mocking frameworks and proxy-based tools, which work by subclassing, cannot intercept those methods at all.
Trying to get around a final method by changing its parameter list: that compiles as an overload, so calls made with the original arguments still reach the final version and the new method never runs.
Calling an overridable method from a constructor while the checks around it are final: the subclass override runs before the subclass fields are assigned and sees zeros and nulls.
Try it yourself
Change, predict, then run
Write a class Job with a final run() that prints a fixed header line, calls a protected body(), then prints a footer, and a subclass that overrides only body(). Then add public void run() to the subclass, read the compiler error, and delete it.
Open the Java workspaceCheck your understanding
Box declares public final String label() { return "box"; } and its subclass Crate adds public String label(int copies) { return "crate"; }. What happens with Box b = new Crate(); System.out.println(b.label());?
- Crate does not compile, because a final method cannot be overloaded either
- It prints crate, because at runtime the most specific subclass method wins
- It prints box, because label(int) is an unrelated overload and final only blocks the same signature
- It compiles, but calling label() on a Crate throws a runtime error about the final method
Show answer
final forbids a subclass method with the identical name and parameter list; label(int) has a different signature, so it is simply a new method Crate adds, and a zero-argument call can only resolve to the inherited final label(). Option 1 assumes dynamic dispatch will find the subclass method, but which overload is called is decided at compile time from the static type Box, and no runtime dispatch can route a no-argument call to a method that requires an int.