Skip to content
academia.sh

Lesson 11 / 13

Exception Hierarchy

What the checked/unchecked exception distinction forces at compile time gets measured: only one of nine writings gets rejected, the declaration sits in the class file as an attribute, not an instruction, and the virtual machine never checks it. Which catch branch runs, though, is decided by the runtime type.

Contents

The previous two lessons counted the gates the compiler closes: writing to the wrong type did not compile, writing to a covariant view did not compile, writing back a value read from a container did not compile. In each of these, the compiler placed a prohibition. Java also has something else the compiler imposes on source: a requirement — a method cannot be written without declaring or handling certain events.

That an exception is an object, that a catch takes a class, and how many events the hierarchy holds got measured in the Python Fundamentals course; that arithmetic is not repeated here. The question here is this course’s question, and it has two answers: the declared type decides the requirement to declare an exception, while the runtime type decides which catch branch runs. This lesson measures both in the same place.

The One Thing the Compiler Forces

  • GE15 — Three exception kinds get defined in source within the lesson: a checked exception under Exception, an unchecked exception under RuntimeException, a severe error under Error.
  • GE16 — The three writing styles carry the same body; only the method’s declaration and what surrounds the body change.
  • GE17 — The compiler’s message text is not printed; what gets measured is whether the writing gets accepted.
// Scale.java - shared measurement core: compiles a class file and reads it
import java.io.PrintWriter;
import java.io.Writer;
import java.lang.classfile.*;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;

class Scale {
    static ClassModel compile(String source, String className) throws Exception {
        Path dir = Files.createTempDirectory("scale");
        Path file = dir.resolve(className + ".java");
        Files.writeString(file, source);
        PrintWriter silent = new PrintWriter(Writer.nullWriter());
        if (ToolProvider.findFirst("javac").orElseThrow()
                .run(silent, silent, "-d", dir.toString(), file.toString()) != 0)
            throw new IllegalStateException("did not compile: " + className);
        return ClassFile.of().parse(dir.resolve(className + ".class"));
    }

    static List<String> instructions(String source, String className, String methodName) throws Exception {
        List<String> names = new ArrayList<>();
        for (MethodModel m : compile(source, className).methods())
            if (m.methodName().stringValue().equals(methodName))
                for (CodeElement e : m.code().orElseThrow())
                    if (e instanceof Instruction i)
                        names.add(i.opcode().name().toLowerCase(Locale.ROOT));
        return names;
    }

    static String shortName(String internalName) { return internalName.substring(internalName.lastIndexOf('/') + 1); }
}
// Mandatory.java - three exception kinds, three writing styles
public class Mandatory {
    static final String TYPES = """
        class BaseException extends Exception { BaseException(String m) { super(m); } }
        class SilentException extends RuntimeException { SilentException(String m) { super(m); } }
        class SevereError extends Error { SevereError(String m) { super(m); } }
        """;

    static final String[] KINDS = {"BaseException", "SilentException", "SevereError"};

    static final String WITHOUT_DECLARING = "class C { static void f() { throw new %1$s(\"event\"); } }";
    static final String WITH_DECLARATION =
            "class C { static void f() throws %1$s { throw new %1$s(\"event\"); } }";
    static final String WITH_CATCH =
            "class C { static void f() { try { throw new %1$s(\"event\"); } catch (%1$s e) { } } }";

    static String attempt(String pattern, String kind) {
        try {
            Scale.compile(TYPES + pattern.formatted(kind), "C");
            return "compiled";
        } catch (Exception e) {
            return "did not compile";
        }
    }

    public static void main(String[] args) {
        System.out.printf("%-16s %-17s %-17s %s%n",
                "exception kind", "without declaring", "with declaration", "with catch");
        int passed = 0;
        for (String k : KINDS) {
            String a = attempt(WITHOUT_DECLARING, k), b = attempt(WITH_DECLARATION, k), c = attempt(WITH_CATCH, k);
            for (String s : new String[] {a, b, c}) if (s.equals("compiled")) passed++;
            System.out.printf("%-16s %-17s %-17s %s%n", k, a, b, c);
        }
        System.out.println("compiled count of nine writings: " + passed);
    }
}
exception kind   without declaring with declaration  with catch
BaseException    did not compile   compiled          compiled
SilentException  compiled          compiled          compiled
SevereError      compiled          compiled          compiled
compiled count of nine writings: 8

