Java Exceptions

An exception in Java is an event that disrupts the normal flow of a program’s instructions, usually triggered by an error such as dividing by zero, accessing an invalid array index, or calling a method on a null reference. Instead of letting the program crash outright, Java lets you detect these events and respond to them gracefully. Exception handling is one of the most important skills for writing robust, production-quality code, because real programs constantly deal with unpredictable input, missing files, network failures, and programmer mistakes.

This lesson covers the full exception model from A to Z: the class hierarchy behind every error, the try/catch/finally syntax, checked vs. unchecked exceptions, writing your own exception classes, what the JVM actually does when an exception is thrown, and the mistakes that trip up almost every beginner (and plenty of experienced developers).

Overview: How Exceptions Work

Every error condition in Java is represented by an object — an instance of a class that ultimately extends java.lang.Throwable. When something goes wrong, either the JVM itself or your own code creates one of these objects and hands it off to the runtime. This is called throwing an exception. If nothing intercepts it, the exception propagates up through every method call on the call stack until it either reaches a handler or reaches main(), at which point the JVM prints a stack trace to standard error and terminates that thread.

Throwable has two direct subclasses: Error and Exception. An Error represents a serious problem the application usually cannot recover from, such as OutOfMemoryError or StackOverflowError; you should almost never catch these. Exception is what your code is meant to handle, and it splits into two practical categories:

  • Checked exceptions are subclasses of Exception (but not RuntimeException) that the compiler forces you to deal with — you must either catch them or declare them on the method signature with throws. IOException is the classic example: the compiler assumes you can and should plan for I/O failures.
  • Unchecked exceptions are RuntimeException and its subclasses, such as NullPointerException, ArithmeticException, and ArrayIndexOutOfBoundsException. These are not checked at compile time because they usually indicate programmer bugs that could theoretically occur almost anywhere; forcing a catch for all of them would make ordinary code unreadable.

Checked vs Unchecked Exceptions

Exception Type Typical Cause
ArithmeticException Unchecked Division by zero
NullPointerException Unchecked Calling a method or field on a null reference
ArrayIndexOutOfBoundsException Unchecked Accessing an invalid array index
NumberFormatException Unchecked Parsing an invalid numeric string
ClassCastException Unchecked Invalid downcast between types
IOException Checked File or network I/O failure
InterruptedException Checked A waiting thread is interrupted

Syntax

The core mechanism for handling exceptions is the try/catch/finally block:

try {
    // code that might throw an exception
} catch (ExceptionType1 e1) {
    // handle ExceptionType1
} catch (ExceptionType2 e2) {
    // handle ExceptionType2
} finally {
    // always executes, exception or not
}
  • try — wraps the code that might fail. It must be followed by at least one catch or a finally.
  • catch — runs if an exception of the matching type (or a subtype) is thrown inside the try block. You can chain multiple catch blocks; Java checks them top to bottom and runs the first one that matches.
  • finally — runs no matter what happens: normal completion, an exception that was caught, or even an exception that wasn’t caught. It’s used for cleanup such as closing files or connections.
  • throw — a statement used inside your own code to raise an exception object: throw new IllegalArgumentException("bad input").
  • throws — a clause on a method signature declaring that the method might propagate a checked exception to its caller.

Examples

Example 1: try/catch/finally with an Unchecked Exception

This program attempts an integer division that fails, catches the resulting ArithmeticException, and shows that finally runs regardless.

public class Main {
    public static void main(String[] args) {
        int a = 10;
        int b = 0;
        try {
            int result = a / b;
            System.out.println("Result: " + result);
        } catch (ArithmeticException e) {
            System.out.println("Cannot divide by zero: " + e.getMessage());
        } finally {
            System.out.println("Division attempt finished.");
        }
        System.out.println("Program continues...");
    }
}

Output:

Cannot divide by zero: / by zero
Division attempt finished.
Program continues...

Because b is 0, the division throws an ArithmeticException before result is ever assigned. Control jumps straight to the matching catch block, then the finally block still runs, and execution resumes normally after the whole try statement — the program does not crash.

