Lesson 17 of 25

Exception Handling

What an Exception Is, and Why Java Has Them

An exception is Java's way of saying that something went wrong at a point where the code cannot sensibly continue. A file was not there. A network call timed out. A user typed letters where digits were expected. When it happens, Java creates an object describing the problem, abandons the rest of the current method, and hands that object up to whoever called it — repeatedly, until something catches it or the program stops with a stack trace.

The older alternative, still used in C, is to return a special value meaning failure. That approach has two flaws. Callers can ignore the return value entirely and nothing forces them to check, so failures pass silently. And every function ends up with error checks tangled through the normal logic, making both harder to read.

Exceptions separate the two. The try block holds the normal path, written as though nothing will go wrong. The catch block holds the recovery. A failure cannot be silently ignored — it either gets handled or it travels up and stops the program loudly, which is far better than continuing with wrong data.

Learn to read a stack trace rather than fearing it. The first line names the exception type and its message. Each line beginning with at is a method call, most recent first, with the file and line number. The top at line is where the exception was thrown; the topmost line that mentions your class is almost always where the mistake is. Reading it properly turns most debugging into a ten-second job.

Example
public class Trace {
    public static void main(String[] args) {
        int[] marks = {90, 85};
        System.out.println(average(marks, 5));
    }

    static double average(int[] marks, int count) {
        int total = 0;
        for (int i = 0; i < count; i++) {
            total += marks[i];              // fails when i reaches 2
        }
        return (double) total / count;
    }
}

// Exception in thread "main"
//   java.lang.ArrayIndexOutOfBoundsException:
//   Index 2 out of bounds for length 2
//     at Trace.average(Trace.java:10)     <- thrown HERE
//     at Trace.main(Trace.java:4)         <- called from here

// Handling it
try {
    int result = 10 / 0;
    System.out.println("never reached");
} catch (ArithmeticException e) {
    System.out.println("Cannot divide by zero: " + e.getMessage());
}
// Cannot divide by zero: / by zero
// ...and the program carries on normally
Notes
  • An exception thrown inside a try block abandons the rest of that block immediately. Any statements after the failing line simply do not run, which is worth remembering when you are trying to work out why a variable was never assigned.

The Hierarchy: Checked, Unchecked and Error

Every exception is an object, and they all descend from Throwable. Two branches come off it, and the split determines how the compiler treats them.

Error covers problems the JVM itself is in trouble with — OutOfMemoryError, StackOverflowError. You are not expected to catch these; if memory has run out, your catch block has nothing useful to do.

Exception is everything your program should care about, and it splits again. Unchecked exceptions extend RuntimeException, and the compiler does not require you to do anything about them. They almost always signal a bug in your code: a null you did not check, an index off the end, a string that was not a number. The right response is usually to fix the bug, not to catch the exception.

Checked exceptions are everything else under Exception, and the compiler forces you to deal with them — either catch them, or declare throws on your method so the obligation moves to your caller. They represent conditions outside your control that a correct program must still cope with: the file is missing, the network is down, the database rejected the query. The compiler is asking you to have a plan.

This distinction is heavily examined, and the practical rule for writing your own is simple. Use a checked exception when the caller can reasonably recover — retry, use a fallback, ask the user again. Use an unchecked one when the caller passed you something invalid and the only real fix is to change their code.

  • Throwable — the root of everything that can be thrown
  • Error — JVM-level failures such as OutOfMemoryError and StackOverflowError; do not catch
  • RuntimeException and its subclasses — unchecked; usually bugs
  • NullPointerException, ArrayIndexOutOfBoundsException, ArithmeticException — unchecked
  • NumberFormatException, IllegalArgumentException, IllegalStateException, ClassCastException — unchecked
  • IOException, FileNotFoundException, SQLException, InterruptedException — checked; the compiler insists
  • Checked: catch it or declare throws. Unchecked: neither is required.
Example
import java.io.*;
import java.nio.file.*;

// Unchecked — compiles even with no try-catch at all
int[] a = new int[2];
// a[5] = 1;              // ArrayIndexOutOfBoundsException at run time

String s = null;
// s.length();            // NullPointerException at run time

// Integer.parseInt("abc");   // NumberFormatException at run time