Eight of nine writings compile. The one rejected writing sits in the table’s top-left corner: a method that throws a checked exception and neither declares nor catches it. This is the entire thing a checked exception forces at compile time — making a choice. There is no difference to the compiler between declaring and catching; both close the same gate, and both columns are equally valid.

The second and third rows are the other face of the distinction. For the unchecked exception and the severe error, all three writings compile: they can be declared, they can be caught, but neither is required. An unchecked exception can leave a method without leaving a trace anywhere in the source.

Two sentences follow from this. First: the checked/unchecked distinction is not a distinction of behavior; both get thrown, both get caught, both fold the call stack upward. The distinction shows up only in whether the compiler accepts a writing. Second: this requirement looks at the declared type. The compiler does not learn what a body actually throws by running it; it reads the declarations of the methods it calls.

A Declaration Is an Attribute, Not an Instruction

  • GE18 — Both sources carry a single method, and their bodies are the same shape; only the kind of thrown exception changes.
// Declaration.java - where the checked-exception declaration sits in the class file
import java.lang.classfile.*;
import java.lang.classfile.attribute.ExceptionsAttribute;
import java.util.List;

public class Declaration {
    static final String TYPES = """
        class BaseException extends Exception { BaseException(String m) { super(m); } }
        class SilentException extends RuntimeException { SilentException(String m) { super(m); } }
        """;

    static final String DECLARING = TYPES
            + "class C { static void f() throws BaseException { throw new BaseException(\"event\"); } }";
    static final String NOT_DECLARING = TYPES
            + "class C { static void f() { throw new SilentException(\"event\"); } }";

    static void report(String label, String source) throws Exception {
        for (MethodModel m : Scale.compile(source, "C").methods())
            if (m.methodName().stringValue().equals("f"))
                System.out.printf("%-14s descriptor %-6s exceptions attribute %s%n", label,
                        m.methodType().stringValue(),
                        m.findAttribute(Attributes.exceptions())
                                .map(ExceptionsAttribute::exceptions).orElse(List.of())
                                .stream().map(e -> Scale.shortName(e.asInternalName())).toList());
    }

    public static void main(String[] args) throws Exception {
        report("checked", DECLARING);
        report("unchecked", NOT_DECLARING);
        System.out.println();
        System.out.println("checked throw    : " + Scale.instructions(DECLARING, "C", "f"));
        System.out.println("unchecked throw  : " + Scale.instructions(NOT_DECLARING, "C", "f"));
    }
}
checked        descriptor ()V    exceptions attribute [BaseException]
unchecked      descriptor ()V    exceptions attribute []

checked throw    : [new, dup, ldc, invokespecial, athrow]
unchecked throw  : [new, dup, ldc, invokespecial, athrow]

The two methods’ descriptors are the same: ()V. A method’s identity as the virtual machine sees it is made of its parameter types and return type; the exceptions it might throw do not enter there. The declaration sits in a separate attribute, and for the unchecked exception, that attribute is empty.

The lower lines are this fact’s counterpart on the code side: the two throws are the exact same five instructions. Create the object, duplicate, load the message, call the constructor, throw. In the class file’s executed part, there is no difference between a checked and an unchecked exception. The difference sits only in the attribute the compiler reads — just like a type parameter sitting only in the signature attribute. The pattern measured in this course’s first lesson shows up here once more: information gets written into the class file as data, not as a check.

The Virtual Machine Does Not Look at the Declaration

  • GE19 — The measurement is built within the language itself, using no outside tool; the only information printed about the exception object is its class name.
// Hidden.java - does the virtual machine check the declaration
class BaseException extends Exception {
    BaseException(String m) { super(m); }
}

public class Hidden {
    @SuppressWarnings("unchecked")
    static <T extends Throwable> void raise(Throwable t) throws T {
        throw (T) t;
    }

    static void undeclared() {
        Hidden.<RuntimeException>raise(new BaseException("event"));
    }

