← Retour au blog
javaoop

Why Final Classes Exist in Java (And When to Use Them)

·5 min de lecture

Java's final keyword on a class means one thing: no subclassing allowed.

public final class Human { }

public class Man extends Human { }  // compile error

That's the whole mechanic. The interesting question is why you'd want it — because at first glance, preventing extension seems to go against the grain of object-oriented design.

The case for final

Design intent

The most underrated reason to mark a class final is communication. When a future developer sees final, they know the original author decided this class was not designed for inheritance. That's meaningful signal. Without it, they're left guessing whether extending it is safe or whether it will produce subtle breakage.

Joshua Bloch puts this crisply in Effective Java:

"Design and document for inheritance, or else prohibit it."

If you haven't explicitly thought through how a class behaves when subclassed — what the invariants are, which methods are safe to override, what the consequences of calling overridable methods from the constructor are — then the honest thing is to make it final rather than leave it open and hope for the best.

Security

Classes that can be subclassed can have their behavior silently replaced. A subclass can override a method used internally and intercept or corrupt data the parent class assumes is under its control. For classes that enforce invariants (think String, Integer, the wrapper types) or handle sensitive operations, final is a hard boundary against this.

It's not coincidental that String is final in Java. A mutable or overridable String would make it impossible to safely pass strings across security boundaries.

Performance

The JVM can inline calls to final methods at compile time because it knows there's no subclass that could override them. This is a minor consideration for most code, but in hot paths it matters.

The danger of unconstrained inheritance: a concrete example

This example is from Effective Java and it's worth reading carefully, because the bug is non-obvious.

Suppose you want a HashSet that tracks how many elements have been added to it over its lifetime:

public class InstrumentedHashSet<E> extends HashSet<E> {
    private int addCount = 0;

    public InstrumentedHashSet() { }

    @Override
    public boolean add(E e) {
        addCount++;
        return super.add(e);
    }

    @Override
    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return super.addAll(c);
    }

    public int getAddCount() {
        return addCount;
    }
}

Reasonable code. Now add three elements:

InstrumentedHashSet<String> s = new InstrumentedHashSet<>();
s.addAll(Arrays.asList("Snap", "Crackle", "Pop"));

System.out.println(s.getAddCount()); // Expected: 3. Actual: 6.

The count is six, not three.

What happened: addAll in InstrumentedHashSet adds 3 to addCount, then delegates to HashSet.addAll. But HashSet.addAll is implemented by calling this.add() for each element — which is the overridden add() in InstrumentedHashSet, incrementing addCount again for each of the three elements.

The bug is a consequence of relying on an implementation detail of HashSet that isn't documented and isn't guaranteed to remain stable across Java versions. HashSet was not designed for this kind of instrumented subclassing, and there's no way to make InstrumentedHashSet correct without either removing the addAll override (and hoping HashSet's implementation never changes) or doing something fragile.

The correct solution here is composition, not inheritance:

public class InstrumentedSet<E> {
    private final Set<E> set;
    private int addCount = 0;

    public InstrumentedSet(Set<E> set) {
        this.set = set;
    }

    public boolean add(E e) {
        addCount++;
        return set.add(e);
    }

    public boolean addAll(Collection<? extends E> c) {
        addCount += c.size();
        return set.addAll(c);
    }

    public int getAddCount() { return addCount; }
}

Now InstrumentedSet wraps any Set implementation, controls exactly which operations it intercepts, and is immune to changes in the wrapped class's internal implementation. No surprises.

When to use final in practice

The default in most modern Java codebases leans toward final. It's easier to remove final later (non-breaking change) than to add it after a class has been subclassed in the wild.