Skip to content

07 - Exceptions

What this session is

About an hour. You'll learn how Java handles things going wrong - files that don't exist, numbers that can't be parsed, division by zero. Java uses exceptions that fly up the call stack until caught. You'll also meet checked vs unchecked exceptions (Java's controversial design choice) and try-with-resources, the modern way to handle cleanup.

A small example

public class Boom {
    public static void main(String[] args) {
        int n = Integer.parseInt("hello");    // boom
        System.out.println(n);
    }
}

Compile and run. You'll see:

Exception in thread "main" java.lang.NumberFormatException: For input string: "hello"
    at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
    at java.base/java.lang.Integer.parseInt(Integer.java:661)
    at java.base/java.lang.Integer.parseInt(Integer.java:777)
    at Boom.main(Boom.java:3)

NumberFormatException is the type of exception. The message says what happened. The stack trace below shows the call chain. Read it bottom-up - the last line (Boom.main(Boom.java:3)) is where the chain started.

If nothing catches the exception, the program crashes.

Catching: try/catch

public class SafeParse {
    public static void main(String[] args) {
        try {
            int n = Integer.parseInt("hello");
            System.out.println("got " + n);
        } catch (NumberFormatException e) {
            System.out.println("not a number: " + e.getMessage());
        }
    }
}

How it reads: - Try the code in the try block. - If it throws NumberFormatException, jump to the catch block. e is the exception object. - If no exception, skip the catch.

e.getMessage() gets the human-readable message. e.printStackTrace() dumps the stack trace (useful for debugging; avoid in production - log the exception properly instead).

Catching multiple exception types

Multiple catch clauses (first match wins, top to bottom):

try {
    risky();
} catch (NumberFormatException e) {
    handleBadNumber(e);
} catch (IOException e) {
    handleIoProblem(e);
} catch (Exception e) {
    System.out.println("something else went wrong: " + e);
}

Multi-catch (Java 7+) - same handling for several types:

try {
    risky();
} catch (NumberFormatException | IllegalStateException e) {
    handleEither(e);
}

finally: code that always runs

try {
    doSomething();
} catch (Exception e) {
    handle(e);
} finally {
    cleanup();    // runs whether or not an exception occurred
}

finally is for cleanup that must happen no matter what - closing files, releasing locks, restoring state. Use sparingly; the modern try-with-resources (below) usually replaces it.

try-with-resources: automatic cleanup

For anything that needs to be closed after use (files, network sockets, database connections), use try-with-resources:

import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;

public class ReadFile {
    public static void main(String[] args) {
        try (BufferedReader reader = new BufferedReader(new FileReader("notes.txt"))) {
            String line;
            while ((line = reader.readLine()) != null) {
                System.out.println(line);
            }
        } catch (IOException e) {
            System.out.println("couldn't read: " + e.getMessage());
        }
    }
}

The resource declared in the try (...) parens is automatically closed when the block exits - whether normally or via exception. No finally needed.

Multiple resources, semicolon-separated:

try (var in = new FileReader("input.txt");
     var out = new FileWriter("output.txt")) {
    // copy
}

Any class implementing AutoCloseable works with try-with-resources. Almost all standard I/O classes do.

Use try-with-resources for everything that needs closing. It's safer than manual close + finally and reads cleaner.

Checked vs unchecked: Java's controversial design

Java has two families of exceptions:

  • Checked exceptions - extend Exception but not RuntimeException. The compiler forces you to handle them (or declare you throw them). Examples: IOException, SQLException.
  • Unchecked exceptions - extend RuntimeException. No compiler enforcement. Examples: NullPointerException, IllegalArgumentException, NumberFormatException, IndexOutOfBoundsException.
// Unchecked - compiler doesn't force anything:
public int parseAge(String s) {
    return Integer.parseInt(s);    // can throw NumberFormatException; no compile error
}

// Checked - compiler forces declaration:
public String readConfig(String path) throws IOException {
    return Files.readString(Path.of(path));    // throws IOException; must declare
}

To call a method that throws a checked exception, you must either: 1. Catch it. 2. Declare your own method throws the same exception, passing the obligation upward.

public String readConfig(String path) throws IOException {
    return Files.readString(Path.of(path));
}

public void useConfig() {
    try {
        String text = readConfig("conf.json");
        // use text
    } catch (IOException e) {
        System.err.println("config unreadable: " + e.getMessage());
    }
}

The debate: checked exceptions force you to think about failures (good!) but also create unwanted noise when you don't have a sensible recovery (bad!). Modern Java practice:

  • Use unchecked exceptions for almost everything in your own code. Extend RuntimeException for custom errors.
  • Tolerate checked exceptions when consuming standard-library APIs that use them (IOException, SQLException).
  • Don't propagate checked exceptions up through many layers. Wrap them as unchecked at the boundary.

Raising your own exceptions

public void withdraw(double amount) {
    if (amount <= 0) {
        throw new IllegalArgumentException("amount must be positive: " + amount);
    }
    if (amount > balance) {
        throw new IllegalStateException("insufficient funds");
    }
    balance -= amount;
}

Use existing exception types when they fit: - IllegalArgumentException - bad argument value. - IllegalStateException - operation not legal in current state. - NullPointerException - usually let Java throw this for you. - UnsupportedOperationException - method not implemented or not allowed.

Define your own when callers should catch this specific kind of failure:

public class InsufficientFundsException extends RuntimeException {
    public InsufficientFundsException(String message) {
        super(message);
    }
}

Extending RuntimeException (not Exception) keeps it unchecked.

Common exceptions you'll meet

Exception Meaning
NullPointerException (NPE) called a method on null
ArrayIndexOutOfBoundsException array index out of range
IndexOutOfBoundsException similar, for List.get(n) etc.
ClassCastException tried to cast to wrong type
NumberFormatException Integer.parseInt got bad text
IllegalArgumentException bad argument
IllegalStateException wrong state for the operation
IOException I/O failed (checked)
RuntimeException base of all unchecked
Exception base of all (use sparingly)

The classic NPE

NullPointerException is Java's most-shipped bug. It happens when you call a method on a null reference:

String s = getMessage();   // returns null sometimes
int len = s.length();      // NPE if s was null

Modern fixes: - Objects.requireNonNull(s, "s") - fail loudly with a useful message if null is passed. - Optional<String> - explicit "value may or may not be there" type. - @NonNull annotations (JSR 305, Checker Framework, IntelliJ inspections) - static checks that catch potential NPEs at compile time.

Java doesn't have null-safety baked in (like Kotlin does). The discipline: be explicit about which references can be null and check at boundaries.

Exercise

In a new file Parse.java:

  1. Write a method parsePositive(String s) that returns an int:
  2. Parse s as an integer using Integer.parseInt.
  3. If parsing fails, catch the NumberFormatException and throw a new IllegalArgumentException with a useful message like "not a number: " + s.
  4. If the parsed number is ≤ 0, throw IllegalArgumentException with "must be positive, got " + n.
  5. Otherwise return the number.

  6. In main, loop over these inputs and print success or error for each:

    String[] inputs = {"42", "hello", "-5", "0", "100"};
    
    Use try-catch around each call to parsePositive. Format: 42 -> 42 for success, hello -> error: not a number: hello for failure.

  7. Stretch: create your own BadInputException extends RuntimeException. Have parsePositive throw it instead of IllegalArgumentException. Update main's catch accordingly.

What you might wonder

"When should I make my exception checked vs unchecked?" Modern advice: unchecked, unless you have a very strong reason. Checked forces callers to handle, which sounds good but in practice produces "swallow the exception" anti-patterns. The standard library still uses checked exceptions (IOException etc.) for historical reasons; you'll have to deal with them.

"What's the difference between throw and throws?" throw (verb) raises an exception: throw new RuntimeException("bad");. throws (clause) declares that a method might throw a checked exception: void foo() throws IOException { ... }. Easy to confuse; read aloud as "I throw" vs "I throws".

"Should I ever catch Throwable or Error?" No. Throwable is the root of both Exception and Error. Error (OutOfMemoryError, StackOverflowError) means "the JVM is in trouble" - there's nothing useful you can do; let it propagate and crash. Catch Exception at most.

"What about try without catch but with finally?" Legal: try { ... } finally { ... }. Useful when you want cleanup but can't actually handle the exception meaningfully. Almost always replaced by try-with-resources in modern code.

Done

You can now: - Read stack traces (bottom-up). - Catch exceptions with try/catch. - Use multi-catch and finally. - Use try-with-resources for automatic cleanup. - Distinguish checked from unchecked exceptions. - Raise your own exceptions. - Define custom exception types.

Next page: modern Java's superpowers - records, sealed classes, pattern matching.

Next: Records, sealed, pattern matching →

Comments