Example 2: Multiple catch Blocks

Real programs often need to handle more than one failure mode in the same block of code. Here, parsing a string and storing it in an array can fail in two different, independent ways.

public class Main {
    public static void main(String[] args) {
        String[] inputs = {"42", "abc", "17"};
        int[] numbers = new int[2];
        int index = 0;

        for (String input : inputs) {
            try {
                int value = Integer.parseInt(input);
                numbers[index] = value;
                System.out.println("Stored " + value + " at index " + index);
                index++;
            } catch (NumberFormatException e) {
                System.out.println("Skipping invalid number: " + input);
            } catch (ArrayIndexOutOfBoundsException e) {
                System.out.println("No more room to store values.");
            }
        }
    }
}

Output:

Stored 42 at index 0
Skipping invalid number: abc
Stored 17 at index 1

"abc" cannot be parsed by Integer.parseInt, so a NumberFormatException is thrown and caught without disturbing the loop or the counter. Notice that each iteration gets a fresh chance to fail differently — the two catch blocks handle two unrelated failure types cleanly, instead of one giant if chain checking every precondition up front.

Example 3: A Custom Checked Exception

When none of Java’s built-in exceptions describe your problem domain, you can define your own by extending Exception (checked) or RuntimeException (unchecked).

public class Main {
    static class InsufficientFundsException extends Exception {
        public InsufficientFundsException(String message) {
            super(message);
        }
    }

    static class BankAccount {
        private double balance;

        public BankAccount(double balance) {
            this.balance = balance;
        }

        public void withdraw(double amount) throws InsufficientFundsException {
            if (amount > balance) {
                throw new InsufficientFundsException(
                        "Cannot withdraw $" + amount + ", balance is only $" + balance);
            }
            balance -= amount;
            System.out.println("Withdrew $" + amount + ". New balance: $" + balance);
        }
    }

    public static void main(String[] args) {
        BankAccount account = new BankAccount(100.0);
        try {
            account.withdraw(40.0);
            account.withdraw(90.0);
        } catch (InsufficientFundsException e) {
            System.out.println("Transaction failed: " + e.getMessage());
        }
        System.out.println("Done processing account.");
    }
}

Output:

Withdrew $40.0. New balance: $60.0
Transaction failed: Cannot withdraw $90.0, balance is only $60.0
Done processing account.

Because InsufficientFundsException is checked, the compiler requires withdraw to declare throws InsufficientFundsException, and requires main to either catch it or declare it too. This forces every caller of withdraw to consciously decide how to handle a failed transaction instead of silently ignoring the possibility.

Under the Hood: How the JVM Handles Exceptions

When you write a try block, the compiler does not generate any runtime checks around every single instruction. Instead, javac emits an exception table attached to the method’s bytecode: a list of entries mapping a range of bytecode instructions to a handler address and the exception type that handler catches. This table lives in the compiled .class file, not in the executed instruction stream, so a try block that never throws costs essentially nothing at runtime.

When an exception object is actually thrown — via the athrow bytecode instruction — the JVM looks up the current program counter in the current method’s exception table. If a matching entry is found, execution jumps to that handler. If not, the JVM pops the current stack frame (this is called stack unwinding) and repeats the search in the caller’s frame, and the caller’s caller, and so on, until a handler is found or the thread’s initial frame is reached, at which point the default handler prints the stack trace and the thread dies.

The stack trace itself is captured at the moment the exception object is constructed, not when it’s thrown or caught — the Throwable constructor calls a native method, fillInStackTrace(), which walks the current call stack and records it. This is why constructing exceptions is relatively expensive and why you should never create exception objects in a hot loop just to inspect them without throwing.

The finally block is handled specially by the compiler: it is effectively duplicated into every possible exit path out of the try/catch (normal completion, each catch, and even a return or an uncaught exception), which is exactly why it’s guaranteed to run in every scenario.

Common Mistakes

Mistake 1: Swallowing Exceptions with an Empty catch Block