// Checked — will NOT compile without handling
// String text = Files.readString(Path.of("data.txt"));
// error: unreported exception IOException; must be caught or declared to be thrown

// Option 1: catch it here
try {
    String text = Files.readString(Path.of("data.txt"));
    System.out.println(text);
} catch (IOException e) {
    System.out.println("Could not read the file: " + e.getMessage());
}

// Option 2: declare it and let the caller decide
static String load(String name) throws IOException {
    return Files.readString(Path.of(name));
}
Notes
  • FileNotFoundException extends IOException, so catching IOException catches both. That is why catch order matters: the more specific type must come first, or the compiler reports that the exception has already been caught.

Multiple Catches, finally, and Where Control Goes

A try can have several catch blocks. Java checks them top to bottom and runs the first one whose type matches, then skips the rest. Because a subclass matches its parent's catch, the specific types must be listed before the general ones — putting catch (Exception e) first makes every block below it unreachable, and the compiler says so.

When two different exceptions need identical handling, a multi-catch written with | avoids duplicating the block. The types listed must not be in a parent-child relationship with each other, and the variable is implicitly final.

finally runs whether or not an exception was thrown, and whether or not it was caught. It even runs when the try block executes a return. That makes it the correct place for cleanup that must happen no matter what — closing a file, releasing a lock, resetting a flag. The only things that prevent it are System.exit() and the JVM being killed.

One trap worth knowing: a return inside finally overrides any return or exception from the try block, silently discarding it. A method that throws an exception, then returns a value from its finally, swallows the exception entirely and nobody ever learns anything went wrong. Never put return, break or continue inside a finally.

Example
import java.io.*;

try {
    String input = "abc";
    int n = Integer.parseInt(input);
    int[] arr = new int[n];
    System.out.println(arr[10]);

} catch (NumberFormatException e) {            // most specific first
    System.out.println("Not a number: " + e.getMessage());

} catch (ArrayIndexOutOfBoundsException e) {
    System.out.println("Bad index: " + e.getMessage());

} catch (RuntimeException e) {                 // broader, so it comes later
    System.out.println("Something else went wrong: " + e);

} finally {
    System.out.println("This always runs");    // cleanup goes here
}

// Wrong order will not compile:
// catch (Exception e) { }
// catch (NumberFormatException e) { }
// error: exception NumberFormatException has already been caught

// Multi-catch: one block, several types
try {
    riskyOperation();
} catch (IOException | IllegalStateException e) {
    System.out.println("Failed: " + e.getMessage());
}

// ---- The finally-return trap ----
static int broken() {
    try {
        throw new RuntimeException("boom");
    } finally {
        return 42;          // swallows the exception completely
    }
}
// broken() returns 42 and nobody ever hears about "boom"
Notes
  • catch (Exception e) also catches every unchecked exception, including the NullPointerException caused by a typo in your own code. That turns a bug you would have found instantly into a mysterious log line. Catch the narrowest type that actually describes what you can handle.

throw, throws and Custom Exceptions

throw and throws look alike and do different jobs. throw is a statement that raises an exception right now: throw new IllegalArgumentException("..."). throws is part of a method's declaration, warning callers that this method may raise a checked exception they must deal with.

Throwing exceptions yourself is not an advanced technique — it is how you make a class defend its own rules, as the constructors and encapsulation lessons did with IllegalArgumentException. Java's built-in types cover most cases: IllegalArgumentException for a bad argument, IllegalStateException for an operation attempted at the wrong time, UnsupportedOperationException for something deliberately not implemented.

Write a custom exception when the caller needs to distinguish your failure from every other failure, or when the exception should carry extra data. InsufficientFundsException can hold how much was short, so the caller can display a useful message instead of parsing a string. Extend Exception for a checked exception and RuntimeException for an unchecked one — that choice is the whole design decision.

When you catch one exception and throw a different one, always pass the original as the cause. Every exception class has a constructor taking a cause, and the stack trace then shows both, connected by Caused by:. Discarding the cause is one of the most frustrating things you can do to whoever debugs your code later, because the real error disappears completely.

Example
// A checked exception: the caller can reasonably recover
public class InsufficientFundsException extends Exception {
    private final double shortfall;

