Back to Blog
Java

Java Exception Hierarchy: Checked vs Unchecked

java exception hierarchy: Understand the Java exception hierarchy from Throwable down to custom exceptions, including checked vs unchecked rules and practical handling...

exception-handlingchecked-exceptionsunchecked-exceptionsthrowablecustom-exceptionserror-handling
Diagram of the Java exception hierarchy showing Throwable, Error, and Exception branches with checked and unchecked categories.

The java exception hierarchy is a tree of classes rooted at Throwable, and every exception thrown in a Java program is an instance of one of its descendants. Understanding that structure matters because it determines what you can catch, what the compiler forces you to handle, and how you design errors for your own APIs.

The Root of the Hierarchy: Throwable

Throwable is the superclass of all errors and exceptions in Java. It has two direct subclasses: Error and Exception. The distinction between these two branches is not cosmetic; it reflects the intended severity and recoverability of the condition.

Error represents serious problems that a well-behaved application should not attempt to catch. Examples include OutOfMemoryError, StackOverflowError, and AssertionError. These indicate that the JVM or the application is in a state where recovery is unlikely or impossible. Catching Error is almost always a mistake because it can leave the system in an inconsistent state.

Exception is the branch that application code is expected to deal with. It is further divided into checked exceptions and unchecked exceptions, a split that the Java compiler enforces at compile time.

Checked vs Unchecked Exceptions

Checked exceptions are subclasses of Exception that are not also subclasses of RuntimeException. The compiler requires a method to either declare them in its throws clause or handle them with a try-catch block. IOException, SQLException, and ClassNotFoundException are typical checked exceptions.

Unchecked exceptions are subclasses of RuntimeException. They do not need to be declared or caught. NullPointerException, IllegalArgumentException, and IndexOutOfBoundsException belong to this group. The compiler does not enforce handling because these exceptions usually indicate programming bugs that should be fixed rather than handled at runtime.

This split is a design choice that forces developers to think about failure modes that are outside the immediate control of the code, such as I/O failures, while leaving programming errors alone.

Common Exception Subtypes and Their Use

Within the Exception branch, several subtypes appear frequently in real code. IOException covers failures during input and output operations. InterruptedException signals that a thread has been interrupted while waiting. ReflectiveOperationException is the parent of reflection-related exceptions like ClassNotFoundException and NoSuchMethodException.

On the RuntimeException side, IllegalArgumentException is often used to reject invalid method arguments. IllegalStateException indicates that an object is not in a legal state for a requested operation. NullPointerException is the most common runtime exception, and UnsupportedOperationException marks methods that are intentionally not implemented.

Knowing these subtypes helps you write precise catch blocks. Catching a general Exception is convenient but often hides the specific failure and makes debugging harder. Prefer catching the most specific subtype that you can actually handle.

How the Compiler Enforces Checked Exceptions

The compiler's enforcement of checked exceptions is a static analysis. When a method calls another method that declares a checked exception, the caller must either catch that exception or declare it in its own throws clause. This propagation continues up the call stack until a try-catch block or a throws declaration is found.

public void readFile(String path) throws IOException { Files.readAllLines(Paths.get(path)); }

In this example, readAllLines declares IOException, so readFile must also declare it. If a caller of readFile does not handle IOException, it must declare it as well. This chain ensures that checked exceptions are visible at every level of the call stack.

Unchecked exceptions do not participate in this analysis. A method can throw NullPointerException without any declaration, and callers are not forced to catch it. This distinction is the core practical difference between the two branches.

Catching and Rethrowing: Preserving the Original Failure

When you catch an exception and then throw a new one, you risk losing the original cause. Java provides the cause field in Throwable to address this. Always pass the original exception to the new exception's constructor.

try { parse(input); } catch (ParseException e) { throw new ProcessingException("Failed to parse input", e); }

The second argument to the ProcessingException constructor sets the cause. Later, when you inspect the stack trace, the cause chain shows both the new exception and the original ParseException. Without this, the root cause is lost, and debugging becomes guesswork.

Java 7 introduced multi-catch, which lets you catch several exception types in one block when they are not in a subclass relationship.

try { readAndParse(file); } catch (IOException | ParseException e) { log.error("Operation failed", e); }

This reduces duplication and keeps the handling logic together. However, the variable e is effectively final, so you cannot reassign it inside the block.

Performance Considerations in Exception Handling

Exception handling has a runtime cost that is often misunderstood. Throwing an exception is not free; it involves constructing a stack trace, which requires capturing the current call stack. The cost is proportional to the depth of the stack and the number of frames captured.

In modern JVMs, the performance of exception handling when no exception is thrown is negligible. The try block itself adds almost no overhead. The cost appears when an exception is actually thrown. This is why exceptions should not be used for normal control flow. A loop that throws an exception on every iteration will be significantly slower than one that checks a condition first.

// Avoid this pattern for (int i = 0; i < list.size(); i++) { try { process(list.get(i)); } catch (IndexOutOfBoundsException e) { break; } }

This loop uses an exception to detect the end of the list. It works, but it creates a stack trace on every iteration. A simple i < list.size() check is faster and clearer. Use exceptions for exceptional conditions, not for expected flow.

Another subtle cost is that filling in the stack trace can be disabled for performance-critical paths by using Throwable(String message, Throwable cause, boolean enableSuppression, boolean writableStackTrace) constructor. This is an advanced option and should be used only after profiling shows that exception creation is a bottleneck.

Designing Custom Exceptions Within the Hierarchy

When you create a custom exception, you must decide whether it should be checked or unchecked. The decision should be based on whether the caller can reasonably be expected to recover from the condition.

If the failure is due to an external factor that the caller should handle, such as a missing file or a failed network request, make it a checked exception by extending Exception. If the failure is a programming error, such as an invalid argument or an illegal state, extend RuntimeException instead.

public class InsufficientBalanceException extends Exception { public InsufficientBalanceException(String message) { super(message); } public InsufficientBalanceException(String message, Throwable cause) { super(message, cause); } }

A custom exception should provide constructors that match the common patterns: a message-only constructor, a cause-only constructor, and a constructor with both. This makes it easy to throw and wrap exceptions without losing information.

Avoid creating a new exception class for every minor condition. If the only difference is the message, use a standard exception like IllegalArgumentException with a descriptive message. Custom classes add maintenance overhead and should be reserved for cases where the type itself carries meaning, such as when a caller needs to catch it specifically.

Compatibility and Maintainability Tradeoffs

The checked exception design has been debated since Java's early days. The main tradeoff is between safety and convenience. Checked exceptions force callers to acknowledge failure modes, which can improve reliability. But they also make code verbose and can lead to empty catch blocks when developers do not know what to do.

When you modify a method that throws a checked exception, changing the exception type can break all callers. This is a compatibility concern. Unchecked exceptions avoid this problem because they are not part of the method signature. For library APIs, this is a significant consideration.

A common compromise is to use unchecked exceptions for programming errors and checked exceptions only for conditions that the caller is expected to handle. This keeps the API clean while still forcing important failures to be addressed.

Another maintainability issue is exception swallowing. A catch block that does nothing hides the failure and makes the system harder to diagnose. If you cannot handle the exception, rethrow it as a different type or log it with enough context. The java exception hierarchy gives you the tools to preserve the original cause, so use them.

Finally, remember that Error and Exception are both subclasses of Throwable. Catching Throwable catches everything, including OutOfMemoryError and ThreadDeath. This is almost never what you want. Stick to catching Exception or more specific subtypes unless you have a very specific reason to catch Error.

java exception hierarchy: Practical Usage and Code Examples | RYUSLOG DEV