Catching an exception and doing nothing with it hides bugs and makes debugging painful, because the program keeps running in a broken state with no trace of what went wrong.

int[] data = {1, 2, 3};
try {
    System.out.println(data[10]);
} catch (ArrayIndexOutOfBoundsException e) {
    // do nothing
}
System.out.println("Continuing as if nothing happened...");

Output:

Continuing as if nothing happened...

The failure vanishes silently. At minimum, log or print something so the problem is visible:

int[] data = {1, 2, 3};
try {
    System.out.println(data[10]);
} catch (ArrayIndexOutOfBoundsException e) {
    System.out.println("Invalid index accessed: " + e.getMessage());
}

Output:

Invalid index accessed: Index 10 out of bounds for length 3

Mistake 2: Catching a Superclass Before a Subclass

Because catch blocks are checked top to bottom and the first match wins, listing a broader exception type before a more specific one that it already covers is not just bad style — it’s a compile error, since the second block could never possibly run.

try {
    String s = null;
    System.out.println(s.length());
} catch (Exception e) {
    System.out.println("General error occurred.");
} catch (NullPointerException e) {
    System.out.println("Null pointer error occurred.");
}

This fails to compile with an error similar to “exception NullPointerException has already been caught by alternative Exception”, because NullPointerException is a subtype of Exception and the first catch already matches it. Always order catch blocks from most specific to most general:

try {
    String s = null;
    System.out.println(s.length());
} catch (NullPointerException e) {
    System.out.println("Null pointer error occurred.");
} catch (Exception e) {
    System.out.println("General error occurred.");
}

Output:

Null pointer error occurred.

Best Practices

  • Catch the most specific exception type you can meaningfully handle; avoid catching bare Exception or Throwable unless you’re at a top-level boundary that must not crash.
  • Never leave a catch block empty — at minimum log the exception, including its stack trace, so failures are diagnosable.
  • Use try-with-resources (try (Resource r = ...) { ... }) for anything that implements AutoCloseable, such as streams and connections, instead of manually closing in finally.
  • Only make an exception checked if callers can realistically do something useful in response; otherwise prefer an unchecked RuntimeException subtype so you don’t force boilerplate on every caller.
  • Include useful context in exception messages (what value, what operation) rather than generic text like “error occurred”.
  • Don’t use exceptions for ordinary control flow (e.g. using an exception to break out of a loop on a normal condition) — they are relatively expensive and make code harder to follow.
  • Preserve the original cause when wrapping an exception in another one, using the constructor that takes a cause parameter, so the root error isn’t lost.

Practice Exercises

Exercise 1: Write a program that uses Scanner to read two integers from the user and divides the first by the second. Wrap the division in a try/catch that handles both ArithmeticException (division by zero) and java.util.InputMismatchException (non-numeric input), printing a clear message for each and looping until the user enters valid values.

Exercise 2: Create a checked exception named InvalidAgeException. Write a method validateAge(int age) that throws it when age is negative or greater than 150, and prints a confirmation message otherwise. Call it from main with a few different ages inside a try/catch.

Exercise 3: Take a method that reads a list of strings and converts each to an integer using Integer.parseInt inside a loop. Add exception handling so that one bad entry (like "twelve") is skipped with a printed warning instead of crashing the whole loop, and the program still prints the sum of all valid numbers at the end.

Summary

  • An exception is an object representing an error, thrown when something disrupts normal program flow.
  • All exceptions descend from Throwable, which splits into Error (rarely caught) and Exception.
  • RuntimeException subtypes are unchecked (no compiler enforcement); everything else under Exception is checked and must be caught or declared with throws.
  • try wraps risky code, catch handles specific failure types (checked most-specific to least-specific), and finally always runs for cleanup.
  • The JVM uses a compiled exception table and stack unwinding to find a matching handler; the stack trace is captured when the exception object is constructed.
  • Custom exceptions, created by extending Exception or RuntimeException, let you model domain-specific failures precisely.
  • Never swallow exceptions silently, order catch blocks correctly, and prefer try-with-resources for cleanup.