    public InsufficientFundsException(double shortfall) {
        super(String.format("Short by Rs %.2f", shortfall));
        this.shortfall = shortfall;
    }

    public double getShortfall() { return shortfall; }
}

// An unchecked exception: the caller passed something invalid
public class InvalidAccountException extends RuntimeException {
    public InvalidAccountException(String message) {
        super(message);
    }
}

public class Account {
    private double balance = 5000;

    // 'throws' warns callers about the checked one
    public void withdraw(double amount) throws InsufficientFundsException {
        if (amount <= 0) {
            throw new IllegalArgumentException("Amount must be positive");
        }
        if (amount > balance) {
            throw new InsufficientFundsException(amount - balance);
        }
        balance -= amount;
    }
}

try {
    new Account().withdraw(8000);
} catch (InsufficientFundsException e) {
    System.out.println(e.getMessage());
    System.out.printf("Please add Rs %.2f%n", e.getShortfall());   // extra data
}

// ---- Always keep the cause when rethrowing ----
try {
    // ... database work ...
} catch (java.sql.SQLException e) {
    throw new RuntimeException("Could not save the student record", e);
    //                                                              ^ cause
}
Notes
  • Do not declare throws Exception on your methods. It forces every caller to handle everything and tells them nothing about what can actually go wrong. Declare the specific types.

try-with-resources, and Four Habits to Avoid

Anything you open must be closed: files, database connections, network sockets. Doing it in finally works but is verbose and easy to get wrong, especially when the close call itself can throw.

try-with-resources handles it. Declare the resource in brackets after try and Java closes it automatically when the block ends, however it ends. Several resources can be listed, separated by semicolons, and they are closed in reverse order of creation. The only requirement is that the class implements AutoCloseable, which every file, stream, connection and Scanner in the standard library does. Since Java 9 you may also list an existing final or effectively-final variable rather than declaring it inline.

The final part of this lesson is a short list of habits that turn exception handling from a safety net into a liability. Every one of them appears regularly in student projects, and every one of them makes a program harder to debug than having no handling at all.

Example
import java.io.*;
import java.nio.file.*;

// The old way
BufferedReader reader = null;
try {
    reader = Files.newBufferedReader(Path.of("marks.txt"));
    System.out.println(reader.readLine());
} catch (IOException e) {
    System.out.println("Read failed: " + e.getMessage());
} finally {
    if (reader != null) {
        try { reader.close(); } catch (IOException ignored) { }
    }
}

// try-with-resources: closed automatically, however the block ends
try (BufferedReader r = Files.newBufferedReader(Path.of("marks.txt"))) {
    String line;
    while ((line = r.readLine()) != null) {
        System.out.println(line);
    }
} catch (IOException e) {
    System.out.println("Read failed: " + e.getMessage());
}

// Several resources, closed in reverse order
try (BufferedReader in  = Files.newBufferedReader(Path.of("in.txt"));
     BufferedWriter out = Files.newBufferedWriter(Path.of("out.txt"))) {
    out.write(in.readLine());
} catch (IOException e) {
    e.printStackTrace();
}

// ---- The worst thing you can write ----
try {
    riskyOperation();
} catch (Exception e) {
    // nothing here
}
// The program continues as though it succeeded. Nobody will ever find this.
  • Swallowing. An empty catch block destroys the evidence. At the very least, log the exception.
  • Catching too broadly. catch (Exception e) hides your own typos alongside the failure you meant to handle.
  • Losing the cause. Rethrowing without passing the original exception deletes the real stack trace.
  • Using exceptions for normal flow. Catching ArrayIndexOutOfBoundsException to detect the end of a loop is slow and unreadable — test the condition instead.
  • Catching Error or Throwable. There is nothing sensible your code can do about OutOfMemoryError.
  • Handling too early. A low-level method usually does not know what the right recovery is. Let the exception travel up to the layer that does.
Notes
  • e.printStackTrace() is fine while learning but does not belong in real software — it writes to the console and vanishes. Production code uses a logging framework so the failure is recorded with a timestamp, a severity level and the surrounding context.
  • Never catch an exception just to print "error" and carry on. Either recover meaningfully, or let it propagate. A program that stops with a clear stack trace is much easier to fix than one that continues with corrupted data.
Ask AI