Exception Basics
An exception is an event that disrupts the normal flow of a program during execution. Java's exception system is a structured mechanism for signalling, propagating, and handling error conditions — a significant improvement over the older approach of returning special error codes that callers could silently ignore. Understanding exceptions means understanding the class hierarchy that categorises them, the distinction between checked and unchecked exceptions, what the JVM does when an exception is thrown, how the call stack is unwound, and what information an exception object carries. This entry covers the full exception hierarchy, the checked vs unchecked distinction and the reasoning behind it, exception propagation through the call stack, and the information model of exception objects.
The Exception Hierarchy
// ── The Exception Hierarchy ─────────────────────────────────────────
//
// Throwable
// ├── Error (JVM problems — almost never catch)
// │ ├── OutOfMemoryError
// │ ├── StackOverflowError
// │ ├── VirtualMachineError
// │ ├── AssertionError
// │ └── ExceptionInInitializerError
// │
// └── Exception (application problems — handle these)
// ├── IOException (checked)
// │ ├── FileNotFoundException (checked)
// │ └── SocketException (checked)
// ├── SQLException (checked)
// ├── CloneNotSupportedException (checked)
// │
// └── RuntimeException (unchecked — compiler does not enforce)
// ├── NullPointerException
// ├── IllegalArgumentException
// ├── IllegalStateException
// ├── IndexOutOfBoundsException
// │ ├── ArrayIndexOutOfBoundsException
// │ └── StringIndexOutOfBoundsException
// ├── ArithmeticException
// ├── ClassCastException
// ├── NumberFormatException
// ├── UnsupportedOperationException
// └── ConcurrentModificationException
// ── Checked vs unchecked ──────────────────────────────────────────────
// Checked — compiler REQUIRES handling (catch or declare in throws):
void readFile(String path) throws IOException { // must declare
Files.readAllBytes(Path.of(path)); // throws IOException
}
// Unchecked — compiler does NOT require handling:
void processNumber(String s) {
int n = Integer.parseInt(s); // may throw NumberFormatException
// No throws declaration required
}
// ── instanceof checks ─────────────────────────────────────────────────
try {
throw new FileNotFoundException("file.txt not found");
} catch (Throwable t) {
System.out.println(t instanceof FileNotFoundException); // true
System.out.println(t instanceof IOException); // true
System.out.println(t instanceof Exception); // true
System.out.println(t instanceof Throwable); // true
System.out.println(t instanceof RuntimeException); // false
}Checked vs Unchecked — The Design Philosophy
// ── Checked exception — compiler enforces awareness ──────────────────
// The compiler WILL NOT compile this without handling IOException:
void readConfig() { // compile error — unhandled IOException
Files.readAllBytes(Path.of("app.cfg")); // throws IOException (checked)
}
// Must either catch:
void readConfig_catching() {
try {
Files.readAllBytes(Path.of("app.cfg"));
} catch (IOException e) {
System.err.println("Config not found: " + e.getMessage());
}
}
// Or declare:
void readConfig_declaring() throws IOException {
Files.readAllBytes(Path.of("app.cfg")); // propagates to caller
}
// ── Unchecked exception — caller has a bug ────────────────────────────
void processAge(int age) {
if (age < 0) {
throw new IllegalArgumentException(
"Age cannot be negative: " + age); // no throws needed
}
}
// ── The viral declaration problem with checked exceptions ─────────────
// If a low-level method throws a checked exception:
void parseData() throws ParseException { ... }
// Every method in the call chain must declare or catch it:
void processData() throws ParseException { parseData(); }
void handleRequest() throws ParseException { processData(); }
void dispatch() throws ParseException { handleRequest(); }
void serve() throws ParseException { dispatch(); }
// Checked exception forces its way into every signature up the stack
// ── Common pattern: wrap checked as unchecked at boundary ─────────────
// Libraries often do this to avoid polluting the entire call stack
public class DataProcessingException extends RuntimeException {
public DataProcessingException(String message, Throwable cause) {
super(message, cause);
}
}
void processData_wrapped() {
try {
parseData();
} catch (ParseException e) {
throw new DataProcessingException("Failed to parse data", e);
}
}Exception Propagation — The Call Stack
// ── Exception propagation through the call stack ────────────────────
public class PropagationDemo {
public static void main(String[] args) {
try {
methodA();
} catch (Exception e) {
System.out.println("Caught in main: " + e.getMessage());
e.printStackTrace();
}
}
static void methodA() {
methodB(); // exception propagates up from methodB
}
static void methodB() {
methodC(); // exception propagates up from methodC
}
static void methodC() {
throw new IllegalStateException("Something went wrong in C");
// Not caught here — propagates to methodB
// Not caught in methodB — propagates to methodA
// Not caught in methodA — propagates to main
// Caught in main's try-catch
}
}
// ── Stack trace output ────────────────────────────────────────────────
// Exception in thread "main" java.lang.IllegalStateException:
// Something went wrong in C
// at PropagationDemo.methodC(PropagationDemo.java:24) ← thrown here
// at PropagationDemo.methodB(PropagationDemo.java:20) ← called methodC
// at PropagationDemo.methodA(PropagationDemo.java:16) ← called methodB
// at PropagationDemo.main(PropagationDemo.java:7) ← called methodA
// ── Exception object information ─────────────────────────────────────
try {
int[] arr = new int[5];
arr[10] = 1;
} catch (ArrayIndexOutOfBoundsException e) {
System.out.println(e.getMessage()); // "Index 10 out of bounds for length 5"
System.out.println(e.getClass().getName()); // java.lang.ArrayIndexOutOfBoundsException
System.out.println(e.getClass().getSimpleName()); // ArrayIndexOutOfBoundsException
// Stack trace as an array of StackTraceElement
StackTraceElement[] trace = e.getStackTrace();
System.out.println(trace[0].getClassName()); // class where thrown
System.out.println(trace[0].getMethodName()); // method where thrown
System.out.println(trace[0].getLineNumber()); // line number
// Print stack trace to a string (useful for logging)
StringWriter sw = new StringWriter();
e.printStackTrace(new PrintWriter(sw));
String stackTraceString = sw.toString();
}Creating Custom Exceptions
// ── Custom checked exception ─────────────────────────────────────────
public class InsufficientFundsException extends Exception {
private final double amount;
private final double balance;
public InsufficientFundsException(double amount, double balance) {
super(String.format(
"Cannot withdraw %.2f: balance is %.2f", amount, balance));
this.amount = amount;
this.balance = balance;
}
public InsufficientFundsException(String message, Throwable cause) {
super(message, cause);
this.amount = 0;
this.balance = 0;
}
public double getAmount() { return amount; }
public double getBalance() { return balance; }
}
// ── Custom unchecked exception ────────────────────────────────────────
public class OrderNotFoundException extends RuntimeException {
private final Long orderId;
public OrderNotFoundException(Long orderId) {
super("Order not found: " + orderId);
this.orderId = orderId;
}
// Cause constructor — preserves root cause in chain
public OrderNotFoundException(Long orderId, Throwable cause) {
super("Order not found: " + orderId, cause);
this.orderId = orderId;
}
public Long getOrderId() { return orderId; }
}
// ── Domain exception hierarchy ────────────────────────────────────────
public class AppException extends RuntimeException {
public AppException(String message) { super(message); }
public AppException(String message, Throwable cause) { super(message, cause); }
}
public class ValidationException extends AppException {
private final String field;
private final Object rejectedValue;
public ValidationException(String field, Object rejectedValue, String msg) {
super(msg);
this.field = field;
this.rejectedValue = rejectedValue;
}
public String getField() { return field; }
public Object getRejectedValue() { return rejectedValue; }
}
public class ServiceException extends AppException {
public ServiceException(String msg) { super(msg); }
public ServiceException(String msg, Throwable cause) { super(msg, cause); }
}