Mastering Java Exception Handling, Part 1: Problem Detection and the Basics of Try-Catch

Before we jump into exception handling, let’s first understand problem detection in Java. This can be categorized into two distinct phases. Java reports issues in two distinct phases of a program’s lifecycle:

  • Compile Time: When the Java compiler (javac) processes your source code into bytecode, problems here prevent the program from even running.

  • Runtime: When the compiled bytecode runs on the JVM, problems here occur during execution.

Each phase has its own way of signaling that “something is wrong,” and these signals aren’t limited to just “errors” and “exceptions.” Let’s explore both.

Compile-Time Issues

At compile time, Java reports problems as compile-time errors. These are issues in your source code that the compiler detects and refuses to let pass until fixed. They’re not “exceptions” because exceptions are runtime constructs—compile-time errors stop the program from reaching runtime. These include syntax errors, semantic errors, access/modifier errors, exception handling errors, and more.

The important one for us here is the exception handling error, which is generated at compile time. We should not confuse this with actual exceptions. Exceptions and errors, which are thrown and caught, are all generated at runtime as instances of the Throwable class. We’ll discuss them later in this article. For now, understand that an exception handling error occurs when the compiler detects a “checked exception” thrown in the code that is neither caught nor declared using throws in the method signature. We’ll explore this in detail below in the section on runtime issues.

Runtime Issues

At runtime, Java reports problems through exceptions and errors, both of which are instances of the Throwable class. Unlike compile-time errors, these don’t stop compilation—they manifest during program execution. They are “thrown” at runtime when the JVM runs your compiled bytecode.

Below is the hierarchy of all subclasses of Throwable, instances of which are thrown at runtime:

Throwable
├── Error (unchecked)
│   ├── OutOfMemoryError
│   ├── StackOverflowError
│   ├── NoClassDefFoundError
│   ├── LinkageError
│   └── VirtualMachineError
└── Exception
    ├── RuntimeException (unchecked)
    │   ├── NullPointerException
    │   ├── ArithmeticException
    │   ├── ArrayIndexOutOfBoundsException
    │   ├── IllegalArgumentException
    │   ├── ClassCastException
    │   ├── NumberFormatException
    │   └── UnsupportedOperationException
    └── [Other Exceptions] (checked)
        ├── IOException
        │   ├── FileNotFoundException
        │   ├── EOFException
        │   └── SocketException
        ├── SQLException
        ├── ClassNotFoundException
        ├── InterruptedException
        ├── ParseException
        └── NoSuchMethodException

As these exceptions or errors are “thrown,” they are meant to be “caught” or handled in the code itself. Java provides the try-catch block for this purpose. When an exception occurs without a try-catch handler, Java’s runtime system takes over, stops executing the current method, prints a stack trace (a detailed error message showing where things went wrong), and terminates the program unless the exception is caught higher up in the call stack. This is Java’s default behavior for unhandled exceptions, and it’s not pretty. Below, we detail the try-catch block.

The Basics of Try-Catch

The try-catch block is a two-part structure:

  • Try Block: This is where you place code that might throw an exception. Java monitors this section, ready to intervene if something goes wrong.

  • Catch Block: If an exception occurs in the try block, the catch block handles it. You specify the type of exception you’re prepared to deal with, and Java transfers control if that type (or a subtype) is thrown.

Here’s a simple example:

try {
    int result = 10 / 0; // This will throw an ArithmeticException
    System.out.println("Result: " + result);
} catch (ArithmeticException e) {
    System.out.println("Oops! You can’t divide by zero.");
}

When you run this, instead of crashing, the program prints: Oops! You can’t divide by zero. The ArithmeticException is caught, and execution continues smoothly.

Multiple Catch Blocks: Handling Different Exceptions

Sometimes, your try block might risk throwing more than one type of exception. For instance, you might read from a file (which could fail with an IOException) and perform some math (which might trigger an ArithmeticException). Java allows you to stack multiple catch blocks to handle these scenarios individually.

Here’s how it looks:

try {
    String text = null;
    int length = text.length(); // This will throw a NullPointerException
    int result = 10 / 0; // This will throw an ArithmeticException
} catch (NullPointerException e) {
    System.out.println("Caught a null reference: " + e.getMessage());
} catch (ArithmeticException e) {
    System.out.println("Caught a math error: " + e.getMessage());
}

In this case, since text is null, the NullPointerException is thrown first, and the corresponding catch block runs, printing: Caught a null reference: null. The ArithmeticException doesn’t get a chance to fire because execution exits the try block after the first exception.

Key Point: Java executes only the first catch block that matches the thrown exception. Once caught, the remaining catch blocks are skipped, and the program moves on.

Exception Hierarchy: Order Matters

Exceptions in Java aren’t just a flat list—they’re organized in a hierarchy with Throwable at the top, splitting into Error and Exception. The Exception branch includes common classes like IOException, NullPointerException, and ArithmeticException, many of which are subclasses of RuntimeException.

This hierarchy is critical when using multiple catch blocks. Java matches exceptions based on their type and respects inheritance. If you catch a parent class, it also catches all its subclasses. For example, catching Exception will snag any exception derived from it, like ArithmeticException or NullPointerException.

Here’s an example where hierarchy comes into play:

try {
    int[] numbers = new int[2];
    numbers[5] = 10; // This throws an ArrayIndexOutOfBoundsException
} catch (ArrayIndexOutOfBoundsException e) {
    System.out.println("Array index issue: " + e.getMessage());
} catch (Exception e) {
    System.out.println("Some other exception: " + e.getMessage());
}

This works fine—the ArrayIndexOutOfBoundsException is caught by its specific block. But what happens if we flip the order?

try {
    int[] numbers = new int[2];
    numbers[5] = 10; // This throws an ArrayIndexOutOfBoundsException
} catch (Exception e) {
    System.out.println("Caught a general exception: " + e.getMessage());
} catch (ArrayIndexOutOfBoundsException e) {
    System.out.println("This won’t run!");
}

Here, you’ll get a compiler error: error: exception ArrayIndexOutOfBoundsException has already been caught. Why? Because Exception is a superclass of ArrayIndexOutOfBoundsException. If Exception comes first, it catches everything under it, making the more specific catch block unreachable.

Rule of Thumb: Always order catch blocks from most specific to most general. Start with subclasses (like ArithmeticException or NullPointerException) and end with parent classes (like Exception) if you need a catch-all.

A Real-World Example

Let’s tie it together with a practical scenario—reading from an array and doing some division:

public class Example {
    public static void main(String[] args) {
        try {
            int[] numbers = {1, 2};
            int value = numbers[2]; // Out of bounds
            int result = value / 0; // Division by zero
            System.out.println("Result: " + result);
        } catch (ArrayIndexOutOfBoundsException e) {
            System.out.println("Array access failed: " + e.getMessage());
        } catch (ArithmeticException e) {
            System.out.println("Math error: " + e.getMessage());
        } catch (Exception e) {
            System.out.println("Unexpected issue: " + e.getMessage());
        }
    }
}

Output: Array access failed: Index 2 out of bounds for length 2. The ArrayIndexOutOfBoundsException is caught first, and the program skips the other catch blocks. If we fixed the array access but kept the division by zero, it would hit the ArithmeticException block instead.