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 underRuntimeException, a severe error underError. - 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
Basehas to handle it, a call looking at it asDeriveddoes 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.