    public static void main(String[] args) {
        try {
            undeclared();
            System.out.println("no exception came out");
        } catch (Throwable t) {
            System.out.println("came out of the undeclared method      : " + t.getClass().getSimpleName());
            System.out.println("is it a checked exception               : "
                    + (t instanceof RuntimeException || t instanceof Error ? "no" : "yes"));
            System.out.println("did the virtual machine block the path  : no");
        }
    }
}
came out of the undeclared method      : BaseException
is it a checked exception               : yes
did the virtual machine block the path  : no

The undeclared method declares no exception, and its exceptions attribute is empty. Despite this, a checked exception comes out of that method, and nothing stops this path throughout the run.

How this happens joins the two halves of this topic. The raise method is declared with the type parameter T; at the call site, RuntimeException gets substituted for T, so to the compiler this method throws an unchecked exception. The cast in the body, though, happens to T, and because of type erasure, that cast leaves no check in the class file. The guarantee the compiler builds with the declaration slips through the gap type-parameter erasure opens up.

The rule the measurement states: a checked-exception declaration is a contract between compilations, not a runtime guarantee. The virtual machine never checks whether an exception coming out of a method is written in that method’s declaration. By this course’s measure, this is a pure declared-type decision, and the runtime type has nothing to say about it.

A Declaration Can Narrow, Not Widen

  • GE20 — In the five attempts, only the subclass’s declaration changes; the superclass’s declaration, the exception types, and the calling code are the same in every attempt.
// Widen.java - can an overriding method widen the declaration
public class Widen {
    static final String PATTERN = """
        class BaseException extends Exception { BaseException(String m) { super(m); } }
        class DerivedException extends BaseException { DerivedException(String m) { super(m); } }
        class IndependentException extends Exception { IndependentException(String m) { super(m); } }
        class Base { void f() throws BaseException { } }
        class Derived extends Base { @Override void f() %s { } }
        class C { static void c(Base u) { try { u.f(); } catch (Exception e) { } } }
        """;

    static void attempt(String declaration) {
        String label = declaration.isEmpty() ? "(no declaration)" : declaration;
        try {
            Scale.compile(PATTERN.formatted(declaration), "C");
            System.out.printf("Derived.f() %-28s -> compiled%n", label);
        } catch (Exception e) {
            System.out.printf("Derived.f() %-28s -> did not compile%n", label);
        }
    }

    public static void main(String[] args) throws Exception {
        for (String d : new String[] {"throws BaseException", "throws DerivedException", "",
                "throws IndependentException", "throws RuntimeException"})
            attempt(d);

        System.out.println();
        String call = """
            class BaseException extends Exception { BaseException(String m) { super(m); } }
            class Base { void f() throws BaseException { } }
            class Derived extends Base { @Override void f() { } }
            class C { static void c() { %s.f(); } }
            """;
        for (String declared : new String[] {"new Base()", "new Derived()"}) {
            String label = declared.equals("new Base()") ? "Base" : "Derived";
            try {
                Scale.compile(call.formatted(declared), "C");
                System.out.printf("declared type %-8s call unhandled -> compiled%n", label);
            } catch (Exception e) {
                System.out.printf("declared type %-8s call unhandled -> did not compile%n", label);
            }
        }
    }
}
Derived.f() throws BaseException         -> compiled
Derived.f() throws DerivedException      -> compiled
Derived.f() (no declaration)             -> compiled
Derived.f() throws IndependentException  -> did not compile
Derived.f() throws RuntimeException      -> compiled

declared type Base     call unhandled -> did not compile
declared type Derived  call unhandled -> compiled

The upper table’s rule runs one direction. An overriding method can declare exactly the exception the superclass declares, can declare a subtype of it, can declare nothing — but it cannot add another checked exception. Declaring an unchecked exception is free in every case, because there was never any requirement for it to begin with.

The lower two lines measure this rule’s reasoning, and answer this course’s question right at its center. Derived‘s method declares no exception. The same object, named as Derived, compiles with the call unhandled; named as Base, it does not compile. The object is the same, the body that runs is the same, the result is the same — the only thing that changes is the **name’s declared type**. Since the compiler reads the handling requirement only from there, letting the subclass’s declaration widen would make no call written through Base trustworthy.

The Two Sides of a Catch

// Catch.java - the two sides of catch: which branch can be written, which branch runs
class BaseException extends Exception {
    BaseException(String m) { super(m); }
}

