---
title: 'Exception Hierarchy'
source: 'https://academia.sh/en/courses/java-object-model/exception-hierarchy'
course: 'Object-Oriented Java'
language: en
updated: '2026-08-17T18:09:41+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```java
// 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); }
}
```

```java
// 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.

```java
// 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.

```java
// 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.

```java
// 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

```java
// 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.