class DerivedException extends BaseException {
    DerivedException(String m) { super(m); }
}

public class Catch {
    static final String PATTERN = """
        class BaseException extends Exception { BaseException(String m) { super(m); } }
        class DerivedException extends BaseException { DerivedException(String m) { super(m); } }
        class IndependentException extends Exception { IndependentException(String m) { super(m); } }
        class C {
            static void raise() throws BaseException { throw new DerivedException("event"); }
            static void y() throws BaseException { try { raise(); } catch (%s e) { } }
        }
        """;

    static void attempt(String branch) {
        try {
            Scale.compile(PATTERN.formatted(branch), "C");
            System.out.printf("catch (%-19s -> compiled%n", branch + " e)");
        } catch (Exception e) {
            System.out.printf("catch (%-19s -> did not compile%n", branch + " e)");
        }
    }

    static void raise() throws BaseException {
        throw new DerivedException("event");
    }

    public static void main(String[] args) {
        for (String branch : new String[]
                {"BaseException", "DerivedException", "IndependentException", "RuntimeException", "Exception"})
            attempt(branch);

        System.out.println();
        try {
            raise();
        } catch (BaseException e) {
            System.out.println("BaseException branch caught, object's class : "
                    + e.getClass().getSimpleName());
        }
        try {
            raise();
        } catch (DerivedException e) {
            System.out.println("branch that runs with two branches          : DerivedException");
        } catch (BaseException e) {
            System.out.println("branch that runs with two branches          : BaseException");
        }
    }
}
catch (BaseException e)    -> compiled
catch (DerivedException e) -> compiled
catch (IndependentException e) -> did not compile
catch (RuntimeException e) -> compiled
catch (Exception e)        -> compiled

BaseException branch caught, object's class : DerivedException
branch that runs with two branches          : DerivedException

The upper table is a catch’s compile-time face, and the one rejected branch is the interesting one. IndependentException is a checked exception and does not appear in the declaration of the call inside the try block; the compiler does not even let that branch be written. The decision here comes entirely from declared types: the compiler works out, from the declarations, which checked exceptions can come out of the block, and treats a branch outside that set as dead. The RuntimeException branch passing is the other side of the same rule — since an unchecked exception can come from anywhere, it is never considered dead.

The lower lines are the same catch’s runtime face, and this lesson’s boundary measurement. The throwing method declares BaseException, but the class of the object it throws is DerivedException. The BaseException branch catches this object, and the caught object’s class reads as DerivedException. Once the two branches sit side by side, the branch that runs is DerivedException — that is, the choice is made not from the declared type, but from the runtime type.

Finding two opposite answers in the same structure is this course’s basic claim, in this topic’s shape. A catch branch being writable depends on the declared type; it running depends on the runtime type. A programmer looking at the wrong side can pay for this in two separate ways: writing a narrow branch against the upper type and seeing it never run, or writing a narrow branch under a wide one and seeing it not compile.

Summary

  • The only thing a checked exception forces at compile time is a choice: declaring it or catching it. Only one of nine writings gets rejected; all three writings for the unchecked exception and the severe error are valid.
  • There is no behavioral difference between a checked and an unchecked exception; the difference sits only in whether the compiler accepts a writing.
  • The declaration does not enter the method’s descriptor, it sits in a separate attribute; the two kinds’ throwing code is the exact same five instructions.
  • The virtual machine never checks the declaration: a checked exception thrown through a type parameter can come out of a method that declares nothing. A declaration is a compile-time contract.
  • An overriding method can narrow its declaration, not widen it; a call looking at the same object as Base has to handle it, a call looking at it as Derived does not.
  • Boundary measurement: the declared type decides which catch branch can be written, the runtime type decides which one runs. Two opposite answers sit together in the same structure.

Next Step

In this lesson, one exception got thrown, declared, and caught; every measurement had a single event, and where it went got tracked. In a real body, though, an exception is often not alone: if a resource got opened, that resource has to be closed while the body is failing, and the close can fail too. In that case, two exceptions show up at once, and one can cover the other. The next lesson puts a hand-written close next to a compiler-generated one, and counts how many exceptions get lost, how many get kept, and in what order the closing happens.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close